Files
pipu-music/services/api/internal/storageagent/router.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

255 lines
8.4 KiB
Go

// Package storageagent — внутренний сервис монтирования хранилищ (см. docs/library-and-storage.md).
package storageagent
import (
"context"
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"errors"
"log/slog"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/go-chi/chi/v5"
"pipu/api/internal/platform/httpx"
)
// Mount — состояние точки монтирования источника.
type Mount struct {
SourceID string `json:"source_id"`
Path string `json:"path"`
}
// NewRouter возвращает роутер агента. token — общий секрет INTERNAL_TOKEN;
// при пустом token все запросы к /internal отклоняются.
func NewRouter(log *slog.Logger, token, libraryRoot string) http.Handler {
return NewRouterWithMounter(log, token, libraryRoot, ExecMounter{})
}
// NewRouterWithMounter — NewRouter с заданным способом монтирования (для тестов).
func NewRouterWithMounter(log *slog.Logger, token, libraryRoot string, mounter Mounter) http.Handler {
return NewRouterWithPaths(log, token, Paths{Library: libraryRoot, FileUID: -1, FileGID: -1}, mounter)
}
// NewRouterWithPaths — роутер с каталогами записи (импорт загрузок).
func NewRouterWithPaths(log *slog.Logger, token string, paths Paths, mounter Mounter) http.Handler {
libraryRoot := paths.Library
if log == nil {
log = slog.New(slog.DiscardHandler)
}
r := chi.NewRouter()
r.NotFound(httpx.NotFound)
r.Get("/healthz", func(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
})
r.Route("/internal", func(r chi.Router) {
r.Use(requireToken(token))
r.Get("/mounts", func(w http.ResponseWriter, r *http.Request) {
mounts, err := listMounts(libraryRoot)
if err != nil {
log.ErrorContext(r.Context(), "list mounts", "err", err)
httpx.WriteProblem(w, http.StatusInternalServerError, "storage.list_failed", "cannot list mounts")
return
}
writeJSON(w, http.StatusOK, map[string]any{"mounts": mounts})
})
a := &agent{log: log, root: libraryRoot, paths: paths, mounter: mounter}
r.Post("/mounts", a.mount)
r.Post("/mounts/test", a.test)
r.Delete("/mounts/{sourceID}", a.unmount)
r.Post("/files", a.writeFile)
r.Post("/files/test", a.testWrite)
})
return r
}
type agent struct {
log *slog.Logger
root string
paths Paths
mounter Mounter
}
// MountRequest — тело POST /internal/mounts.
type MountRequest struct {
SourceID string `json:"source_id"`
NFSSpec
Writable bool `json:"writable"`
}
// MountResult — итог монтирования или проверки.
type MountResult struct {
Status string `json:"status"` // mounted | error
Message string `json:"message,omitempty"`
AudioFiles int `json:"audio_files"`
}
func (a *agent) decode(w http.ResponseWriter, r *http.Request, v any) bool {
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(v); err != nil {
httpx.WriteProblem(w, http.StatusBadRequest, "common.bad_request", "invalid json")
return false
}
return true
}
func writeValidation(w http.ResponseWriter, err error) {
var ve *ValidationError
if errors.As(err, &ve) {
writeJSON(w, http.StatusUnprocessableEntity, map[string]any{
"status": http.StatusUnprocessableEntity, "code": "validation.nfs_invalid", "detail": ve.Reason,
"errors": []map[string]string{{"field": ve.Field, "code": "validation.nfs_invalid"}},
})
return
}
httpx.WriteProblem(w, http.StatusBadRequest, "common.bad_request", err.Error())
}
// mount (пере)монтирует источник в <root>/<source_id> и считает аудиофайлы для проверки.
func (a *agent) mount(w http.ResponseWriter, r *http.Request) {
var req MountRequest
if !a.decode(w, r, &req) {
return
}
target, err := mountPoint(a.root, req.SourceID)
if err != nil {
writeValidation(w, &ValidationError{Field: "source_id", Reason: err.Error()})
return
}
spec, err := req.NFSSpec.Normalize(req.Writable)
if err != nil {
writeValidation(w, err)
return
}
ctx, cancel := context.WithTimeout(r.Context(), probeTimeout)
defer cancel()
_ = a.mounter.Unmount(ctx, target)
rwTarget := a.rwPoint(req.SourceID)
if rwTarget != "" {
_ = a.mounter.Unmount(ctx, rwTarget)
_ = os.Remove(rwTarget)
}
if !req.Writable {
writeJSON(w, http.StatusOK, a.mountAndCount(ctx, spec, target))
return
}
// Источник принимает загрузки: rw-точка видна только агенту, всем остальным — bind-копия только на чтение.
if rwTarget == "" {
writeJSON(w, http.StatusOK, MountResult{Status: "error", Message: "writable mounts are disabled (LIBRARY_RW_ROOT)"})
return
}
res := a.mountAndCount(ctx, spec, rwTarget)
if res.Status == "mounted" {
if err := a.mounter.BindReadOnly(ctx, rwTarget, target); err != nil {
_ = a.mounter.Unmount(ctx, rwTarget)
res = MountResult{Status: "error", Message: err.Error()}
}
}
writeJSON(w, http.StatusOK, res)
}
// rwPoint — rw-точка источника ("" — запись в NFS выключена или id неверный).
func (a *agent) rwPoint(sourceID string) string {
if a.paths.LibraryRW == "" {
return ""
}
p, err := mountPoint(a.paths.LibraryRW, sourceID)
if err != nil {
return ""
}
return p
}
// test монтирует во временную точку, считает файлы и отключает.
func (a *agent) test(w http.ResponseWriter, r *http.Request) {
var spec NFSSpec
if !a.decode(w, r, &spec) {
return
}
spec, err := spec.Normalize(false)
if err != nil {
writeValidation(w, err)
return
}
var rnd [6]byte
_, _ = rand.Read(rnd[:])
target := filepath.Join(a.root, ".test-"+hex.EncodeToString(rnd[:]))
ctx, cancel := context.WithTimeout(r.Context(), probeTimeout)
defer cancel()
res := a.mountAndCount(ctx, spec, target)
_ = a.mounter.Unmount(context.Background(), target)
_ = os.Remove(target)
writeJSON(w, http.StatusOK, res)
}
func (a *agent) mountAndCount(ctx context.Context, spec NFSSpec, target string) MountResult {
if err := a.mounter.Mount(ctx, spec, target); err != nil {
a.log.WarnContext(ctx, "nfs mount failed", "target", target, "err", err)
return MountResult{Status: "error", Message: err.Error()}
}
n, err := countAudio(ctx, target, 1000)
if err != nil {
return MountResult{Status: "error", Message: "mounted, but listing failed: " + err.Error()}
}
return MountResult{Status: "mounted", AudioFiles: n, Message: "found " + itoa(n) + " audio files"}
}
func (a *agent) unmount(w http.ResponseWriter, r *http.Request) {
target, err := mountPoint(a.root, chi.URLParam(r, "sourceID"))
if err != nil {
writeValidation(w, &ValidationError{Field: "source_id", Reason: err.Error()})
return
}
if err := a.mounter.Unmount(r.Context(), target); err != nil {
httpx.WriteProblem(w, http.StatusInternalServerError, "storage.unmount_failed", err.Error())
return
}
_ = os.Remove(target)
if rw := a.rwPoint(chi.URLParam(r, "sourceID")); rw != "" {
_ = a.mounter.Unmount(r.Context(), rw)
_ = os.Remove(rw)
}
w.WriteHeader(http.StatusNoContent)
}
func requireToken(token string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
if !ok || token == "" || subtle.ConstantTimeCompare([]byte(got), []byte(token)) != 1 {
httpx.WriteProblem(w, http.StatusUnauthorized, "auth.invalid_internal_token", "invalid internal token")
return
}
next.ServeHTTP(w, r)
})
}
}
// listMounts перечисляет подкаталоги корня библиотеки (каждый — точка монтирования источника).
func listMounts(root string) ([]Mount, error) {
entries, err := os.ReadDir(root)
if err != nil {
if os.IsNotExist(err) {
return []Mount{}, nil
}
return nil, err
}
mounts := make([]Mount, 0, len(entries))
for _, e := range entries {
if e.IsDir() && !strings.HasPrefix(e.Name(), ".") {
mounts = append(mounts, Mount{SourceID: e.Name(), Path: filepath.Join(root, e.Name())})
}
}
return mounts, nil
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}