diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfe0770 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Auto detect text files and perform LF normalization +* text=auto diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ed0ee63 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +### VisualStudioCode ### +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json + +### VisualStudioCode Patch ### +# Ignore all local history of files +**/.history diff --git a/README.md b/README.md index 69e2316..b747d33 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,3 @@ # filestore + Simple file store diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..b6d8c4c --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/tenrok/filestore + +go 1.23.4 diff --git a/store.go b/store.go new file mode 100644 index 0000000..84846bd --- /dev/null +++ b/store.go @@ -0,0 +1,289 @@ +package filestore + +import ( + "bufio" + "crypto/md5" + "encoding/base32" + "encoding/hex" + "hash/crc32" + "io" + "net/http" + "os" + "path/filepath" + "sync" + "time" +) + +// tmpfileName используется в качестве имени временного файла при генерации ошибок +const tmpfileName = "" + +// Store описывает хранилище файлов. +type Store struct { + root string + + mutexes struct { + sync.Mutex + sync.Once + m map[string]*sync.Mutex + } +} + +// Open открывает и возвращает хранилище файлов. +func Open(root string) (*Store, error) { + // Создаём каталог, если он ещё не создан + if err := os.MkdirAll(root, 0700); err != nil { + return nil, err + } + return &Store{root: root}, nil +} + +// FileInfo описывает информацию о сохраненном файле. +type FileInfo struct { + Location string `json:"location"` + Name string `json:"name"` + Mimetype string `json:"mimetype"` + Size int64 `json:"size"` + CRC32 uint32 `json:"crc32"` + MD5 string `json:"md5"` +} + +// Create сохраняет файл в хранилище. В качестве имени файла используется комбинация из двух хешей. Файл сохраняется в подкаталоге prefix, +// если он задан, но данный prefix не учитывается в возвращаемой информации в имени файла. +func (s *Store) Create(prefix string, r io.Reader) (*FileInfo, error) { + // Добавляем префикс к корню и создаём каталог + root := filepath.Join(s.root, prefix) + if err := os.MkdirAll(root, 0700); err != nil { + return nil, err + } + + // Создаём временный файл в корневом каталоге + tmpfile, err := os.CreateTemp(root, "~tmp") + if err != nil { + err.(*os.PathError).Path = tmpfileName // Подменяем имя файла + return nil, err + } + + // В любом случае временный файл должен быть удален, если он не был переименован, т.е. на момент окончания функции существует под + // временным именем + defer os.Remove(tmpfile.Name()) + + // Копируем содержимое во временный файл + bufferReader := bufio.NewReaderSize(r, 4<<10) + + // Пытаемся определить тип содержимого + data, err := bufferReader.Peek(512) // Читаем первые 512 байт файла + if err != nil && err != io.EOF { + tmpfile.Close() + err = &os.PathError{Op: "create", Path: tmpfileName, Err: err} + return nil, err + } + mimetype := http.DetectContentType(data) // Определяем тип содержимого + + // Одновременно с сохранением в файл считаем две хеш-суммы + hashCRC32, hashMD5 := crc32.NewIEEE(), md5.New() + size, err := bufferReader.WriteTo(io.MultiWriter(tmpfile, hashCRC32, hashMD5)) + if err != nil { + tmpfile.Close() + err = &os.PathError{Op: "write", Path: tmpfileName, Err: err} + return nil, err + } + + // Формируем информацию о файле + sumMD5 := hashMD5.Sum(nil) + name := base32.StdEncoding.EncodeToString(append(hashCRC32.Sum(nil), sumMD5...)) + fi := &FileInfo{ + Location: s.GetFullName(prefix, name), + Name: name, + Mimetype: mimetype, + Size: size, + CRC32: hashCRC32.Sum32(), + MD5: hex.EncodeToString(sumMD5), + } + + // Закрываем временный файл + if err := tmpfile.Close(); err != nil { + if _, ok := err.(*os.PathError); ok { + err.(*os.PathError).Path = tmpfileName + } + return nil, err + } + + // Если файл уже существует, то просто обновляем его время создания + now := time.Now() + if err := os.Chtimes(fi.Location, now, now); err == nil { + return fi, nil // Возвращаем информацию о файле, временный файл будет автоматически удален + } + + // Если такого файла нет, то создаем для него каталог + if err := os.MkdirAll(filepath.Dir(fi.Location), 0700); err != nil { + err.(*os.PathError).Path = fi.Name + return nil, err + } + + // Перемещаем временный файл в этот каталог + if err := os.Rename(tmpfile.Name(), fi.Location); err != nil { + if _, ok := err.(*os.PathError); ok { + err.(*os.PathError).Path = fi.Name + } + return nil, err + } + + // Возвращаем информацию о созданном файле + return fi, nil +} + +// Open открывает файл из каталога. +func (s *Store) Open(prefix, name string) (*os.File, error) { + // Полное имя для доступа к файлу + fullName := s.GetFullName(prefix, name) + if fullName == "" { + return nil, os.ErrNotExist + } + + // Открываем файл + file, err := os.Open(fullName) + if err != nil { + err.(*os.PathError).Path = name + return nil, err + } + + // Получаем информацию о файле и проверяем, что это не каталог + fi, err := file.Stat() + if err != nil { + file.Close() + err.(*os.PathError).Path = name + return nil, err + } + + // Возвращаем ошибку, если это каталог, а не файл + if fi.IsDir() { + file.Close() + return nil, &os.PathError{Op: "open", Path: name, Err: os.ErrPermission} + } + + // Обновляем время доступа к файлу + now := time.Now() + os.Chtimes(fullName, now, now) + + return file, nil // Возвращаем открытый файл +} + +// Remove удаляет файл из хранилища. +func (s *Store) Remove(prefix, name string) error { + mu := s.getMutex(name) + mu.Lock() + defer mu.Unlock() + + // Полное имя для доступа к файлу + fullName := s.GetFullName(prefix, name) + if fullName == "" { + return os.ErrNotExist + } + + if err := os.Remove(fullName); err != nil { + err.(*os.PathError).Path = name + return err + } + + // Пытаемся удалить пустые каталоги, если они образовались + for i := 0; i < 2; i++ { + fullName = filepath.Dir(fullName) + if err := os.Remove(fullName); err != nil { + break // Если не получилось, значит каталог не пустой + } + } + + return nil +} + +// Clean удаляет старые файлы, к которым не обращались больше заданного времени. +func (s *Store) Clean(lifetime time.Duration) error { + // Удаляем вообще все файлы, если время жизни не задано + if lifetime <= 0 { + files, err := filepath.Glob(filepath.Join(s.root, "*")) + if err != nil { + return err + } + for _, file := range files { + if err := os.RemoveAll(file); err != nil { + return err + } + } + } + + // Вычисляем крайнюю дату валидности файлов + valid := time.Now().Add(-lifetime) + + err := filepath.Walk(s.root, + func(filename string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + // Не удаляем каталоги и новые файлы + if info.IsDir() || info.ModTime().After(valid) { + return nil + } + + // Удаляем старый файл + if err = os.Remove(filename); err != nil { + return nil // Ошибку удаления игнорируем + } + + // Пытаемся удалить пустые каталоги + for i := 0; i < 2; i++ { + filename = filepath.Dir(filename) + if err = os.Remove(filename); err != nil { + break // Каталог не пустой + } + } + + return nil + }, + ) + + if os.IsNotExist(err) { + return nil // Игнорируем ошибку, что файл не существует + } + + return err +} + +// getMutex +func (s *Store) getMutex(name string) *sync.Mutex { + s.mutexes.Do(func() { s.mutexes.m = make(map[string]*sync.Mutex) }) + + s.mutexes.Lock() + mu, ok := s.mutexes.m[name] + if !ok { + mu = &sync.Mutex{} + s.mutexes.m[name] = mu + } + s.mutexes.Unlock() + + return mu +} + +// GetFullName возвращает полный путь к файлу в хранилище. +func (s *Store) GetFullName(prefix, name string) string { + if len(name) < 27 { + return "" + } + return filepath.Join(s.root, prefix, name[:1], name[1:3], name[3:]) +} + +// IsExists проверяет: существует ли файл в хранилище? +func (s *Store) IsExists(prefix, name string) bool { + // Полное имя файла + fullName := s.GetFullName(prefix, name) + if fullName == "" { + return false + } + + fi, err := os.Stat(fullName) + if os.IsNotExist(err) { + return false + } + + return !fi.IsDir() +}