Files
pipu-music/services/api/internal/storageagent/files.go
T
mrleo1nidandClaude Opus 5.5 f66be2ba0a
ci / api / api (push) Successful in 33s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 43s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m11s
ci / ts / ts (packages + web) (push) Successful in 1m10s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 51s
feat: storage writes via storage-agent, accepts_uploads and write_template (M5.5)
- storage-agent: POST /internal/files copies a staged file into a source
  (temp file, fsync, SHA-256 check, hard link so nothing is overwritten,
  " (2)" suffix on conflict, owner PIPU_FILE_UID/GID) and
  POST /internal/files/test. NFS sources that accept uploads are mounted
  rw in an agent-only /library-rw and bind-mounted read-only for others;
  local sources are written through /library-host-rw.
- meta: write templates ({track:02}, {disc:1-}, [optional]) with name
  sanitising and length limits, suggestion from the source's regexes
  and a reversibility check against 50 catalogue tracks.
- API and admin UI: accepts_uploads / write_template on sources,
  write-template suggest and check endpoints, write test result.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 13:46:56 +03:00

290 lines
9.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package storageagent
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"os"
"path"
"path/filepath"
"strings"
)
// Запись в хранилище (docs/library-and-storage.md#запись-в-хранилище): только импорт одобренных загрузок,
// только создание новых файлов. Перезаписи и удаления файлов библиотеки в коде агента нет.
// Paths — каталоги агента.
type Paths struct {
Library string // ro-точки NFS: <Library>/<source_id> (видны api, nginx, analyzer)
LibraryRW string // rw-точки NFS-источников с accepts_uploads; видны только агенту. Пусто — запись в NFS выключена
HostRW string // LIBRARY_HOST на запись (local-источники); пусто — запись в local выключена
Uploads string // staging очереди на добавление (только чтение)
FileUID int // владелец новых файлов и каталогов; < 0 — не менять
FileGID int
}
// WriteRequest — тело POST /internal/files.
type WriteRequest struct {
SourceID string `json:"source_id"`
LocalPath *string `json:"local_path,omitempty"` // для local-источника: папка внутри LIBRARY_HOST
RelPath string `json:"rel_path"` // желаемый путь в хранилище
StagingPath string `json:"staging_path"` // путь файла относительно каталога staging
SHA256 string `json:"sha256"` // ожидаемый хэш (hex), копия сверяется с ним
}
// WriteResult — где файл оказался (при конфликте имени — с суффиксом « (2)»).
type WriteResult struct {
RelPath string `json:"rel_path"`
Size int64 `json:"size"`
Renamed bool `json:"renamed"`
}
// WriteTestRequest — тело POST /internal/files/test.
type WriteTestRequest struct {
SourceID string `json:"source_id"`
LocalPath *string `json:"local_path,omitempty"`
}
// WriteTestResult — можно ли писать в хранилище.
type WriteTestResult struct {
OK bool `json:"ok"`
Message string `json:"message,omitempty"`
}
const maxConflicts = 100
// errBadPath — путь вне корня, абсолютный или с «..».
var errBadPath = errors.New("path must be relative, without .. and hidden segments")
// cleanRel проверяет относительный путь с `/` и возвращает его в каноническом виде.
func cleanRel(rel string) (string, error) {
if rel == "" || strings.HasPrefix(rel, "/") || strings.ContainsAny(rel, "\\\x00") {
return "", errBadPath
}
for _, seg := range strings.Split(rel, "/") {
if seg == "" || seg == "." || seg == ".." || strings.HasPrefix(seg, ".") {
return "", errBadPath
}
}
return path.Clean(rel), nil
}
// writeRoot — каталог источника на запись.
func (a *agent) writeRoot(sourceID string, localPath *string) (string, error) {
if localPath != nil {
if a.paths.HostRW == "" {
return "", errors.New("writing to local sources is disabled")
}
root := a.paths.HostRW
if *localPath != "" {
rel, err := cleanRel(strings.Trim(*localPath, "/"))
if err != nil {
return "", err
}
root = filepath.Join(root, filepath.FromSlash(rel))
}
if info, err := os.Stat(root); err != nil || !info.IsDir() {
return "", errors.New("source folder not found")
}
return root, nil
}
if a.paths.LibraryRW == "" {
return "", errors.New("writing to NFS sources is disabled")
}
root, err := mountPoint(a.paths.LibraryRW, sourceID)
if err != nil {
return "", err
}
if _, err := os.Stat(root); err != nil {
return "", errors.New("source is not mounted for writing (accepts_uploads is off?)")
}
return root, nil
}
func (a *agent) writeFile(w http.ResponseWriter, r *http.Request) {
var req WriteRequest
if !a.decode(w, r, &req) {
return
}
root, err := a.writeRoot(req.SourceID, req.LocalPath)
if err != nil {
writeValidation(w, &ValidationError{Field: "source_id", Reason: err.Error()})
return
}
rel, err := cleanRel(req.RelPath)
if err != nil {
writeValidation(w, &ValidationError{Field: "rel_path", Reason: err.Error()})
return
}
staging, err := cleanRel(req.StagingPath)
if err != nil || a.paths.Uploads == "" {
writeValidation(w, &ValidationError{Field: "staging_path", Reason: "invalid staging path"})
return
}
want, err := hex.DecodeString(req.SHA256)
if err != nil || len(want) != sha256.Size {
writeValidation(w, &ValidationError{Field: "sha256", Reason: "must be 64 hex characters"})
return
}
res, err := a.write(root, rel, filepath.Join(a.paths.Uploads, filepath.FromSlash(staging)), want)
if err != nil {
a.log.ErrorContext(r.Context(), "write file", "source_id", req.SourceID, "rel_path", rel, "err", err)
writeJSON(w, http.StatusInternalServerError, map[string]any{
"status": http.StatusInternalServerError, "code": "storage.write_failed", "detail": err.Error(),
})
return
}
a.log.InfoContext(r.Context(), "file written", "source_id", req.SourceID, "rel_path", res.RelPath, "size", res.Size)
writeJSON(w, http.StatusOK, res)
}
// write копирует staging во временный файл рядом с целью, fsync, сверяет SHA-256 и создаёт итоговое имя
// жёсткой ссылкой (не перезаписывает существующий файл, в отличие от rename), затем убирает временный.
func (a *agent) write(root, rel, staging string, want []byte) (WriteResult, error) {
target := filepath.Join(root, filepath.FromSlash(rel))
dir := filepath.Dir(target)
if err := a.mkdirs(root, dir); err != nil {
return WriteResult{}, err
}
tmp := filepath.Join(dir, "."+filepath.Base(target)+".pipu-tmp")
size, err := a.copyTemp(staging, tmp)
if err != nil {
_ = os.Remove(tmp)
return WriteResult{}, err
}
defer func() { _ = os.Remove(tmp) }()
got, err := fileSHA256(tmp)
if err != nil {
return WriteResult{}, err
}
if !bytes.Equal(got, want) {
return WriteResult{}, fmt.Errorf("checksum mismatch: copy %x, expected %x", got, want)
}
ext := path.Ext(rel)
base := strings.TrimSuffix(rel, ext)
for n := 1; n <= maxConflicts; n++ {
name := rel
if n > 1 {
name = fmt.Sprintf("%s (%d)%s", base, n, ext)
}
err := os.Link(tmp, filepath.Join(root, filepath.FromSlash(name)))
if err == nil {
syncDir(dir)
return WriteResult{RelPath: name, Size: size, Renamed: n > 1}, nil
}
if !os.IsExist(err) {
return WriteResult{}, fmt.Errorf("link: %w", err)
}
}
return WriteResult{}, errors.New("too many files with the same name")
}
// mkdirs создаёт недостающие каталоги от root до dir (0755, владелец — FileUID/FileGID).
func (a *agent) mkdirs(root, dir string) error {
rel, err := filepath.Rel(root, dir)
if err != nil || strings.HasPrefix(rel, "..") {
return errBadPath
}
cur := root
for _, seg := range strings.Split(rel, string(filepath.Separator)) {
if seg == "." || seg == "" {
continue
}
cur = filepath.Join(cur, seg)
if err := os.Mkdir(cur, 0o755); err != nil {
if os.IsExist(err) {
continue
}
return fmt.Errorf("mkdir: %w", err)
}
a.chown(cur)
}
return nil
}
func (a *agent) copyTemp(staging, tmp string) (int64, error) {
src, err := os.Open(staging)
if err != nil {
return 0, fmt.Errorf("staging: %w", err)
}
defer func() { _ = src.Close() }()
_ = os.Remove(tmp) // остаток прерванной попытки
dst, err := os.OpenFile(tmp, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
if err != nil {
return 0, fmt.Errorf("create temp: %w", err)
}
n, err := io.Copy(dst, src)
if err == nil {
err = dst.Sync()
}
if cerr := dst.Close(); err == nil {
err = cerr
}
if err != nil {
return 0, fmt.Errorf("copy: %w", err)
}
a.chown(tmp)
return n, nil
}
func (a *agent) chown(p string) {
if a.paths.FileUID >= 0 {
if err := os.Lchown(p, a.paths.FileUID, a.paths.FileGID); err != nil {
a.log.Warn("chown failed", "path", p, "err", err)
}
}
}
// testWrite создаёт и удаляет .pipu-write-test в корне источника на запись.
func (a *agent) testWrite(w http.ResponseWriter, r *http.Request) {
var req WriteTestRequest
if !a.decode(w, r, &req) {
return
}
root, err := a.writeRoot(req.SourceID, req.LocalPath)
if err != nil {
writeJSON(w, http.StatusOK, WriteTestResult{Message: err.Error()})
return
}
p := filepath.Join(root, ".pipu-write-test")
f, err := os.OpenFile(p, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
if err == nil {
_, err = f.WriteString("pipu")
if cerr := f.Close(); err == nil {
err = cerr
}
}
_ = os.Remove(p)
if err != nil {
writeJSON(w, http.StatusOK, WriteTestResult{Message: "cannot write: " + err.Error()})
return
}
writeJSON(w, http.StatusOK, WriteTestResult{OK: true, Message: "write access ok"})
}
func fileSHA256(p string) ([]byte, error) {
f, err := os.Open(p)
if err != nil {
return nil, err
}
defer func() { _ = f.Close() }()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return nil, err
}
return h.Sum(nil), nil
}
// syncDir — fsync каталога, чтобы новое имя пережило сбой питания (на Windows не поддерживается — молча).
func syncDir(dir string) {
if d, err := os.Open(dir); err == nil {
_ = d.Sync()
_ = d.Close()
}
}