package pocketbase import ( "errors" "fmt" "io" "os" "path/filepath" "github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/tools/filesystem" "git.vakhrushev.me/av/transcriber/internal/contract" "git.vakhrushev.me/av/transcriber/internal/entity" "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations" ) // workFile — рабочая копия файла на диске. Живёт во временном каталоге // системы, а не в каталоге данных: последний смонтирован на сервере, и // временному там не место. type workFile struct { path string } func (w *workFile) Path() string { return w.path } func (w *workFile) Size() (int64, error) { info, err := os.Stat(w.path) if err != nil { return 0, fmt.Errorf("failed to stat work file: %w", err) } return info.Size(), nil } // Close убирает копию. Отсутствие файла отказом не считается: шаг мог не дойти // до его создания, и повторный Close тоже законен. func (w *workFile) Close() error { if err := os.Remove(w.path); err != nil && !os.IsNotExist(err) { return fmt.Errorf("failed to remove work file: %w", err) } return nil } type FileRepository struct { app core.App } func NewFileRepository(app core.App) *FileRepository { return &FileRepository{app: app} } // newWorkFile заводит пустую копию во временном каталоге. Расширение сохраняется // в имени: `ffprobe` и `ffmpeg` по нему выбирают разбор. func newWorkFile(ext string) (*workFile, error) { f, err := os.CreateTemp("", "transcriber-*"+ext) if err != nil { return nil, fmt.Errorf("failed to create work file: %w", err) } path := f.Name() if err := f.Close(); err != nil { _ = os.Remove(path) return nil, fmt.Errorf("failed to close work file: %w", err) } return &workFile{path: path}, nil } func (repo *FileRepository) StageEmpty(ext string) (contract.WorkFile, error) { return newWorkFile(ext) } func (repo *FileRepository) Stage(ext string, content io.Reader) (contract.WorkFile, error) { work, err := newWorkFile(ext) if err != nil { return nil, err } if err := writeTo(work.path, content); err != nil { // Отказ уборки не подменяет отказ записи, но и не теряется. return nil, errors.Join(err, work.Close()) } return work, nil } func (repo *FileRepository) Localize(fileID string) (contract.WorkFile, error) { record, err := repo.app.FindRecordById(migrations.FilesCollection, fileID) if err != nil { return nil, fmt.Errorf("failed to find file %s: %w", fileID, err) } name := firstFileName(record) if name == "" { return nil, fmt.Errorf("file %s has no content in storage", fileID) } work, err := newWorkFile(filepath.Ext(name)) if err != nil { return nil, err } src, err := repo.openStored(record, name) if err != nil { return nil, errors.Join(err, work.Close()) } defer src.Close() if err := writeTo(work.path, src); err != nil { return nil, errors.Join(err, work.Close()) } return work, nil } // CreateLocal кладёт рабочую копию в хранилище. Имя задаём мы: умолчание // библиотеки строит его из имени, данного отправителем, а имя отправителя в // хранилище не попадает — путь к файлу читается в журнале, и инвариант // приватности этого не допускает. Свой суффикс хранилище допишет само. func (repo *FileRepository) CreateLocal(name string, work contract.WorkFile, ownerID string) (*entity.File, error) { collection, err := findCollection(repo.app, migrations.FilesCollection) if err != nil { return nil, err } stored, err := filesystem.NewFileFromPath(work.Path()) if err != nil { return nil, fmt.Errorf("failed to read work file: %w", err) } stored.Name = name record := core.NewRecord(collection) record.Set("file", stored) record.Set("location", entity.LocationLocal) record.Set("size", stored.Size) // Владелец файла — владелец записи, которой файл принадлежит. Пустой значит // «файл без владельца»: таков всякий файл записи, принятой ботом. Правило // просмотра коллекции сужено этой колонкой, и без неё чужое аудио осталось // бы доступным всякому вошедшему. record.Set("owner", ownerID) if err := repo.app.Save(record); err != nil { // Отказ укладки называет имя файла — то самое, из которого строится // ссылка на скачивание. В цепочку оно не идёт по той же причине, что и // ключ при чтении. return nil, errors.New("failed to store file") } return recordToFile(record), nil } func (repo *FileRepository) CreateRemote(objectKey string, size int64, ownerID string) (*entity.File, error) { collection, err := findCollection(repo.app, migrations.FilesCollection) if err != nil { return nil, err } record := core.NewRecord(collection) record.Set("location", entity.LocationS3) record.Set("object_key", objectKey) record.Set("size", size) record.Set("owner", ownerID) if err := repo.app.Save(record); err != nil { return nil, fmt.Errorf("failed to store remote file record: %w", err) } return recordToFile(record), nil } func (repo *FileRepository) GetByID(id string) (*entity.File, error) { record, err := repo.app.FindRecordById(migrations.FilesCollection, id) if err != nil { return nil, fmt.Errorf("failed to get file: %w", err) } return recordToFile(record), nil } func (repo *FileRepository) Open(fileID string) (io.ReadCloser, error) { record, err := repo.app.FindRecordById(migrations.FilesCollection, fileID) if err != nil { return nil, fmt.Errorf("failed to find file %s: %w", fileID, err) } name := firstFileName(record) if name == "" { return nil, fmt.Errorf("file %s has no content in storage", fileID) } return repo.openStored(record, name) } // openStored открывает содержимое файла в хранилище потоком. func (repo *FileRepository) openStored(record *core.Record, name string) (io.ReadCloser, error) { fsys, err := repo.app.NewFilesystem() if err != nil { return nil, fmt.Errorf("failed to open storage filesystem: %w", err) } reader, err := fsys.GetReader(record.BaseFilesPath() + "/" + name) if err != nil { // Отказ хранилища несёт ключ файла целиком, а ключ — последняя часть // ссылки `/api/files/...`, по которой запись скачивают. Наружу отдаётся // идентификатор записи, и только он: цепочка `%w` уехала бы в журнал и // стала бы там бессрочным ключом к чужому аудио. return nil, errors.Join( fmt.Errorf("failed to read stored file of record %s", record.Id), fsys.Close(), ) } return &storedReader{reader: reader, fsys: fsys}, nil } // storedReader держит открытой файловую систему хранилища на всё время чтения: // закрытая раньше времени, она обрывает поток на середине записи. type storedReader struct { reader io.ReadCloser fsys io.Closer } func (r *storedReader) Read(p []byte) (int, error) { return r.reader.Read(p) } func (r *storedReader) Close() error { readerErr := r.reader.Close() fsysErr := r.fsys.Close() switch { case readerErr != nil && fsysErr != nil: return errors.New("failed to close stored file and its filesystem") case readerErr != nil: return errors.New("failed to close stored file") default: return fsysErr } } // writeTo переливает содержимое в файл потоком. В память запись целиком не // читается: расчётный потолок — шесть часов. func writeTo(path string, content io.Reader) error { dst, err := os.Create(path) if err != nil { return fmt.Errorf("failed to open work file: %w", err) } if _, err := io.Copy(dst, content); err != nil { _ = dst.Close() return fmt.Errorf("failed to write work file: %w", err) } if err := dst.Close(); err != nil { return fmt.Errorf("failed to close work file: %w", err) } return nil } func firstFileName(record *core.Record) string { names := record.GetStringSlice("file") if len(names) == 0 { return "" } return names[0] } func recordToFile(record *core.Record) *entity.File { name := firstFileName(record) if name == "" { name = record.GetString("object_key") } return &entity.File{ Id: record.Id, Location: record.GetString("location"), FileName: name, Size: int64(record.GetInt("size")), CreatedAt: record.GetDateTime("created").Time(), } }