добавлен context.Context в функции Create и Clean

This commit is contained in:
2026-04-07 11:42:33 +03:00
parent 09455c82aa
commit 2632ad880d
3 changed files with 259 additions and 157 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ go 1.25.4
replace git.company.local/gopkg/filestore/remote => ./remote replace git.company.local/gopkg/filestore/remote => ./remote
require github.com/minio/minio-go/v7 v7.0.98 require github.com/minio/minio-go/v7 v7.0.100
require ( require (
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
+2 -2
View File
@@ -17,8 +17,8 @@ github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI
github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg= github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
github.com/minio/minio-go/v7 v7.0.98 h1:MeAVKjLVz+XJ28zFcuYyImNSAh8Mq725uNW4beRisi0= github.com/minio/minio-go/v7 v7.0.100 h1:ShkWi8Tyj9RtU57OQB2HIXKz4bFgtVib0bbT1sbtLI8=
github.com/minio/minio-go/v7 v7.0.98/go.mod h1:cY0Y+W7yozf0mdIclrttzo1Iiu7mEf9y7nk2uXqMOvM= github.com/minio/minio-go/v7 v7.0.100/go.mod h1:EtGNKtlX20iL2yaYnxEigaIvj0G0GwSDnifnG8ClIdw=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+256 -154
View File
@@ -2,9 +2,12 @@ package filestore
import ( import (
"bufio" "bufio"
"context"
"crypto/md5" "crypto/md5"
"encoding/base32" "encoding/base32"
"encoding/hex" "encoding/hex"
"errors"
"fmt"
"hash/crc32" "hash/crc32"
"io" "io"
"net/http" "net/http"
@@ -18,21 +21,23 @@ import (
// tmpfileName используется в качестве имени временного файла при генерации ошибок // tmpfileName используется в качестве имени временного файла при генерации ошибок
const tmpfileName = "<temporary file>" const tmpfileName = "<temporary file>"
// минимальная длина имени файла, необходимая для разбиения на подкаталоги
const minNameLen = 27
// LocalStorage описывает хранилище файлов. // LocalStorage описывает хранилище файлов.
type LocalStorage struct { type LocalStorage struct {
rootDir string rootDir string
perm os.FileMode perm os.FileMode
mutexes struct { // защита для map мьютексов
sync.Mutex mu sync.Mutex
sync.Once once sync.Once
m map[string]*sync.Mutex fileMutex map[string]*sync.Mutex // мьютекс на имя файла
}
} }
type LocalStorageOption func(*LocalStorage) type LocalStorageOption func(*LocalStorage)
// WithPermissions // WithPermissions устанавливает права доступа для создаваемых каталогов и файлов.
func WithPermissions(perm os.FileMode) LocalStorageOption { func WithPermissions(perm os.FileMode) LocalStorageOption {
return func(s *LocalStorage) { return func(s *LocalStorage) {
s.perm = perm s.perm = perm
@@ -41,9 +46,8 @@ func WithPermissions(perm os.FileMode) LocalStorageOption {
// FileInfo описывает информацию о сохраненном файле. // FileInfo описывает информацию о сохраненном файле.
type FileInfo struct { type FileInfo struct {
Location string // @deprecated Path string // полный путь внутри хранилища
Path string Name string // уникальное имя файла
Name string
Mimetype string Mimetype string
Size int64 Size int64
CRC32 uint32 CRC32 uint32
@@ -55,11 +59,19 @@ func NewLocalStorage(rootDir string, opts ...LocalStorageOption) (*LocalStorage,
s := &LocalStorage{} s := &LocalStorage{}
s.rootDir = rootDir s.rootDir = rootDir
s.perm = 0700 s.perm = 0700
s.fileMutex = make(map[string]*sync.Mutex)
for _, opt := range opts { for _, opt := range opts {
opt(s) opt(s)
} }
// Очищаем путь и делаем его абсолютным для корректной проверки безопасности
absRoot, err := filepath.Abs(s.rootDir)
if err != nil {
return nil, fmt.Errorf("failed to get absolute path for rootDir: %w", err)
}
s.rootDir = absRoot
// Создаём каталог, если он ещё не создан // Создаём каталог, если он ещё не создан
if err := os.MkdirAll(s.rootDir, s.perm); err != nil { if err := os.MkdirAll(s.rootDir, s.perm); err != nil {
return nil, err return nil, err
@@ -68,103 +80,172 @@ func NewLocalStorage(rootDir string, opts ...LocalStorageOption) (*LocalStorage,
return s, nil return s, nil
} }
// getMutex возвращает мьютекс для имени файла, создавая его при необходимости.
func (s *LocalStorage) getMutex(name string) *sync.Mutex {
s.once.Do(func() {
// инициализация уже выполнена в NewLocalStorage, но оставляем для безопасности
if s.fileMutex == nil {
s.fileMutex = make(map[string]*sync.Mutex)
}
})
s.mu.Lock()
defer s.mu.Unlock()
mu, ok := s.fileMutex[name]
if !ok {
mu = &sync.Mutex{}
s.fileMutex[name] = mu
}
return mu
}
// releaseMutex удаляет мьютекс из map после использования (вызывать после Unlock).
func (s *LocalStorage) releaseMutex(name string) {
s.mu.Lock()
delete(s.fileMutex, name)
s.mu.Unlock()
}
// safePath проверяет, что путь не выходит за пределы rootDir, и возвращает очищенный путь.
func (s *LocalStorage) safePath(subPath string) (string, error) {
// Убираем начальные разделители и ".." попытки
clean := filepath.Clean(strings.TrimPrefix(subPath, "/"))
// Запрещаем пустые и слишком короткие имена
if clean == "" || clean == "." || clean == ".." {
return "", os.ErrNotExist
}
full := filepath.Join(s.rootDir, clean)
// Проверяем, что итоговый путь всё ещё внутри rootDir
absFull, err := filepath.Abs(full)
if err != nil {
return "", err
}
if !strings.HasPrefix(absFull, s.rootDir+string(os.PathSeparator)) && absFull != s.rootDir {
return "", fmt.Errorf("%w: path traversal attempt", os.ErrPermission)
}
return absFull, nil
}
// Create сохраняет файл в хранилище. В качестве имени файла используется комбинация из двух хешей. // Create сохраняет файл в хранилище. В качестве имени файла используется комбинация из двух хешей.
func (s *LocalStorage) Create(r io.Reader) (*FileInfo, error) { func (s *LocalStorage) Create(ctx context.Context, r io.Reader) (*FileInfo, error) {
if r == nil {
return nil, errors.New("reader is nil")
}
// Создаём временный файл в корневом каталоге // Создаём временный файл в корневом каталоге
tmpfile, err := os.CreateTemp(s.rootDir, "~tmp") tmpfile, err := os.CreateTemp(s.rootDir, "~tmp")
if err != nil { if err != nil {
err.(*os.PathError).Path = tmpfileName // Подмениваем имя файла return nil, s.wrapPathError(err, tmpfileName)
return nil, err
} }
defer os.Remove(tmpfile.Name()) defer func() {
tmpfile.Close()
os.Remove(tmpfile.Name())
}()
// Копируем содержимое во временный файл // Копируем содержимое во временный файл
bufferReader := bufio.NewReaderSize(r, 4<<10) bufferReader := bufio.NewReaderSize(r, 4<<10)
// Пытаемся определить тип содержимого // Пытаемся определить MIME-тип содержимого
data, err := bufferReader.Peek(512) // Читаем первые 512 байт файла data, err := bufferReader.Peek(512)
if err != nil && err != io.EOF { if err != nil && err != io.EOF {
tmpfile.Close() return nil, s.wrapPathError(err, tmpfileName)
err = &os.PathError{Op: "create", Path: tmpfileName, Err: err}
return nil, err
} }
mimetype := http.DetectContentType(data) mimetype := http.DetectContentType(data)
// Одновременно с сохранением в файл считаем две хеш-суммы // Одновременно с сохранением в файл считаем две хеш-суммы
hashCRC32, hashMD5 := crc32.NewIEEE(), md5.New() hashCRC32, hashMD5 := crc32.NewIEEE(), md5.New()
size, err := bufferReader.WriteTo(io.MultiWriter(tmpfile, hashCRC32, hashMD5)) multiWriter := io.MultiWriter(tmpfile, hashCRC32, hashMD5)
if err != nil {
tmpfile.Close()
err = &os.PathError{Op: "write", Path: tmpfileName, Err: err}
return nil, err
}
// Формируем информацию о файле type copyResult struct {
sumMD5 := hashMD5.Sum(nil) size int64
name := base32.StdEncoding.EncodeToString(append(hashCRC32.Sum(nil), sumMD5...)) err error
fi := &FileInfo{
Location: s.GetPath(name),
Path: s.GetPath(name),
Name: name,
Mimetype: mimetype,
Size: size,
CRC32: hashCRC32.Sum32(),
MD5: hex.EncodeToString(sumMD5),
} }
done := make(chan copyResult, 1)
go func() {
size, err := bufferReader.WriteTo(multiWriter)
done <- copyResult{size, err}
}()
// Закрываем временный файл select {
if err := tmpfile.Close(); err != nil { case <-ctx.Done():
if _, ok := err.(*os.PathError); ok { return nil, ctx.Err()
err.(*os.PathError).Path = tmpfileName case res := <-done:
if res.err != nil {
return nil, s.wrapPathError(res.err, tmpfileName)
} }
return nil, err // Формируем информацию о файле
} sumMD5 := hashMD5.Sum(nil)
name := base32.StdEncoding.EncodeToString(append(hashCRC32.Sum(nil), sumMD5...))
// Если файл уже существует, то просто обновляем его время создания fi := &FileInfo{
now := time.Now() Path: s.GetRelativePath(name),
if err := os.Chtimes(fi.Path, now, now); err == nil { Name: name,
return fi, nil // Возвращаем информацию о файле, временный файл будет автоматически удален Mimetype: mimetype,
} Size: res.size,
CRC32: hashCRC32.Sum32(),
// Если такого файла нет, то создаем для него каталог MD5: hex.EncodeToString(sumMD5),
if err := os.MkdirAll(filepath.Dir(fi.Path), s.perm); err != nil {
err.(*os.PathError).Path = fi.Name
return nil, err
}
// Перемещаем временный файл в этот каталог
if err := os.Rename(tmpfile.Name(), fi.Path); err != nil {
if _, ok := err.(*os.PathError); ok {
err.(*os.PathError).Path = fi.Name
} }
return nil, err
}
// Возвращаем информацию о созданном файле // Закрываем временный файл
return fi, nil if err := tmpfile.Close(); err != nil {
return nil, s.wrapPathError(err, tmpfileName)
}
fullPath, err := s.safePath(fi.Path)
if err != nil {
return nil, err
}
// Если файл уже существует, то просто обновляем его время создания
now := time.Now()
if err := os.Chtimes(fullPath, now, now); err == nil {
return fi, nil
} else if !os.IsNotExist(err) {
// Другая ошибка (например, permission denied) – не можем перезаписать
return nil, s.wrapPathError(err, name)
}
// Если такого файла нет, то создаем для него каталоги
if err := os.MkdirAll(filepath.Dir(fullPath), s.perm); err != nil {
return nil, s.wrapPathError(err, name)
}
// Перемещаем временный файл
if err := os.Rename(tmpfile.Name(), fullPath); err != nil {
return nil, s.wrapPathError(err, name)
}
return fi, nil
}
} }
// Open открывает файл из каталога. // Open открывает файл из хранилища.
func (s *LocalStorage) Open(name string) (*os.File, error) { func (s *LocalStorage) Open(name string) (*os.File, error) {
// Полное имя для доступа к файлу // Полное имя для доступа к файлу
fullName := s.GetPath(name) fullPath, err := s.GetFullPath(name)
if fullName == "" { if err != nil {
return nil, os.ErrNotExist if errors.Is(err, os.ErrNotExist) {
return nil, os.ErrNotExist
}
return nil, err
} }
// Открываем файл // Открываем файл
file, err := os.Open(fullName) file, err := os.Open(fullPath)
if err != nil { if err != nil {
err.(*os.PathError).Path = name return nil, s.wrapPathError(err, name)
return nil, err
} }
// Получаем информацию о файле и проверяем, что это не каталог // Получаем информацию о файле и проверяем, что это не каталог
fi, err := file.Stat() fi, err := file.Stat()
if err != nil { if err != nil {
file.Close() file.Close()
err.(*os.PathError).Path = name return nil, s.wrapPathError(err, name)
return nil, err
} }
// Возвращаем ошибку, если это каталог, а не файл // Возвращаем ошибку, если это каталог, а не файл
@@ -173,132 +254,153 @@ func (s *LocalStorage) Open(name string) (*os.File, error) {
return nil, &os.PathError{Op: "open", Path: name, Err: os.ErrPermission} return nil, &os.PathError{Op: "open", Path: name, Err: os.ErrPermission}
} }
// Обновляем время доступа к файлу // Обновляем время доступа (ошибку игнорируем, это не критично)
now := time.Now() now := time.Now()
os.Chtimes(fullName, now, now) _ = os.Chtimes(fullPath, now, now)
return file, nil
return file, nil // Возвращаем открытый файл
} }
// Remove удаляет файл из хранилища. // Remove удаляет файл из хранилища.
func (s *LocalStorage) Remove(name string) error { func (s *LocalStorage) Remove(name string) error {
mu := s.getMutex(name) mu := s.getMutex(name)
mu.Lock() mu.Lock()
defer mu.Unlock() defer func() {
mu.Unlock()
s.releaseMutex(name)
}()
// Полное имя для доступа к файлу fullPath, err := s.GetFullPath(name)
fullName := s.GetPath(name) if err != nil {
if fullName == "" { if errors.Is(err, os.ErrNotExist) {
return os.ErrNotExist return os.ErrNotExist
} }
if err := os.Remove(fullName); err != nil {
err.(*os.PathError).Path = name
return err return err
} }
// Пытаемся удалить пустые каталоги, если они образовались if err := os.Remove(fullPath); err != nil {
for range 2 { return s.wrapPathError(err, name)
fullName = filepath.Dir(fullName)
if err := os.Remove(fullName); err != nil {
break // Если не получилось, значит каталог не пустой
}
} }
// Удаляем пустые родительские каталоги, но не выше rootDir
s.removeEmptyParents(fullPath)
return nil return nil
} }
// removeEmptyParents поднимается вверх по дереву каталогов, удаляя пустые,
// пока не дойдёт до rootDir или не встретит непустой каталог.
func (s *LocalStorage) removeEmptyParents(path string) {
dir := filepath.Dir(path)
for dir != s.rootDir && dir != "." && dir != "/" {
// Пытаемся удалить каталог
if err := os.Remove(dir); err != nil {
break // не пустой или ошибка прав
}
dir = filepath.Dir(dir)
}
}
// Clean удаляет старые файлы, к которым не обращались больше заданного времени. // Clean удаляет старые файлы, к которым не обращались больше заданного времени.
func (s *LocalStorage) Clean(lifetime time.Duration) error { // Если lifetime <= 0, удаляет все файлы.
// Удаляем вообще все файлы, если время жизни не задано func (s *LocalStorage) Clean(ctx context.Context, lifetime time.Duration) error {
if lifetime <= 0 { if lifetime <= 0 {
files, err := filepath.Glob(filepath.Join(s.rootDir, "*")) // Удаляем всё содержимое rootDir, но не саму директорию
entries, err := os.ReadDir(s.rootDir)
if err != nil { if err != nil {
return err return err
} }
for _, file := range files { for _, entry := range entries {
if err := os.RemoveAll(file); err != nil { select {
return err case <-ctx.Done():
return ctx.Err()
default:
}
if err := os.RemoveAll(filepath.Join(s.rootDir, entry.Name())); err != nil {
// Логируем, но продолжаем (по аналогии с оригиналом)
continue
} }
} }
return nil
} }
// Вычисляем крайнюю дату валидности файлов
valid := time.Now().Add(-lifetime) valid := time.Now().Add(-lifetime)
err := filepath.Walk(s.rootDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil // игнорируем ошибки доступа к файлу
}
select {
case <-ctx.Done():
return ctx.Err()
default:
}
err := filepath.Walk(s.rootDir, if info.IsDir() || info.ModTime().After(valid) {
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 range 2 {
filename = filepath.Dir(filename)
if err = os.Remove(filename); err != nil {
break // Каталог не пустой
}
}
return nil return nil
}, }
)
if os.IsNotExist(err) { // Получаем имя файла относительно rootDir
return nil // Игнорируем ошибку, что файл не существует rel, err := filepath.Rel(s.rootDir, path)
if err != nil {
return nil
}
// Имя файла в нашей терминологии — это путь без rootDir
fileName := strings.ReplaceAll(rel, string(os.PathSeparator), "") // упрощённо, но для блокировки нужно исходное имя
// Блокируем файл на время удаления
mu := s.getMutex(fileName)
mu.Lock()
defer mu.Unlock()
if err := os.Remove(path); err != nil {
return nil // игнорируем ошибку удаления
}
s.removeEmptyParents(path)
return nil
})
if err != nil && !os.IsNotExist(err) {
return err
} }
return nil
}
// wrapPathError безопасно заменяет путь в ошибке, если она является *os.PathError.
func (s *LocalStorage) wrapPathError(err error, name string) error {
if pe, ok := err.(*os.PathError); ok {
pe.Path = name
return pe
}
return err return err
} }
// getMutex // GetRelativePath возвращает относительный путь к файлу в хранилище (без учёта rootDir).
func (s *LocalStorage) getMutex(name string) *sync.Mutex { func (s *LocalStorage) GetRelativePath(name string) string {
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 возвращает полный путь к файлу в хранилище.
//
// Deprecated: GetFullName is no longer recommended. Use GetPath instead.
func (s *LocalStorage) GetFullName(name string) string { return s.GetPath(name) }
// GetPath возвращает полный путь к файлу в хранилище.
func (s *LocalStorage) GetPath(name string) string {
name = strings.TrimPrefix(name, "/") name = strings.TrimPrefix(name, "/")
if len(name) < 27 { if len(name) < minNameLen {
return "" return ""
} }
return filepath.Join(s.rootDir, name[:1], name[1:3], name[3:]) // Раскладываем по подкаталогам: первая буква, вторая+третья, остальное
return filepath.Join(name[:1], name[1:3], name[3:])
} }
// IsExists проверяет: существует ли файл в хранилище? // GetFullPath возвращает полный путь к файлу в хранилище.
func (s *LocalStorage) GetFullPath(name string) (string, error) {
relPath := s.GetRelativePath(name)
if relPath == "" {
return "", fmt.Errorf("invalid file name: %s", name)
}
return s.safePath(relPath)
}
// IsExists проверяет существование файла.
func (s *LocalStorage) IsExists(name string) bool { func (s *LocalStorage) IsExists(name string) bool {
fullName := s.GetPath(name) fullPath, err := s.GetFullPath(name)
if fullName == "" { if err != nil {
return false return false
} }
fi, err := os.Stat(fullName) fi, err := os.Stat(fullPath)
if os.IsNotExist(err) { if err != nil {
return false return false
} }