Compare commits
5 Commits
ee251c8727
...
4154c1b0b9
| Author | SHA1 | Date | |
|---|---|---|---|
| 4154c1b0b9 | |||
| 1cb2d54c0c | |||
| a6387f2eb8 | |||
| cf7317747e | |||
| dea6a55dfc |
@ -12,6 +12,7 @@ import (
|
|||||||
"tanabata/backend/internal/db/postgres"
|
"tanabata/backend/internal/db/postgres"
|
||||||
"tanabata/backend/internal/handler"
|
"tanabata/backend/internal/handler"
|
||||||
"tanabata/backend/internal/service"
|
"tanabata/backend/internal/service"
|
||||||
|
"tanabata/backend/internal/storage"
|
||||||
"tanabata/backend/migrations"
|
"tanabata/backend/migrations"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -43,9 +44,26 @@ func main() {
|
|||||||
migDB.Close()
|
migDB.Close()
|
||||||
slog.Info("migrations applied")
|
slog.Info("migrations applied")
|
||||||
|
|
||||||
|
// Storage
|
||||||
|
diskStorage, err := storage.NewDiskStorage(
|
||||||
|
cfg.FilesPath,
|
||||||
|
cfg.ThumbsCachePath,
|
||||||
|
cfg.ThumbWidth, cfg.ThumbHeight,
|
||||||
|
cfg.PreviewWidth, cfg.PreviewHeight,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to initialise storage", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
// Repositories
|
// Repositories
|
||||||
userRepo := postgres.NewUserRepo(pool)
|
userRepo := postgres.NewUserRepo(pool)
|
||||||
sessionRepo := postgres.NewSessionRepo(pool)
|
sessionRepo := postgres.NewSessionRepo(pool)
|
||||||
|
fileRepo := postgres.NewFileRepo(pool)
|
||||||
|
mimeRepo := postgres.NewMimeRepo(pool)
|
||||||
|
aclRepo := postgres.NewACLRepo(pool)
|
||||||
|
auditRepo := postgres.NewAuditRepo(pool)
|
||||||
|
transactor := postgres.NewTransactor(pool)
|
||||||
|
|
||||||
// Services
|
// Services
|
||||||
authSvc := service.NewAuthService(
|
authSvc := service.NewAuthService(
|
||||||
@ -55,16 +73,28 @@ func main() {
|
|||||||
cfg.JWTAccessTTL,
|
cfg.JWTAccessTTL,
|
||||||
cfg.JWTRefreshTTL,
|
cfg.JWTRefreshTTL,
|
||||||
)
|
)
|
||||||
|
aclSvc := service.NewACLService(aclRepo)
|
||||||
|
auditSvc := service.NewAuditService(auditRepo)
|
||||||
|
fileSvc := service.NewFileService(
|
||||||
|
fileRepo,
|
||||||
|
mimeRepo,
|
||||||
|
diskStorage,
|
||||||
|
aclSvc,
|
||||||
|
auditSvc,
|
||||||
|
transactor,
|
||||||
|
cfg.ImportPath,
|
||||||
|
)
|
||||||
|
|
||||||
// Handlers
|
// Handlers
|
||||||
authMiddleware := handler.NewAuthMiddleware(authSvc)
|
authMiddleware := handler.NewAuthMiddleware(authSvc)
|
||||||
authHandler := handler.NewAuthHandler(authSvc)
|
authHandler := handler.NewAuthHandler(authSvc)
|
||||||
|
fileHandler := handler.NewFileHandler(fileSvc)
|
||||||
|
|
||||||
r := handler.NewRouter(authMiddleware, authHandler)
|
r := handler.NewRouter(authMiddleware, authHandler, fileHandler)
|
||||||
|
|
||||||
slog.Info("starting server", "addr", cfg.ListenAddr)
|
slog.Info("starting server", "addr", cfg.ListenAddr)
|
||||||
if err := r.Run(cfg.ListenAddr); err != nil {
|
if err := r.Run(cfg.ListenAddr); err != nil {
|
||||||
slog.Error("server error", "err", err)
|
slog.Error("server error", "err", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -17,6 +17,7 @@ require (
|
|||||||
github.com/bytedance/sonic v1.15.0 // indirect
|
github.com/bytedance/sonic v1.15.0 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||||
|
github.com/disintegration/imaging v1.6.2 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||||
github.com/go-playground/locales v0.14.1 // indirect
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
@ -37,12 +38,14 @@ require (
|
|||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||||
|
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd // indirect
|
||||||
github.com/sethvargo/go-retry v0.3.0 // indirect
|
github.com/sethvargo/go-retry v0.3.0 // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||||
go.uber.org/multierr v1.11.0 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
golang.org/x/arch v0.22.0 // indirect
|
golang.org/x/arch v0.22.0 // indirect
|
||||||
golang.org/x/crypto v0.39.0 // indirect
|
golang.org/x/crypto v0.39.0 // indirect
|
||||||
|
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 // indirect
|
||||||
golang.org/x/net v0.33.0 // indirect
|
golang.org/x/net v0.33.0 // indirect
|
||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.41.0 // indirect
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
|
|||||||
@ -10,6 +10,8 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3
|
|||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
||||||
|
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||||
@ -78,6 +80,8 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94
|
|||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||||
|
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK3dcGsnCnO41eRBOnY12zwkn5qVwgc=
|
||||||
|
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=
|
||||||
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
|
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
|
||||||
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
|
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
@ -101,6 +105,8 @@ golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
|||||||
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||||
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
|
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
|
||||||
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
|
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
|
||||||
|
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U=
|
||||||
|
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||||
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
|
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
|
||||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
@ -108,6 +114,7 @@ golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
|||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
|
|||||||
795
backend/internal/db/postgres/file_repo.go
Normal file
795
backend/internal/db/postgres/file_repo.go
Normal file
@ -0,0 +1,795 @@
|
|||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"tanabata/backend/internal/db"
|
||||||
|
"tanabata/backend/internal/domain"
|
||||||
|
"tanabata/backend/internal/port"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Row structs
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type fileRow struct {
|
||||||
|
ID uuid.UUID `db:"id"`
|
||||||
|
OriginalName *string `db:"original_name"`
|
||||||
|
MIMEType string `db:"mime_type"`
|
||||||
|
MIMEExtension string `db:"mime_extension"`
|
||||||
|
ContentDatetime time.Time `db:"content_datetime"`
|
||||||
|
Notes *string `db:"notes"`
|
||||||
|
Metadata json.RawMessage `db:"metadata"`
|
||||||
|
EXIF json.RawMessage `db:"exif"`
|
||||||
|
PHash *int64 `db:"phash"`
|
||||||
|
CreatorID int16 `db:"creator_id"`
|
||||||
|
CreatorName string `db:"creator_name"`
|
||||||
|
IsPublic bool `db:"is_public"`
|
||||||
|
IsDeleted bool `db:"is_deleted"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// fileTagRow is used for both single-file and batch tag loading.
|
||||||
|
// file_id is always selected so the same struct works for both cases.
|
||||||
|
type fileTagRow struct {
|
||||||
|
FileID uuid.UUID `db:"file_id"`
|
||||||
|
ID uuid.UUID `db:"id"`
|
||||||
|
Name string `db:"name"`
|
||||||
|
Notes *string `db:"notes"`
|
||||||
|
Color *string `db:"color"`
|
||||||
|
CategoryID *uuid.UUID `db:"category_id"`
|
||||||
|
CategoryName *string `db:"category_name"`
|
||||||
|
CategoryColor *string `db:"category_color"`
|
||||||
|
Metadata json.RawMessage `db:"metadata"`
|
||||||
|
CreatorID int16 `db:"creator_id"`
|
||||||
|
CreatorName string `db:"creator_name"`
|
||||||
|
IsPublic bool `db:"is_public"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// anchorValRow holds the sort-column values fetched for an anchor file.
|
||||||
|
type anchorValRow struct {
|
||||||
|
ContentDatetime time.Time `db:"content_datetime"`
|
||||||
|
OriginalName string `db:"original_name"` // COALESCE(original_name,'') applied in SQL
|
||||||
|
MIMEType string `db:"mime_type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Converters
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func toFile(r fileRow) domain.File {
|
||||||
|
return domain.File{
|
||||||
|
ID: r.ID,
|
||||||
|
OriginalName: r.OriginalName,
|
||||||
|
MIMEType: r.MIMEType,
|
||||||
|
MIMEExtension: r.MIMEExtension,
|
||||||
|
ContentDatetime: r.ContentDatetime,
|
||||||
|
Notes: r.Notes,
|
||||||
|
Metadata: r.Metadata,
|
||||||
|
EXIF: r.EXIF,
|
||||||
|
PHash: r.PHash,
|
||||||
|
CreatorID: r.CreatorID,
|
||||||
|
CreatorName: r.CreatorName,
|
||||||
|
IsPublic: r.IsPublic,
|
||||||
|
IsDeleted: r.IsDeleted,
|
||||||
|
CreatedAt: domain.UUIDCreatedAt(r.ID),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toTagFromFileTag(r fileTagRow) domain.Tag {
|
||||||
|
return domain.Tag{
|
||||||
|
ID: r.ID,
|
||||||
|
Name: r.Name,
|
||||||
|
Notes: r.Notes,
|
||||||
|
Color: r.Color,
|
||||||
|
CategoryID: r.CategoryID,
|
||||||
|
CategoryName: r.CategoryName,
|
||||||
|
CategoryColor: r.CategoryColor,
|
||||||
|
Metadata: r.Metadata,
|
||||||
|
CreatorID: r.CreatorID,
|
||||||
|
CreatorName: r.CreatorName,
|
||||||
|
IsPublic: r.IsPublic,
|
||||||
|
CreatedAt: domain.UUIDCreatedAt(r.ID),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Cursor
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type fileCursor struct {
|
||||||
|
Sort string `json:"s"` // canonical sort name
|
||||||
|
Order string `json:"o"` // "ASC" or "DESC"
|
||||||
|
ID string `json:"id"` // UUID of the boundary file
|
||||||
|
Val string `json:"v"` // sort column value; empty for "created" (id IS the key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func encodeCursor(c fileCursor) string {
|
||||||
|
b, _ := json.Marshal(c)
|
||||||
|
return base64.RawURLEncoding.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeCursor(s string) (fileCursor, error) {
|
||||||
|
b, err := base64.RawURLEncoding.DecodeString(s)
|
||||||
|
if err != nil {
|
||||||
|
return fileCursor{}, fmt.Errorf("cursor: invalid encoding")
|
||||||
|
}
|
||||||
|
var c fileCursor
|
||||||
|
if err := json.Unmarshal(b, &c); err != nil {
|
||||||
|
return fileCursor{}, fmt.Errorf("cursor: invalid format")
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// makeCursor builds a fileCursor from a boundary row and the current sort/order.
|
||||||
|
func makeCursor(r fileRow, sort, order string) fileCursor {
|
||||||
|
var val string
|
||||||
|
switch sort {
|
||||||
|
case "content_datetime":
|
||||||
|
val = r.ContentDatetime.UTC().Format(time.RFC3339Nano)
|
||||||
|
case "original_name":
|
||||||
|
if r.OriginalName != nil {
|
||||||
|
val = *r.OriginalName
|
||||||
|
}
|
||||||
|
case "mime":
|
||||||
|
val = r.MIMEType
|
||||||
|
// "created": val is empty; f.id is the sort key.
|
||||||
|
}
|
||||||
|
return fileCursor{Sort: sort, Order: order, ID: r.ID.String(), Val: val}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Sort helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func normSort(s string) string {
|
||||||
|
switch s {
|
||||||
|
case "content_datetime", "original_name", "mime":
|
||||||
|
return s
|
||||||
|
default:
|
||||||
|
return "created"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normOrder(o string) string {
|
||||||
|
if strings.EqualFold(o, "asc") {
|
||||||
|
return "ASC"
|
||||||
|
}
|
||||||
|
return "DESC"
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildKeysetCond returns a keyset WHERE fragment and an ORDER BY fragment.
|
||||||
|
//
|
||||||
|
// - forward=true: items after the cursor in the sort order (standard next-page)
|
||||||
|
// - forward=false: items before the cursor (previous-page); ORDER BY is reversed,
|
||||||
|
// caller must reverse the result slice after fetching
|
||||||
|
// - incl=true: include the cursor file itself (anchor case; uses ≤ / ≥)
|
||||||
|
//
|
||||||
|
// All user values are bound as parameters — no SQL injection possible.
|
||||||
|
func buildKeysetCond(
|
||||||
|
sort, order string,
|
||||||
|
forward, incl bool,
|
||||||
|
cursorID uuid.UUID, cursorVal string,
|
||||||
|
n int, args []any,
|
||||||
|
) (where, orderBy string, nextN int, outArgs []any) {
|
||||||
|
// goDown=true → want smaller values → primary comparison is "<".
|
||||||
|
// Applies for DESC+forward and ASC+backward.
|
||||||
|
goDown := (order == "DESC") == forward
|
||||||
|
|
||||||
|
var op, idOp string
|
||||||
|
if goDown {
|
||||||
|
op = "<"
|
||||||
|
if incl {
|
||||||
|
idOp = "<="
|
||||||
|
} else {
|
||||||
|
idOp = "<"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
op = ">"
|
||||||
|
if incl {
|
||||||
|
idOp = ">="
|
||||||
|
} else {
|
||||||
|
idOp = ">"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Effective ORDER BY direction: reversed for backward so the DB returns
|
||||||
|
// the closest items first (the ones we keep after trimming the extra).
|
||||||
|
dir := order
|
||||||
|
if !forward {
|
||||||
|
if order == "DESC" {
|
||||||
|
dir = "ASC"
|
||||||
|
} else {
|
||||||
|
dir = "DESC"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch sort {
|
||||||
|
case "created":
|
||||||
|
// Single-column keyset: f.id (UUID v7, so ordering = chronological).
|
||||||
|
where = fmt.Sprintf("f.id %s $%d", idOp, n)
|
||||||
|
orderBy = fmt.Sprintf("f.id %s", dir)
|
||||||
|
outArgs = append(args, cursorID)
|
||||||
|
n++
|
||||||
|
|
||||||
|
case "content_datetime":
|
||||||
|
// Two-column keyset: (content_datetime, id).
|
||||||
|
// $n is referenced twice in the SQL (< and =) but passed once in args —
|
||||||
|
// PostgreSQL extended protocol allows multiple references to $N.
|
||||||
|
t, _ := time.Parse(time.RFC3339Nano, cursorVal)
|
||||||
|
where = fmt.Sprintf(
|
||||||
|
"(f.content_datetime %s $%d OR (f.content_datetime = $%d AND f.id %s $%d))",
|
||||||
|
op, n, n, idOp, n+1)
|
||||||
|
orderBy = fmt.Sprintf("f.content_datetime %s, f.id %s", dir, dir)
|
||||||
|
outArgs = append(args, t, cursorID)
|
||||||
|
n += 2
|
||||||
|
|
||||||
|
case "original_name":
|
||||||
|
// COALESCE treats NULL names as '' for stable pagination.
|
||||||
|
where = fmt.Sprintf(
|
||||||
|
"(COALESCE(f.original_name,'') %s $%d OR (COALESCE(f.original_name,'') = $%d AND f.id %s $%d))",
|
||||||
|
op, n, n, idOp, n+1)
|
||||||
|
orderBy = fmt.Sprintf("COALESCE(f.original_name,'') %s, f.id %s", dir, dir)
|
||||||
|
outArgs = append(args, cursorVal, cursorID)
|
||||||
|
n += 2
|
||||||
|
|
||||||
|
default: // "mime"
|
||||||
|
where = fmt.Sprintf(
|
||||||
|
"(mt.name %s $%d OR (mt.name = $%d AND f.id %s $%d))",
|
||||||
|
op, n, n, idOp, n+1)
|
||||||
|
orderBy = fmt.Sprintf("mt.name %s, f.id %s", dir, dir)
|
||||||
|
outArgs = append(args, cursorVal, cursorID)
|
||||||
|
n += 2
|
||||||
|
}
|
||||||
|
|
||||||
|
nextN = n
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultOrderBy returns the natural ORDER BY for the first page (no cursor).
|
||||||
|
func defaultOrderBy(sort, order string) string {
|
||||||
|
switch sort {
|
||||||
|
case "created":
|
||||||
|
return fmt.Sprintf("f.id %s", order)
|
||||||
|
case "content_datetime":
|
||||||
|
return fmt.Sprintf("f.content_datetime %s, f.id %s", order, order)
|
||||||
|
case "original_name":
|
||||||
|
return fmt.Sprintf("COALESCE(f.original_name,'') %s, f.id %s", order, order)
|
||||||
|
default: // "mime"
|
||||||
|
return fmt.Sprintf("mt.name %s, f.id %s", order, order)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// FileRepo
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// FileRepo implements port.FileRepo using PostgreSQL.
|
||||||
|
type FileRepo struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFileRepo creates a FileRepo backed by pool.
|
||||||
|
func NewFileRepo(pool *pgxpool.Pool) *FileRepo {
|
||||||
|
return &FileRepo{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ port.FileRepo = (*FileRepo)(nil)
|
||||||
|
|
||||||
|
// fileSelectCTE is the SELECT appended after a CTE named "r" that exposes
|
||||||
|
// all file columns (including mime_id). Used by Create, Update, and Restore
|
||||||
|
// to get the full denormalized record in a single round-trip.
|
||||||
|
const fileSelectCTE = `
|
||||||
|
SELECT r.id, r.original_name,
|
||||||
|
mt.name AS mime_type, mt.extension AS mime_extension,
|
||||||
|
r.content_datetime, r.notes, r.metadata, r.exif, r.phash,
|
||||||
|
r.creator_id, u.name AS creator_name,
|
||||||
|
r.is_public, r.is_deleted
|
||||||
|
FROM r
|
||||||
|
JOIN core.mime_types mt ON mt.id = r.mime_id
|
||||||
|
JOIN core.users u ON u.id = r.creator_id`
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Create
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Create inserts a new file record. The MIME type is resolved from
|
||||||
|
// f.MIMEType (name string) via a subquery; the DB generates the UUID v7 id.
|
||||||
|
func (r *FileRepo) Create(ctx context.Context, f *domain.File) (*domain.File, error) {
|
||||||
|
const sqlStr = `
|
||||||
|
WITH r AS (
|
||||||
|
INSERT INTO data.files
|
||||||
|
(original_name, mime_id, content_datetime, notes, metadata, exif, phash, creator_id, is_public)
|
||||||
|
VALUES (
|
||||||
|
$1,
|
||||||
|
(SELECT id FROM core.mime_types WHERE name = $2),
|
||||||
|
$3, $4, $5, $6, $7, $8, $9
|
||||||
|
)
|
||||||
|
RETURNING id, original_name, mime_id, content_datetime, notes,
|
||||||
|
metadata, exif, phash, creator_id, is_public, is_deleted
|
||||||
|
)` + fileSelectCTE
|
||||||
|
|
||||||
|
q := connOrTx(ctx, r.pool)
|
||||||
|
rows, err := q.Query(ctx, sqlStr,
|
||||||
|
f.OriginalName, f.MIMEType, f.ContentDatetime,
|
||||||
|
f.Notes, f.Metadata, f.EXIF, f.PHash,
|
||||||
|
f.CreatorID, f.IsPublic,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("FileRepo.Create: %w", err)
|
||||||
|
}
|
||||||
|
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[fileRow])
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("FileRepo.Create scan: %w", err)
|
||||||
|
}
|
||||||
|
created := toFile(row)
|
||||||
|
return &created, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GetByID
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func (r *FileRepo) GetByID(ctx context.Context, id uuid.UUID) (*domain.File, error) {
|
||||||
|
const sqlStr = `
|
||||||
|
SELECT f.id, f.original_name,
|
||||||
|
mt.name AS mime_type, mt.extension AS mime_extension,
|
||||||
|
f.content_datetime, f.notes, f.metadata, f.exif, f.phash,
|
||||||
|
f.creator_id, u.name AS creator_name,
|
||||||
|
f.is_public, f.is_deleted
|
||||||
|
FROM data.files f
|
||||||
|
JOIN core.mime_types mt ON mt.id = f.mime_id
|
||||||
|
JOIN core.users u ON u.id = f.creator_id
|
||||||
|
WHERE f.id = $1`
|
||||||
|
|
||||||
|
q := connOrTx(ctx, r.pool)
|
||||||
|
rows, err := q.Query(ctx, sqlStr, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("FileRepo.GetByID: %w", err)
|
||||||
|
}
|
||||||
|
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[fileRow])
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, domain.ErrNotFound
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("FileRepo.GetByID scan: %w", err)
|
||||||
|
}
|
||||||
|
f := toFile(row)
|
||||||
|
tags, err := r.ListTags(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
f.Tags = tags
|
||||||
|
return &f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Update
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Update applies editable metadata fields. MIME type and EXIF are immutable.
|
||||||
|
func (r *FileRepo) Update(ctx context.Context, id uuid.UUID, f *domain.File) (*domain.File, error) {
|
||||||
|
const sqlStr = `
|
||||||
|
WITH r AS (
|
||||||
|
UPDATE data.files
|
||||||
|
SET original_name = $2,
|
||||||
|
content_datetime = $3,
|
||||||
|
notes = $4,
|
||||||
|
metadata = $5,
|
||||||
|
is_public = $6
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING id, original_name, mime_id, content_datetime, notes,
|
||||||
|
metadata, exif, phash, creator_id, is_public, is_deleted
|
||||||
|
)` + fileSelectCTE
|
||||||
|
|
||||||
|
q := connOrTx(ctx, r.pool)
|
||||||
|
rows, err := q.Query(ctx, sqlStr,
|
||||||
|
id, f.OriginalName, f.ContentDatetime,
|
||||||
|
f.Notes, f.Metadata, f.IsPublic,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("FileRepo.Update: %w", err)
|
||||||
|
}
|
||||||
|
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[fileRow])
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, domain.ErrNotFound
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("FileRepo.Update scan: %w", err)
|
||||||
|
}
|
||||||
|
updated := toFile(row)
|
||||||
|
tags, err := r.ListTags(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
updated.Tags = tags
|
||||||
|
return &updated, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// SoftDelete / Restore / DeletePermanent
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// SoftDelete moves a file to trash (is_deleted = true). Returns ErrNotFound
|
||||||
|
// if the file does not exist or is already in trash.
|
||||||
|
func (r *FileRepo) SoftDelete(ctx context.Context, id uuid.UUID) error {
|
||||||
|
const sqlStr = `UPDATE data.files SET is_deleted = true WHERE id = $1 AND is_deleted = false`
|
||||||
|
q := connOrTx(ctx, r.pool)
|
||||||
|
tag, err := q.Exec(ctx, sqlStr, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("FileRepo.SoftDelete: %w", err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return domain.ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore moves a file out of trash (is_deleted = false). Returns ErrNotFound
|
||||||
|
// if the file does not exist or is not in trash.
|
||||||
|
func (r *FileRepo) Restore(ctx context.Context, id uuid.UUID) (*domain.File, error) {
|
||||||
|
const sqlStr = `
|
||||||
|
WITH r AS (
|
||||||
|
UPDATE data.files
|
||||||
|
SET is_deleted = false
|
||||||
|
WHERE id = $1 AND is_deleted = true
|
||||||
|
RETURNING id, original_name, mime_id, content_datetime, notes,
|
||||||
|
metadata, exif, phash, creator_id, is_public, is_deleted
|
||||||
|
)` + fileSelectCTE
|
||||||
|
|
||||||
|
q := connOrTx(ctx, r.pool)
|
||||||
|
rows, err := q.Query(ctx, sqlStr, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("FileRepo.Restore: %w", err)
|
||||||
|
}
|
||||||
|
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[fileRow])
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, domain.ErrNotFound
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("FileRepo.Restore scan: %w", err)
|
||||||
|
}
|
||||||
|
restored := toFile(row)
|
||||||
|
tags, err := r.ListTags(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
restored.Tags = tags
|
||||||
|
return &restored, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeletePermanent removes a file record permanently. Only allowed when the
|
||||||
|
// file is already in trash (is_deleted = true).
|
||||||
|
func (r *FileRepo) DeletePermanent(ctx context.Context, id uuid.UUID) error {
|
||||||
|
const sqlStr = `DELETE FROM data.files WHERE id = $1 AND is_deleted = true`
|
||||||
|
q := connOrTx(ctx, r.pool)
|
||||||
|
tag, err := q.Exec(ctx, sqlStr, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("FileRepo.DeletePermanent: %w", err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return domain.ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ListTags / SetTags
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// ListTags returns all tags assigned to a file, ordered by tag name.
|
||||||
|
func (r *FileRepo) ListTags(ctx context.Context, fileID uuid.UUID) ([]domain.Tag, error) {
|
||||||
|
m, err := r.loadTagsBatch(ctx, []uuid.UUID{fileID})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return m[fileID], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetTags replaces all tags on a file (full replace semantics).
|
||||||
|
func (r *FileRepo) SetTags(ctx context.Context, fileID uuid.UUID, tagIDs []uuid.UUID) error {
|
||||||
|
q := connOrTx(ctx, r.pool)
|
||||||
|
const del = `DELETE FROM data.file_tag WHERE file_id = $1`
|
||||||
|
if _, err := q.Exec(ctx, del, fileID); err != nil {
|
||||||
|
return fmt.Errorf("FileRepo.SetTags delete: %w", err)
|
||||||
|
}
|
||||||
|
if len(tagIDs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
const ins = `INSERT INTO data.file_tag (file_id, tag_id) VALUES ($1, $2)`
|
||||||
|
for _, tagID := range tagIDs {
|
||||||
|
if _, err := q.Exec(ctx, ins, fileID, tagID); err != nil {
|
||||||
|
return fmt.Errorf("FileRepo.SetTags insert: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// List
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// List returns a cursor-paginated page of files.
|
||||||
|
//
|
||||||
|
// Pagination is keyset-based for stable performance on large tables.
|
||||||
|
// Cursor encodes the sort position; the caller provides direction.
|
||||||
|
// Anchor mode centres the result around a specific file UUID.
|
||||||
|
func (r *FileRepo) List(ctx context.Context, params domain.FileListParams) (*domain.FilePage, error) {
|
||||||
|
sort := normSort(params.Sort)
|
||||||
|
order := normOrder(params.Order)
|
||||||
|
forward := params.Direction != "backward"
|
||||||
|
limit := db.ClampLimit(params.Limit, 50, 200)
|
||||||
|
|
||||||
|
// --- resolve cursor / anchor ---
|
||||||
|
var (
|
||||||
|
cursorID uuid.UUID
|
||||||
|
cursorVal string
|
||||||
|
hasCursor bool
|
||||||
|
isAnchor bool
|
||||||
|
)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case params.Cursor != "":
|
||||||
|
cur, err := decodeCursor(params.Cursor)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: %v", domain.ErrValidation, err)
|
||||||
|
}
|
||||||
|
id, err := uuid.Parse(cur.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, domain.ErrValidation
|
||||||
|
}
|
||||||
|
// Lock in the sort/order encoded in the cursor so changing query
|
||||||
|
// parameters mid-session doesn't corrupt pagination.
|
||||||
|
sort = normSort(cur.Sort)
|
||||||
|
order = normOrder(cur.Order)
|
||||||
|
cursorID = id
|
||||||
|
cursorVal = cur.Val
|
||||||
|
hasCursor = true
|
||||||
|
|
||||||
|
case params.Anchor != nil:
|
||||||
|
av, err := r.fetchAnchorVals(ctx, *params.Anchor)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cursorID = *params.Anchor
|
||||||
|
switch sort {
|
||||||
|
case "content_datetime":
|
||||||
|
cursorVal = av.ContentDatetime.UTC().Format(time.RFC3339Nano)
|
||||||
|
case "original_name":
|
||||||
|
cursorVal = av.OriginalName
|
||||||
|
case "mime":
|
||||||
|
cursorVal = av.MIMEType
|
||||||
|
// "created": cursorVal stays ""; cursorID is the sort key.
|
||||||
|
}
|
||||||
|
hasCursor = true
|
||||||
|
isAnchor = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Without a cursor there is no meaningful "backward" direction.
|
||||||
|
if !hasCursor {
|
||||||
|
forward = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- build WHERE and ORDER BY ---
|
||||||
|
var conds []string
|
||||||
|
args := make([]any, 0, 8)
|
||||||
|
n := 1
|
||||||
|
|
||||||
|
conds = append(conds, fmt.Sprintf("f.is_deleted = $%d", n))
|
||||||
|
args = append(args, params.Trash)
|
||||||
|
n++
|
||||||
|
|
||||||
|
if params.Search != "" {
|
||||||
|
conds = append(conds, fmt.Sprintf("f.original_name ILIKE $%d", n))
|
||||||
|
args = append(args, "%"+params.Search+"%")
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
|
||||||
|
if params.Filter != "" {
|
||||||
|
filterSQL, nextN, filterArgs, err := ParseFilter(params.Filter, n)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: %v", domain.ErrValidation, err)
|
||||||
|
}
|
||||||
|
if filterSQL != "" {
|
||||||
|
conds = append(conds, filterSQL)
|
||||||
|
n = nextN
|
||||||
|
args = append(args, filterArgs...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var orderBy string
|
||||||
|
if hasCursor {
|
||||||
|
ksWhere, ksOrder, nextN, ksArgs := buildKeysetCond(
|
||||||
|
sort, order, forward, isAnchor, cursorID, cursorVal, n, args)
|
||||||
|
conds = append(conds, ksWhere)
|
||||||
|
n = nextN
|
||||||
|
args = ksArgs
|
||||||
|
orderBy = ksOrder
|
||||||
|
} else {
|
||||||
|
orderBy = defaultOrderBy(sort, order)
|
||||||
|
}
|
||||||
|
|
||||||
|
where := ""
|
||||||
|
if len(conds) > 0 {
|
||||||
|
where = "WHERE " + strings.Join(conds, " AND ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch one extra row to detect whether more items exist beyond this page.
|
||||||
|
args = append(args, limit+1)
|
||||||
|
sqlStr := fmt.Sprintf(`
|
||||||
|
SELECT f.id, f.original_name,
|
||||||
|
mt.name AS mime_type, mt.extension AS mime_extension,
|
||||||
|
f.content_datetime, f.notes, f.metadata, f.exif, f.phash,
|
||||||
|
f.creator_id, u.name AS creator_name,
|
||||||
|
f.is_public, f.is_deleted
|
||||||
|
FROM data.files f
|
||||||
|
JOIN core.mime_types mt ON mt.id = f.mime_id
|
||||||
|
JOIN core.users u ON u.id = f.creator_id
|
||||||
|
%s
|
||||||
|
ORDER BY %s
|
||||||
|
LIMIT $%d`, where, orderBy, n)
|
||||||
|
|
||||||
|
q := connOrTx(ctx, r.pool)
|
||||||
|
rows, err := q.Query(ctx, sqlStr, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("FileRepo.List: %w", err)
|
||||||
|
}
|
||||||
|
collected, err := pgx.CollectRows(rows, pgx.RowToStructByName[fileRow])
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("FileRepo.List scan: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- trim extra row and reverse for backward ---
|
||||||
|
hasMore := len(collected) > limit
|
||||||
|
if hasMore {
|
||||||
|
collected = collected[:limit]
|
||||||
|
}
|
||||||
|
if !forward {
|
||||||
|
// Results were fetched in reversed ORDER BY; invert to restore the
|
||||||
|
// natural sort order expected by the caller.
|
||||||
|
for i, j := 0, len(collected)-1; i < j; i, j = i+1, j-1 {
|
||||||
|
collected[i], collected[j] = collected[j], collected[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- assemble page ---
|
||||||
|
page := &domain.FilePage{
|
||||||
|
Items: make([]domain.File, len(collected)),
|
||||||
|
}
|
||||||
|
for i, row := range collected {
|
||||||
|
page.Items[i] = toFile(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- set next/prev cursors ---
|
||||||
|
// next_cursor: navigate further in the forward direction.
|
||||||
|
// prev_cursor: navigate further in the backward direction.
|
||||||
|
if len(collected) > 0 {
|
||||||
|
firstCur := encodeCursor(makeCursor(collected[0], sort, order))
|
||||||
|
lastCur := encodeCursor(makeCursor(collected[len(collected)-1], sort, order))
|
||||||
|
|
||||||
|
if forward {
|
||||||
|
// We only know a prev page exists if we arrived via cursor.
|
||||||
|
if hasCursor {
|
||||||
|
page.PrevCursor = &firstCur
|
||||||
|
}
|
||||||
|
if hasMore {
|
||||||
|
page.NextCursor = &lastCur
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Backward: last item (after reversal) is closest to original cursor.
|
||||||
|
if hasCursor {
|
||||||
|
page.NextCursor = &lastCur
|
||||||
|
}
|
||||||
|
if hasMore {
|
||||||
|
page.PrevCursor = &firstCur
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- batch-load tags ---
|
||||||
|
if len(page.Items) > 0 {
|
||||||
|
fileIDs := make([]uuid.UUID, len(page.Items))
|
||||||
|
for i, f := range page.Items {
|
||||||
|
fileIDs[i] = f.ID
|
||||||
|
}
|
||||||
|
tagMap, err := r.loadTagsBatch(ctx, fileIDs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for i, f := range page.Items {
|
||||||
|
page.Items[i].Tags = tagMap[f.ID] // nil becomes []domain.Tag{} via loadTagsBatch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return page, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Internal helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// fetchAnchorVals returns the sort-column values for the given file.
|
||||||
|
// Used to set up a cursor when the caller provides an anchor UUID.
|
||||||
|
func (r *FileRepo) fetchAnchorVals(ctx context.Context, fileID uuid.UUID) (*anchorValRow, error) {
|
||||||
|
const sqlStr = `
|
||||||
|
SELECT f.content_datetime,
|
||||||
|
COALESCE(f.original_name, '') AS original_name,
|
||||||
|
mt.name AS mime_type
|
||||||
|
FROM data.files f
|
||||||
|
JOIN core.mime_types mt ON mt.id = f.mime_id
|
||||||
|
WHERE f.id = $1`
|
||||||
|
|
||||||
|
q := connOrTx(ctx, r.pool)
|
||||||
|
rows, err := q.Query(ctx, sqlStr, fileID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("FileRepo.fetchAnchorVals: %w", err)
|
||||||
|
}
|
||||||
|
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[anchorValRow])
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, domain.ErrNotFound
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("FileRepo.fetchAnchorVals scan: %w", err)
|
||||||
|
}
|
||||||
|
return &row, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadTagsBatch fetches tags for multiple files in a single query and returns
|
||||||
|
// them as a map keyed by file ID. Every requested file ID appears as a key
|
||||||
|
// (with an empty slice if the file has no tags).
|
||||||
|
func (r *FileRepo) loadTagsBatch(ctx context.Context, fileIDs []uuid.UUID) (map[uuid.UUID][]domain.Tag, error) {
|
||||||
|
if len(fileIDs) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a parameterised IN list. The max page size is 200, so at most 200
|
||||||
|
// placeholders — well within PostgreSQL's limits.
|
||||||
|
placeholders := make([]string, len(fileIDs))
|
||||||
|
args := make([]any, len(fileIDs))
|
||||||
|
for i, id := range fileIDs {
|
||||||
|
placeholders[i] = fmt.Sprintf("$%d", i+1)
|
||||||
|
args[i] = id
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlStr := fmt.Sprintf(`
|
||||||
|
SELECT ft.file_id,
|
||||||
|
t.id, t.name, t.notes, t.color,
|
||||||
|
t.category_id,
|
||||||
|
c.name AS category_name,
|
||||||
|
c.color AS category_color,
|
||||||
|
t.metadata, t.creator_id, u.name AS creator_name, t.is_public
|
||||||
|
FROM data.file_tag ft
|
||||||
|
JOIN data.tags t ON t.id = ft.tag_id
|
||||||
|
JOIN core.users u ON u.id = t.creator_id
|
||||||
|
LEFT JOIN data.categories c ON c.id = t.category_id
|
||||||
|
WHERE ft.file_id IN (%s)
|
||||||
|
ORDER BY ft.file_id, t.name`, strings.Join(placeholders, ","))
|
||||||
|
|
||||||
|
q := connOrTx(ctx, r.pool)
|
||||||
|
rows, err := q.Query(ctx, sqlStr, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("FileRepo.loadTagsBatch: %w", err)
|
||||||
|
}
|
||||||
|
collected, err := pgx.CollectRows(rows, pgx.RowToStructByName[fileTagRow])
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("FileRepo.loadTagsBatch scan: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make(map[uuid.UUID][]domain.Tag, len(fileIDs))
|
||||||
|
for _, fid := range fileIDs {
|
||||||
|
result[fid] = []domain.Tag{} // guarantee every key has a non-nil slice
|
||||||
|
}
|
||||||
|
for _, row := range collected {
|
||||||
|
result[row.FileID] = append(result[row.FileID], toTagFromFileTag(row))
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
286
backend/internal/db/postgres/filter_parser.go
Normal file
286
backend/internal/db/postgres/filter_parser.go
Normal file
@ -0,0 +1,286 @@
|
|||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Token types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type filterTokenKind int
|
||||||
|
|
||||||
|
const (
|
||||||
|
ftkAnd filterTokenKind = iota
|
||||||
|
ftkOr
|
||||||
|
ftkNot
|
||||||
|
ftkLParen
|
||||||
|
ftkRParen
|
||||||
|
ftkTag // t=<uuid>
|
||||||
|
ftkMimeExact // m=<int>
|
||||||
|
ftkMimeLike // m~<pattern>
|
||||||
|
)
|
||||||
|
|
||||||
|
type filterToken struct {
|
||||||
|
kind filterTokenKind
|
||||||
|
tagID uuid.UUID // ftkTag
|
||||||
|
untagged bool // ftkTag with zero UUID → "file has no tags"
|
||||||
|
mimeID int16 // ftkMimeExact
|
||||||
|
pattern string // ftkMimeLike
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// AST nodes
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// filterNode produces a parameterized SQL fragment.
|
||||||
|
// n is the index of the next available positional parameter ($n).
|
||||||
|
// Returns the fragment, the updated n, and the extended args slice.
|
||||||
|
type filterNode interface {
|
||||||
|
toSQL(n int, args []any) (string, int, []any)
|
||||||
|
}
|
||||||
|
|
||||||
|
type andNode struct{ left, right filterNode }
|
||||||
|
type orNode struct{ left, right filterNode }
|
||||||
|
type notNode struct{ child filterNode }
|
||||||
|
type leafNode struct{ tok filterToken }
|
||||||
|
|
||||||
|
func (a *andNode) toSQL(n int, args []any) (string, int, []any) {
|
||||||
|
ls, n, args := a.left.toSQL(n, args)
|
||||||
|
rs, n, args := a.right.toSQL(n, args)
|
||||||
|
return "(" + ls + " AND " + rs + ")", n, args
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *orNode) toSQL(n int, args []any) (string, int, []any) {
|
||||||
|
ls, n, args := o.left.toSQL(n, args)
|
||||||
|
rs, n, args := o.right.toSQL(n, args)
|
||||||
|
return "(" + ls + " OR " + rs + ")", n, args
|
||||||
|
}
|
||||||
|
|
||||||
|
func (no *notNode) toSQL(n int, args []any) (string, int, []any) {
|
||||||
|
cs, n, args := no.child.toSQL(n, args)
|
||||||
|
return "(NOT " + cs + ")", n, args
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *leafNode) toSQL(n int, args []any) (string, int, []any) {
|
||||||
|
switch l.tok.kind {
|
||||||
|
case ftkTag:
|
||||||
|
if l.tok.untagged {
|
||||||
|
return "NOT EXISTS (SELECT 1 FROM data.file_tag ft WHERE ft.file_id = f.id)", n, args
|
||||||
|
}
|
||||||
|
s := fmt.Sprintf(
|
||||||
|
"EXISTS (SELECT 1 FROM data.file_tag ft WHERE ft.file_id = f.id AND ft.tag_id = $%d)", n)
|
||||||
|
return s, n + 1, append(args, l.tok.tagID)
|
||||||
|
case ftkMimeExact:
|
||||||
|
return fmt.Sprintf("f.mime_id = $%d", n), n + 1, append(args, l.tok.mimeID)
|
||||||
|
case ftkMimeLike:
|
||||||
|
// mt alias comes from the JOIN in the main file query (always present).
|
||||||
|
return fmt.Sprintf("mt.name LIKE $%d", n), n + 1, append(args, l.tok.pattern)
|
||||||
|
}
|
||||||
|
panic("filterNode.toSQL: unknown leaf kind")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Lexer
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// lexFilter tokenises the DSL string {a,b,c,...} into filterTokens.
|
||||||
|
func lexFilter(dsl string) ([]filterToken, error) {
|
||||||
|
dsl = strings.TrimSpace(dsl)
|
||||||
|
if !strings.HasPrefix(dsl, "{") || !strings.HasSuffix(dsl, "}") {
|
||||||
|
return nil, fmt.Errorf("filter DSL must be wrapped in braces: {…}")
|
||||||
|
}
|
||||||
|
inner := strings.TrimSpace(dsl[1 : len(dsl)-1])
|
||||||
|
if inner == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.Split(inner, ",")
|
||||||
|
tokens := make([]filterToken, 0, len(parts))
|
||||||
|
|
||||||
|
for _, raw := range parts {
|
||||||
|
p := strings.TrimSpace(raw)
|
||||||
|
switch {
|
||||||
|
case p == "&":
|
||||||
|
tokens = append(tokens, filterToken{kind: ftkAnd})
|
||||||
|
case p == "|":
|
||||||
|
tokens = append(tokens, filterToken{kind: ftkOr})
|
||||||
|
case p == "!":
|
||||||
|
tokens = append(tokens, filterToken{kind: ftkNot})
|
||||||
|
case p == "(":
|
||||||
|
tokens = append(tokens, filterToken{kind: ftkLParen})
|
||||||
|
case p == ")":
|
||||||
|
tokens = append(tokens, filterToken{kind: ftkRParen})
|
||||||
|
case strings.HasPrefix(p, "t="):
|
||||||
|
id, err := uuid.Parse(p[2:])
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("filter: invalid tag UUID %q", p[2:])
|
||||||
|
}
|
||||||
|
tokens = append(tokens, filterToken{kind: ftkTag, tagID: id, untagged: id == uuid.Nil})
|
||||||
|
case strings.HasPrefix(p, "m="):
|
||||||
|
v, err := strconv.ParseInt(p[2:], 10, 16)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("filter: invalid MIME ID %q", p[2:])
|
||||||
|
}
|
||||||
|
tokens = append(tokens, filterToken{kind: ftkMimeExact, mimeID: int16(v)})
|
||||||
|
case strings.HasPrefix(p, "m~"):
|
||||||
|
// The pattern value is passed as a query parameter, so no SQL injection risk.
|
||||||
|
tokens = append(tokens, filterToken{kind: ftkMimeLike, pattern: p[2:]})
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("filter: unknown token %q", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tokens, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Recursive-descent parser
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type filterParser struct {
|
||||||
|
tokens []filterToken
|
||||||
|
pos int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *filterParser) peek() (filterToken, bool) {
|
||||||
|
if p.pos >= len(p.tokens) {
|
||||||
|
return filterToken{}, false
|
||||||
|
}
|
||||||
|
return p.tokens[p.pos], true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *filterParser) next() filterToken {
|
||||||
|
t := p.tokens[p.pos]
|
||||||
|
p.pos++
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grammar (standard NOT > AND > OR precedence):
|
||||||
|
//
|
||||||
|
// expr := or_expr
|
||||||
|
// or_expr := and_expr ('|' and_expr)*
|
||||||
|
// and_expr := not_expr ('&' not_expr)*
|
||||||
|
// not_expr := '!' not_expr | atom
|
||||||
|
// atom := '(' expr ')' | leaf
|
||||||
|
|
||||||
|
func (p *filterParser) parseExpr() (filterNode, error) { return p.parseOr() }
|
||||||
|
|
||||||
|
func (p *filterParser) parseOr() (filterNode, error) {
|
||||||
|
left, err := p.parseAnd()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
t, ok := p.peek()
|
||||||
|
if !ok || t.kind != ftkOr {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
p.next()
|
||||||
|
right, err := p.parseAnd()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
left = &orNode{left, right}
|
||||||
|
}
|
||||||
|
return left, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *filterParser) parseAnd() (filterNode, error) {
|
||||||
|
left, err := p.parseNot()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
t, ok := p.peek()
|
||||||
|
if !ok || t.kind != ftkAnd {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
p.next()
|
||||||
|
right, err := p.parseNot()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
left = &andNode{left, right}
|
||||||
|
}
|
||||||
|
return left, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *filterParser) parseNot() (filterNode, error) {
|
||||||
|
t, ok := p.peek()
|
||||||
|
if ok && t.kind == ftkNot {
|
||||||
|
p.next()
|
||||||
|
child, err := p.parseNot() // right-recursive to allow !!x
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return ¬Node{child}, nil
|
||||||
|
}
|
||||||
|
return p.parseAtom()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *filterParser) parseAtom() (filterNode, error) {
|
||||||
|
t, ok := p.peek()
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("filter: unexpected end of expression")
|
||||||
|
}
|
||||||
|
if t.kind == ftkLParen {
|
||||||
|
p.next()
|
||||||
|
expr, err := p.parseExpr()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rp, ok := p.peek()
|
||||||
|
if !ok || rp.kind != ftkRParen {
|
||||||
|
return nil, fmt.Errorf("filter: expected ')'")
|
||||||
|
}
|
||||||
|
p.next()
|
||||||
|
return expr, nil
|
||||||
|
}
|
||||||
|
switch t.kind {
|
||||||
|
case ftkTag, ftkMimeExact, ftkMimeLike:
|
||||||
|
p.next()
|
||||||
|
return &leafNode{t}, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("filter: unexpected token at position %d", p.pos)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public entry point
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// ParseFilter parses a filter DSL string into a parameterized SQL fragment.
|
||||||
|
//
|
||||||
|
// argStart is the 1-based index for the first $N placeholder; this lets the
|
||||||
|
// caller interleave filter parameters with other query parameters.
|
||||||
|
//
|
||||||
|
// Returns ("", argStart, nil, nil) for an empty or trivial DSL.
|
||||||
|
// SQL injection is structurally impossible: every user-supplied value is
|
||||||
|
// bound as a query parameter ($N), never interpolated into the SQL string.
|
||||||
|
func ParseFilter(dsl string, argStart int) (sql string, nextN int, args []any, err error) {
|
||||||
|
dsl = strings.TrimSpace(dsl)
|
||||||
|
if dsl == "" || dsl == "{}" {
|
||||||
|
return "", argStart, nil, nil
|
||||||
|
}
|
||||||
|
toks, err := lexFilter(dsl)
|
||||||
|
if err != nil {
|
||||||
|
return "", argStart, nil, err
|
||||||
|
}
|
||||||
|
if len(toks) == 0 {
|
||||||
|
return "", argStart, nil, nil
|
||||||
|
}
|
||||||
|
p := &filterParser{tokens: toks}
|
||||||
|
node, err := p.parseExpr()
|
||||||
|
if err != nil {
|
||||||
|
return "", argStart, nil, err
|
||||||
|
}
|
||||||
|
if p.pos != len(p.tokens) {
|
||||||
|
return "", argStart, nil, fmt.Errorf("filter: trailing tokens at position %d", p.pos)
|
||||||
|
}
|
||||||
|
sql, nextN, args = node.toSQL(argStart, nil)
|
||||||
|
return sql, nextN, args, nil
|
||||||
|
}
|
||||||
755
backend/internal/handler/file_handler.go
Normal file
755
backend/internal/handler/file_handler.go
Normal file
@ -0,0 +1,755 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gabriel-vasile/mimetype"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"tanabata/backend/internal/domain"
|
||||||
|
"tanabata/backend/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FileHandler handles all /files endpoints.
|
||||||
|
type FileHandler struct {
|
||||||
|
fileSvc *service.FileService
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFileHandler creates a FileHandler.
|
||||||
|
func NewFileHandler(fileSvc *service.FileService) *FileHandler {
|
||||||
|
return &FileHandler{fileSvc: fileSvc}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Response types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type tagJSON struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Notes *string `json:"notes"`
|
||||||
|
Color *string `json:"color"`
|
||||||
|
CategoryID *string `json:"category_id"`
|
||||||
|
CategoryName *string `json:"category_name"`
|
||||||
|
CategoryColor *string `json:"category_color"`
|
||||||
|
CreatorID int16 `json:"creator_id"`
|
||||||
|
CreatorName string `json:"creator_name"`
|
||||||
|
IsPublic bool `json:"is_public"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type fileJSON struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
OriginalName *string `json:"original_name"`
|
||||||
|
MIMEType string `json:"mime_type"`
|
||||||
|
MIMEExtension string `json:"mime_extension"`
|
||||||
|
ContentDatetime string `json:"content_datetime"`
|
||||||
|
Notes *string `json:"notes"`
|
||||||
|
Metadata json.RawMessage `json:"metadata"`
|
||||||
|
EXIF json.RawMessage `json:"exif"`
|
||||||
|
PHash *int64 `json:"phash"`
|
||||||
|
CreatorID int16 `json:"creator_id"`
|
||||||
|
CreatorName string `json:"creator_name"`
|
||||||
|
IsPublic bool `json:"is_public"`
|
||||||
|
IsDeleted bool `json:"is_deleted"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
Tags []tagJSON `json:"tags"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func toTagJSON(t domain.Tag) tagJSON {
|
||||||
|
j := tagJSON{
|
||||||
|
ID: t.ID.String(),
|
||||||
|
Name: t.Name,
|
||||||
|
Notes: t.Notes,
|
||||||
|
Color: t.Color,
|
||||||
|
CategoryName: t.CategoryName,
|
||||||
|
CategoryColor: t.CategoryColor,
|
||||||
|
CreatorID: t.CreatorID,
|
||||||
|
CreatorName: t.CreatorName,
|
||||||
|
IsPublic: t.IsPublic,
|
||||||
|
CreatedAt: t.CreatedAt.Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
if t.CategoryID != nil {
|
||||||
|
s := t.CategoryID.String()
|
||||||
|
j.CategoryID = &s
|
||||||
|
}
|
||||||
|
return j
|
||||||
|
}
|
||||||
|
|
||||||
|
func toFileJSON(f domain.File) fileJSON {
|
||||||
|
tags := make([]tagJSON, len(f.Tags))
|
||||||
|
for i, t := range f.Tags {
|
||||||
|
tags[i] = toTagJSON(t)
|
||||||
|
}
|
||||||
|
exif := f.EXIF
|
||||||
|
if exif == nil {
|
||||||
|
exif = json.RawMessage("{}")
|
||||||
|
}
|
||||||
|
return fileJSON{
|
||||||
|
ID: f.ID.String(),
|
||||||
|
OriginalName: f.OriginalName,
|
||||||
|
MIMEType: f.MIMEType,
|
||||||
|
MIMEExtension: f.MIMEExtension,
|
||||||
|
ContentDatetime: f.ContentDatetime.Format(time.RFC3339),
|
||||||
|
Notes: f.Notes,
|
||||||
|
Metadata: f.Metadata,
|
||||||
|
EXIF: exif,
|
||||||
|
PHash: f.PHash,
|
||||||
|
CreatorID: f.CreatorID,
|
||||||
|
CreatorName: f.CreatorName,
|
||||||
|
IsPublic: f.IsPublic,
|
||||||
|
IsDeleted: f.IsDeleted,
|
||||||
|
CreatedAt: f.CreatedAt.Format(time.RFC3339),
|
||||||
|
Tags: tags,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helper
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func parseFileID(c *gin.Context) (uuid.UUID, bool) {
|
||||||
|
id, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, domain.ErrValidation)
|
||||||
|
return uuid.UUID{}, false
|
||||||
|
}
|
||||||
|
return id, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GET /files
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func (h *FileHandler) List(c *gin.Context) {
|
||||||
|
params := domain.FileListParams{
|
||||||
|
Cursor: c.Query("cursor"),
|
||||||
|
Direction: c.DefaultQuery("direction", "forward"),
|
||||||
|
Sort: c.DefaultQuery("sort", "created"),
|
||||||
|
Order: c.DefaultQuery("order", "desc"),
|
||||||
|
Filter: c.Query("filter"),
|
||||||
|
Search: c.Query("search"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if limitStr := c.Query("limit"); limitStr != "" {
|
||||||
|
n, err := strconv.Atoi(limitStr)
|
||||||
|
if err != nil || n < 1 || n > 200 {
|
||||||
|
respondError(c, domain.ErrValidation)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
params.Limit = n
|
||||||
|
} else {
|
||||||
|
params.Limit = 50
|
||||||
|
}
|
||||||
|
|
||||||
|
if anchorStr := c.Query("anchor"); anchorStr != "" {
|
||||||
|
id, err := uuid.Parse(anchorStr)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, domain.ErrValidation)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
params.Anchor = &id
|
||||||
|
}
|
||||||
|
|
||||||
|
if trashStr := c.Query("trash"); trashStr == "true" || trashStr == "1" {
|
||||||
|
params.Trash = true
|
||||||
|
}
|
||||||
|
|
||||||
|
page, err := h.fileSvc.List(c.Request.Context(), params)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]fileJSON, len(page.Items))
|
||||||
|
for i, f := range page.Items {
|
||||||
|
items[i] = toFileJSON(f)
|
||||||
|
}
|
||||||
|
|
||||||
|
respondJSON(c, http.StatusOK, gin.H{
|
||||||
|
"items": items,
|
||||||
|
"next_cursor": page.NextCursor,
|
||||||
|
"prev_cursor": page.PrevCursor,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// POST /files (multipart upload)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func (h *FileHandler) Upload(c *gin.Context) {
|
||||||
|
fh, err := c.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, domain.ErrValidation)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
src, err := fh.Open()
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer src.Close()
|
||||||
|
|
||||||
|
// Detect MIME from actual bytes (ignore client-supplied Content-Type).
|
||||||
|
mt, err := mimetype.DetectReader(src)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Rewind by reopening — FormFile gives a multipart.File which supports Seek.
|
||||||
|
if _, err := src.Seek(0, io.SeekStart); err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mimeStr := strings.SplitN(mt.String(), ";", 2)[0]
|
||||||
|
|
||||||
|
params := service.UploadParams{
|
||||||
|
Reader: src,
|
||||||
|
MIMEType: mimeStr,
|
||||||
|
IsPublic: c.PostForm("is_public") == "true",
|
||||||
|
}
|
||||||
|
|
||||||
|
if name := fh.Filename; name != "" {
|
||||||
|
params.OriginalName = &name
|
||||||
|
}
|
||||||
|
if notes := c.PostForm("notes"); notes != "" {
|
||||||
|
params.Notes = ¬es
|
||||||
|
}
|
||||||
|
if metaStr := c.PostForm("metadata"); metaStr != "" {
|
||||||
|
params.Metadata = json.RawMessage(metaStr)
|
||||||
|
}
|
||||||
|
if dtStr := c.PostForm("content_datetime"); dtStr != "" {
|
||||||
|
t, err := time.Parse(time.RFC3339, dtStr)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, domain.ErrValidation)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
params.ContentDatetime = &t
|
||||||
|
}
|
||||||
|
if tagIDsStr := c.PostForm("tag_ids"); tagIDsStr != "" {
|
||||||
|
for _, raw := range strings.Split(tagIDsStr, ",") {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
id, err := uuid.Parse(raw)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, domain.ErrValidation)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
params.TagIDs = append(params.TagIDs, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := h.fileSvc.Upload(c.Request.Context(), params)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
respondJSON(c, http.StatusCreated, toFileJSON(*f))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GET /files/:id
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func (h *FileHandler) GetMeta(c *gin.Context) {
|
||||||
|
id, ok := parseFileID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := h.fileSvc.Get(c.Request.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
respondJSON(c, http.StatusOK, toFileJSON(*f))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// PATCH /files/:id
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func (h *FileHandler) UpdateMeta(c *gin.Context) {
|
||||||
|
id, ok := parseFileID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
OriginalName *string `json:"original_name"`
|
||||||
|
ContentDatetime *string `json:"content_datetime"`
|
||||||
|
Notes *string `json:"notes"`
|
||||||
|
Metadata json.RawMessage `json:"metadata"`
|
||||||
|
IsPublic *bool `json:"is_public"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
respondError(c, domain.ErrValidation)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
params := service.UpdateParams{
|
||||||
|
OriginalName: body.OriginalName,
|
||||||
|
Notes: body.Notes,
|
||||||
|
Metadata: body.Metadata,
|
||||||
|
IsPublic: body.IsPublic,
|
||||||
|
}
|
||||||
|
if body.ContentDatetime != nil {
|
||||||
|
t, err := time.Parse(time.RFC3339, *body.ContentDatetime)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, domain.ErrValidation)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
params.ContentDatetime = &t
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := h.fileSvc.Update(c.Request.Context(), id, params)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
respondJSON(c, http.StatusOK, toFileJSON(*f))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// DELETE /files/:id (soft-delete)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func (h *FileHandler) SoftDelete(c *gin.Context) {
|
||||||
|
id, ok := parseFileID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.fileSvc.Delete(c.Request.Context(), id); err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GET /files/:id/content
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func (h *FileHandler) GetContent(c *gin.Context) {
|
||||||
|
id, ok := parseFileID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := h.fileSvc.GetContent(c.Request.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
|
||||||
|
c.Header("Content-Type", res.MIMEType)
|
||||||
|
if res.OriginalName != nil {
|
||||||
|
c.Header("Content-Disposition",
|
||||||
|
fmt.Sprintf("attachment; filename=%q", *res.OriginalName))
|
||||||
|
}
|
||||||
|
c.Status(http.StatusOK)
|
||||||
|
io.Copy(c.Writer, res.Body) //nolint:errcheck
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// PUT /files/:id/content (replace)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func (h *FileHandler) ReplaceContent(c *gin.Context) {
|
||||||
|
id, ok := parseFileID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fh, err := c.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, domain.ErrValidation)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
src, err := fh.Open()
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer src.Close()
|
||||||
|
|
||||||
|
mt, err := mimetype.DetectReader(src)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := src.Seek(0, io.SeekStart); err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mimeStr := strings.SplitN(mt.String(), ";", 2)[0]
|
||||||
|
|
||||||
|
name := fh.Filename
|
||||||
|
params := service.UploadParams{
|
||||||
|
Reader: src,
|
||||||
|
MIMEType: mimeStr,
|
||||||
|
OriginalName: &name,
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := h.fileSvc.Replace(c.Request.Context(), id, params)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
respondJSON(c, http.StatusOK, toFileJSON(*f))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GET /files/:id/thumbnail
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func (h *FileHandler) GetThumbnail(c *gin.Context) {
|
||||||
|
id, ok := parseFileID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rc, err := h.fileSvc.GetThumbnail(c.Request.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rc.Close()
|
||||||
|
|
||||||
|
c.Header("Content-Type", "image/jpeg")
|
||||||
|
c.Status(http.StatusOK)
|
||||||
|
io.Copy(c.Writer, rc) //nolint:errcheck
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GET /files/:id/preview
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func (h *FileHandler) GetPreview(c *gin.Context) {
|
||||||
|
id, ok := parseFileID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rc, err := h.fileSvc.GetPreview(c.Request.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rc.Close()
|
||||||
|
|
||||||
|
c.Header("Content-Type", "image/jpeg")
|
||||||
|
c.Status(http.StatusOK)
|
||||||
|
io.Copy(c.Writer, rc) //nolint:errcheck
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// POST /files/:id/restore
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func (h *FileHandler) Restore(c *gin.Context) {
|
||||||
|
id, ok := parseFileID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := h.fileSvc.Restore(c.Request.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
respondJSON(c, http.StatusOK, toFileJSON(*f))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// DELETE /files/:id/permanent
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func (h *FileHandler) PermanentDelete(c *gin.Context) {
|
||||||
|
id, ok := parseFileID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.fileSvc.PermanentDelete(c.Request.Context(), id); err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GET /files/:id/tags
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func (h *FileHandler) ListTags(c *gin.Context) {
|
||||||
|
id, ok := parseFileID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tags, err := h.fileSvc.ListFileTags(c.Request.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]tagJSON, len(tags))
|
||||||
|
for i, t := range tags {
|
||||||
|
items[i] = toTagJSON(t)
|
||||||
|
}
|
||||||
|
respondJSON(c, http.StatusOK, items)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// PUT /files/:id/tags (replace all)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func (h *FileHandler) SetTags(c *gin.Context) {
|
||||||
|
id, ok := parseFileID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
TagIDs []string `json:"tag_ids" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
respondError(c, domain.ErrValidation)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tagIDs, err := parseUUIDs(body.TagIDs)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, domain.ErrValidation)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tags, err := h.fileSvc.SetFileTags(c.Request.Context(), id, tagIDs)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]tagJSON, len(tags))
|
||||||
|
for i, t := range tags {
|
||||||
|
items[i] = toTagJSON(t)
|
||||||
|
}
|
||||||
|
respondJSON(c, http.StatusOK, items)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// PUT /files/:id/tags/:tag_id
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func (h *FileHandler) AddTag(c *gin.Context) {
|
||||||
|
fileID, ok := parseFileID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tagID, err := uuid.Parse(c.Param("tag_id"))
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, domain.ErrValidation)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tags, err := h.fileSvc.AddTag(c.Request.Context(), fileID, tagID)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]tagJSON, len(tags))
|
||||||
|
for i, t := range tags {
|
||||||
|
items[i] = toTagJSON(t)
|
||||||
|
}
|
||||||
|
respondJSON(c, http.StatusOK, items)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// DELETE /files/:id/tags/:tag_id
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func (h *FileHandler) RemoveTag(c *gin.Context) {
|
||||||
|
fileID, ok := parseFileID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tagID, err := uuid.Parse(c.Param("tag_id"))
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, domain.ErrValidation)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.fileSvc.RemoveTag(c.Request.Context(), fileID, tagID); err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// POST /files/bulk/tags
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func (h *FileHandler) BulkSetTags(c *gin.Context) {
|
||||||
|
var body struct {
|
||||||
|
FileIDs []string `json:"file_ids" binding:"required"`
|
||||||
|
Action string `json:"action" binding:"required"`
|
||||||
|
TagIDs []string `json:"tag_ids" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
respondError(c, domain.ErrValidation)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.Action != "add" && body.Action != "remove" {
|
||||||
|
respondError(c, domain.ErrValidation)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fileIDs, err := parseUUIDs(body.FileIDs)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, domain.ErrValidation)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tagIDs, err := parseUUIDs(body.TagIDs)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, domain.ErrValidation)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
applied, err := h.fileSvc.BulkSetTags(c.Request.Context(), fileIDs, body.Action, tagIDs)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
strs := make([]string, len(applied))
|
||||||
|
for i, id := range applied {
|
||||||
|
strs[i] = id.String()
|
||||||
|
}
|
||||||
|
respondJSON(c, http.StatusOK, gin.H{"applied_tag_ids": strs})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// POST /files/bulk/delete
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func (h *FileHandler) BulkDelete(c *gin.Context) {
|
||||||
|
var body struct {
|
||||||
|
FileIDs []string `json:"file_ids" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
respondError(c, domain.ErrValidation)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fileIDs, err := parseUUIDs(body.FileIDs)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, domain.ErrValidation)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.fileSvc.BulkDelete(c.Request.Context(), fileIDs); err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// POST /files/bulk/common-tags
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func (h *FileHandler) CommonTags(c *gin.Context) {
|
||||||
|
var body struct {
|
||||||
|
FileIDs []string `json:"file_ids" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
respondError(c, domain.ErrValidation)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fileIDs, err := parseUUIDs(body.FileIDs)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, domain.ErrValidation)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
common, partial, err := h.fileSvc.CommonTags(c.Request.Context(), fileIDs)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
toStrs := func(ids []uuid.UUID) []string {
|
||||||
|
s := make([]string, len(ids))
|
||||||
|
for i, id := range ids {
|
||||||
|
s[i] = id.String()
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
respondJSON(c, http.StatusOK, gin.H{
|
||||||
|
"common_tag_ids": toStrs(common),
|
||||||
|
"partial_tag_ids": toStrs(partial),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// POST /files/import
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func (h *FileHandler) Import(c *gin.Context) {
|
||||||
|
var body struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
}
|
||||||
|
// Body is optional; ignore bind errors.
|
||||||
|
_ = c.ShouldBindJSON(&body)
|
||||||
|
|
||||||
|
result, err := h.fileSvc.Import(c.Request.Context(), body.Path)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
respondJSON(c, http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func parseUUIDs(strs []string) ([]uuid.UUID, error) {
|
||||||
|
ids := make([]uuid.UUID, 0, len(strs))
|
||||||
|
for _, s := range strs {
|
||||||
|
id, err := uuid.Parse(s)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
return ids, nil
|
||||||
|
}
|
||||||
@ -7,8 +7,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// NewRouter builds and returns a configured Gin engine.
|
// NewRouter builds and returns a configured Gin engine.
|
||||||
// Additional handlers will be added here as they are implemented.
|
func NewRouter(auth *AuthMiddleware, authHandler *AuthHandler, fileHandler *FileHandler) *gin.Engine {
|
||||||
func NewRouter(auth *AuthMiddleware, authHandler *AuthHandler) *gin.Engine {
|
|
||||||
r := gin.New()
|
r := gin.New()
|
||||||
r.Use(gin.Logger(), gin.Recovery())
|
r.Use(gin.Logger(), gin.Recovery())
|
||||||
|
|
||||||
@ -33,5 +32,35 @@ func NewRouter(auth *AuthMiddleware, authHandler *AuthHandler) *gin.Engine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// File endpoints — all require authentication.
|
||||||
|
files := v1.Group("/files", auth.Handle())
|
||||||
|
{
|
||||||
|
files.GET("", fileHandler.List)
|
||||||
|
files.POST("", fileHandler.Upload)
|
||||||
|
|
||||||
|
// Bulk routes must be registered before /:id to avoid ambiguity.
|
||||||
|
files.POST("/bulk/tags", fileHandler.BulkSetTags)
|
||||||
|
files.POST("/bulk/delete", fileHandler.BulkDelete)
|
||||||
|
files.POST("/bulk/common-tags", fileHandler.CommonTags)
|
||||||
|
files.POST("/import", fileHandler.Import)
|
||||||
|
|
||||||
|
// Per-file routes.
|
||||||
|
files.GET("/:id", fileHandler.GetMeta)
|
||||||
|
files.PATCH("/:id", fileHandler.UpdateMeta)
|
||||||
|
files.DELETE("/:id", fileHandler.SoftDelete)
|
||||||
|
|
||||||
|
files.GET("/:id/content", fileHandler.GetContent)
|
||||||
|
files.PUT("/:id/content", fileHandler.ReplaceContent)
|
||||||
|
files.GET("/:id/thumbnail", fileHandler.GetThumbnail)
|
||||||
|
files.GET("/:id/preview", fileHandler.GetPreview)
|
||||||
|
files.POST("/:id/restore", fileHandler.Restore)
|
||||||
|
files.DELETE("/:id/permanent", fileHandler.PermanentDelete)
|
||||||
|
|
||||||
|
files.GET("/:id/tags", fileHandler.ListTags)
|
||||||
|
files.PUT("/:id/tags", fileHandler.SetTags)
|
||||||
|
files.PUT("/:id/tags/:tag_id", fileHandler.AddTag)
|
||||||
|
files.DELETE("/:id/tags/:tag_id", fileHandler.RemoveTag)
|
||||||
|
}
|
||||||
|
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
@ -11,15 +11,15 @@ import (
|
|||||||
// thumbnails, and previews.
|
// thumbnails, and previews.
|
||||||
type FileStorage interface {
|
type FileStorage interface {
|
||||||
// Save writes the reader's content to storage and returns the number of
|
// Save writes the reader's content to storage and returns the number of
|
||||||
// bytes written. ext is the file extension without a leading dot (e.g. "jpg").
|
// bytes written.
|
||||||
Save(ctx context.Context, id uuid.UUID, ext string, r io.Reader) (int64, error)
|
Save(ctx context.Context, id uuid.UUID, r io.Reader) (int64, error)
|
||||||
|
|
||||||
// Read opens the file content for reading. The caller must close the returned
|
// Read opens the file content for reading. The caller must close the returned
|
||||||
// ReadCloser.
|
// ReadCloser.
|
||||||
Read(ctx context.Context, id uuid.UUID, ext string) (io.ReadCloser, error)
|
Read(ctx context.Context, id uuid.UUID) (io.ReadCloser, error)
|
||||||
|
|
||||||
// Delete removes the file content from storage.
|
// Delete removes the file content from storage.
|
||||||
Delete(ctx context.Context, id uuid.UUID, ext string) error
|
Delete(ctx context.Context, id uuid.UUID) error
|
||||||
|
|
||||||
// Thumbnail opens the pre-generated thumbnail (JPEG). Returns ErrNotFound
|
// Thumbnail opens the pre-generated thumbnail (JPEG). Returns ErrNotFound
|
||||||
// if the thumbnail has not been generated yet.
|
// if the thumbnail has not been generated yet.
|
||||||
|
|||||||
759
backend/internal/service/file_service.go
Normal file
759
backend/internal/service/file_service.go
Normal file
@ -0,0 +1,759 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gabriel-vasile/mimetype"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/rwcarlsen/goexif/exif"
|
||||||
|
|
||||||
|
"tanabata/backend/internal/domain"
|
||||||
|
"tanabata/backend/internal/port"
|
||||||
|
)
|
||||||
|
|
||||||
|
const fileObjectType = "file"
|
||||||
|
|
||||||
|
// fileObjectTypeID is the primary key of the "file" row in core.object_types.
|
||||||
|
// It matches the first value inserted in 007_seed_data.sql.
|
||||||
|
const fileObjectTypeID int16 = 1
|
||||||
|
|
||||||
|
// UploadParams holds the parameters for uploading a new file.
|
||||||
|
type UploadParams struct {
|
||||||
|
Reader io.Reader
|
||||||
|
MIMEType string
|
||||||
|
OriginalName *string
|
||||||
|
Notes *string
|
||||||
|
Metadata json.RawMessage
|
||||||
|
ContentDatetime *time.Time
|
||||||
|
IsPublic bool
|
||||||
|
TagIDs []uuid.UUID
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateParams holds the parameters for updating file metadata.
|
||||||
|
type UpdateParams struct {
|
||||||
|
OriginalName *string
|
||||||
|
Notes *string
|
||||||
|
Metadata json.RawMessage
|
||||||
|
ContentDatetime *time.Time
|
||||||
|
IsPublic *bool
|
||||||
|
TagIDs *[]uuid.UUID // nil means don't change tags
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContentResult holds the open reader and metadata for a file download.
|
||||||
|
type ContentResult struct {
|
||||||
|
Body io.ReadCloser
|
||||||
|
MIMEType string
|
||||||
|
OriginalName *string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImportFileError records a failed file during an import operation.
|
||||||
|
type ImportFileError struct {
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImportResult summarises a directory import.
|
||||||
|
type ImportResult struct {
|
||||||
|
Imported int `json:"imported"`
|
||||||
|
Skipped int `json:"skipped"`
|
||||||
|
Errors []ImportFileError `json:"errors"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileService handles business logic for file records.
|
||||||
|
type FileService struct {
|
||||||
|
files port.FileRepo
|
||||||
|
mimes port.MimeRepo
|
||||||
|
storage port.FileStorage
|
||||||
|
acl *ACLService
|
||||||
|
audit *AuditService
|
||||||
|
tx port.Transactor
|
||||||
|
importPath string // default server-side import directory
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFileService creates a FileService.
|
||||||
|
func NewFileService(
|
||||||
|
files port.FileRepo,
|
||||||
|
mimes port.MimeRepo,
|
||||||
|
storage port.FileStorage,
|
||||||
|
acl *ACLService,
|
||||||
|
audit *AuditService,
|
||||||
|
tx port.Transactor,
|
||||||
|
importPath string,
|
||||||
|
) *FileService {
|
||||||
|
return &FileService{
|
||||||
|
files: files,
|
||||||
|
mimes: mimes,
|
||||||
|
storage: storage,
|
||||||
|
acl: acl,
|
||||||
|
audit: audit,
|
||||||
|
tx: tx,
|
||||||
|
importPath: importPath,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Core CRUD
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Upload validates the MIME type, saves the file to storage, creates the DB
|
||||||
|
// record, and applies any initial tags — all within a single transaction.
|
||||||
|
// If ContentDatetime is nil and EXIF DateTimeOriginal is present, it is used.
|
||||||
|
func (s *FileService) Upload(ctx context.Context, p UploadParams) (*domain.File, error) {
|
||||||
|
userID, _, _ := domain.UserFromContext(ctx)
|
||||||
|
|
||||||
|
// Validate MIME type against the whitelist.
|
||||||
|
mime, err := s.mimes.GetByName(ctx, p.MIMEType)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err // ErrUnsupportedMIME or DB error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buffer the upload so we can extract EXIF without re-reading storage.
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if _, err := io.Copy(&buf, p.Reader); err != nil {
|
||||||
|
return nil, fmt.Errorf("FileService.Upload: read body: %w", err)
|
||||||
|
}
|
||||||
|
data := buf.Bytes()
|
||||||
|
|
||||||
|
// Extract EXIF metadata (best-effort; non-image files will error silently).
|
||||||
|
exifData, exifDatetime := extractEXIFWithDatetime(data)
|
||||||
|
|
||||||
|
// Resolve content datetime: explicit > EXIF > zero value.
|
||||||
|
var contentDatetime time.Time
|
||||||
|
if p.ContentDatetime != nil {
|
||||||
|
contentDatetime = *p.ContentDatetime
|
||||||
|
} else if exifDatetime != nil {
|
||||||
|
contentDatetime = *exifDatetime
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assign UUID v7 so CreatedAt can be derived from it later.
|
||||||
|
fileID, err := uuid.NewV7()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("FileService.Upload: generate UUID: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save file bytes to disk before opening the transaction so that a disk
|
||||||
|
// failure does not abort an otherwise healthy DB transaction.
|
||||||
|
if _, err := s.storage.Save(ctx, fileID, bytes.NewReader(data)); err != nil {
|
||||||
|
return nil, fmt.Errorf("FileService.Upload: save to storage: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var created *domain.File
|
||||||
|
txErr := s.tx.WithTx(ctx, func(ctx context.Context) error {
|
||||||
|
f := &domain.File{
|
||||||
|
ID: fileID,
|
||||||
|
OriginalName: p.OriginalName,
|
||||||
|
MIMEType: mime.Name,
|
||||||
|
MIMEExtension: mime.Extension,
|
||||||
|
ContentDatetime: contentDatetime,
|
||||||
|
Notes: p.Notes,
|
||||||
|
Metadata: p.Metadata,
|
||||||
|
EXIF: exifData,
|
||||||
|
CreatorID: userID,
|
||||||
|
IsPublic: p.IsPublic,
|
||||||
|
}
|
||||||
|
|
||||||
|
var createErr error
|
||||||
|
created, createErr = s.files.Create(ctx, f)
|
||||||
|
if createErr != nil {
|
||||||
|
return createErr
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(p.TagIDs) > 0 {
|
||||||
|
if err := s.files.SetTags(ctx, created.ID, p.TagIDs); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tags, err := s.files.ListTags(ctx, created.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
created.Tags = tags
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if txErr != nil {
|
||||||
|
// Attempt to clean up the orphaned file; ignore cleanup errors.
|
||||||
|
_ = s.storage.Delete(ctx, fileID)
|
||||||
|
return nil, txErr
|
||||||
|
}
|
||||||
|
|
||||||
|
objType := fileObjectType
|
||||||
|
_ = s.audit.Log(ctx, "file_create", &objType, &created.ID, nil)
|
||||||
|
return created, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns a file by ID, enforcing view ACL.
|
||||||
|
func (s *FileService) Get(ctx context.Context, id uuid.UUID) (*domain.File, error) {
|
||||||
|
userID, isAdmin, _ := domain.UserFromContext(ctx)
|
||||||
|
|
||||||
|
f, err := s.files.GetByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
ok, err := s.acl.CanView(ctx, userID, isAdmin, f.CreatorID, f.IsPublic, fileObjectTypeID, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return nil, domain.ErrForbidden
|
||||||
|
}
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update applies metadata changes to a file, enforcing edit ACL.
|
||||||
|
func (s *FileService) Update(ctx context.Context, id uuid.UUID, p UpdateParams) (*domain.File, error) {
|
||||||
|
userID, isAdmin, _ := domain.UserFromContext(ctx)
|
||||||
|
|
||||||
|
f, err := s.files.GetByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
ok, err := s.acl.CanEdit(ctx, userID, isAdmin, f.CreatorID, fileObjectTypeID, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return nil, domain.ErrForbidden
|
||||||
|
}
|
||||||
|
|
||||||
|
patch := &domain.File{}
|
||||||
|
if p.OriginalName != nil {
|
||||||
|
patch.OriginalName = p.OriginalName
|
||||||
|
}
|
||||||
|
if p.Notes != nil {
|
||||||
|
patch.Notes = p.Notes
|
||||||
|
}
|
||||||
|
if p.Metadata != nil {
|
||||||
|
patch.Metadata = p.Metadata
|
||||||
|
}
|
||||||
|
if p.ContentDatetime != nil {
|
||||||
|
patch.ContentDatetime = *p.ContentDatetime
|
||||||
|
}
|
||||||
|
if p.IsPublic != nil {
|
||||||
|
patch.IsPublic = *p.IsPublic
|
||||||
|
}
|
||||||
|
|
||||||
|
var updated *domain.File
|
||||||
|
txErr := s.tx.WithTx(ctx, func(ctx context.Context) error {
|
||||||
|
var updateErr error
|
||||||
|
updated, updateErr = s.files.Update(ctx, id, patch)
|
||||||
|
if updateErr != nil {
|
||||||
|
return updateErr
|
||||||
|
}
|
||||||
|
if p.TagIDs != nil {
|
||||||
|
if err := s.files.SetTags(ctx, id, *p.TagIDs); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tags, err := s.files.ListTags(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
updated.Tags = tags
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if txErr != nil {
|
||||||
|
return nil, txErr
|
||||||
|
}
|
||||||
|
|
||||||
|
objType := fileObjectType
|
||||||
|
_ = s.audit.Log(ctx, "file_edit", &objType, &id, nil)
|
||||||
|
return updated, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete soft-deletes a file (moves to trash), enforcing edit ACL.
|
||||||
|
func (s *FileService) Delete(ctx context.Context, id uuid.UUID) error {
|
||||||
|
userID, isAdmin, _ := domain.UserFromContext(ctx)
|
||||||
|
|
||||||
|
f, err := s.files.GetByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
ok, err := s.acl.CanEdit(ctx, userID, isAdmin, f.CreatorID, fileObjectTypeID, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return domain.ErrForbidden
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.files.SoftDelete(ctx, id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
objType := fileObjectType
|
||||||
|
_ = s.audit.Log(ctx, "file_delete", &objType, &id, nil)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore moves a soft-deleted file out of trash, enforcing edit ACL.
|
||||||
|
func (s *FileService) Restore(ctx context.Context, id uuid.UUID) (*domain.File, error) {
|
||||||
|
userID, isAdmin, _ := domain.UserFromContext(ctx)
|
||||||
|
|
||||||
|
f, err := s.files.GetByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
ok, err := s.acl.CanEdit(ctx, userID, isAdmin, f.CreatorID, fileObjectTypeID, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return nil, domain.ErrForbidden
|
||||||
|
}
|
||||||
|
|
||||||
|
restored, err := s.files.Restore(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
objType := fileObjectType
|
||||||
|
_ = s.audit.Log(ctx, "file_restore", &objType, &id, nil)
|
||||||
|
return restored, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PermanentDelete removes the file record and its stored bytes. Only allowed
|
||||||
|
// when the file is already in trash.
|
||||||
|
func (s *FileService) PermanentDelete(ctx context.Context, id uuid.UUID) error {
|
||||||
|
userID, isAdmin, _ := domain.UserFromContext(ctx)
|
||||||
|
|
||||||
|
f, err := s.files.GetByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !f.IsDeleted {
|
||||||
|
return domain.ErrConflict
|
||||||
|
}
|
||||||
|
|
||||||
|
ok, err := s.acl.CanEdit(ctx, userID, isAdmin, f.CreatorID, fileObjectTypeID, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return domain.ErrForbidden
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.files.DeletePermanent(ctx, id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_ = s.storage.Delete(ctx, id)
|
||||||
|
|
||||||
|
objType := fileObjectType
|
||||||
|
_ = s.audit.Log(ctx, "file_permanent_delete", &objType, &id, nil)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace swaps the stored bytes for a file with new content.
|
||||||
|
func (s *FileService) Replace(ctx context.Context, id uuid.UUID, p UploadParams) (*domain.File, error) {
|
||||||
|
userID, isAdmin, _ := domain.UserFromContext(ctx)
|
||||||
|
|
||||||
|
f, err := s.files.GetByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
ok, err := s.acl.CanEdit(ctx, userID, isAdmin, f.CreatorID, fileObjectTypeID, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return nil, domain.ErrForbidden
|
||||||
|
}
|
||||||
|
|
||||||
|
mime, err := s.mimes.GetByName(ctx, p.MIMEType)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if _, err := io.Copy(&buf, p.Reader); err != nil {
|
||||||
|
return nil, fmt.Errorf("FileService.Replace: read body: %w", err)
|
||||||
|
}
|
||||||
|
data := buf.Bytes()
|
||||||
|
exifData, _ := extractEXIFWithDatetime(data)
|
||||||
|
|
||||||
|
if _, err := s.storage.Save(ctx, id, bytes.NewReader(data)); err != nil {
|
||||||
|
return nil, fmt.Errorf("FileService.Replace: save to storage: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
patch := &domain.File{
|
||||||
|
MIMEType: mime.Name,
|
||||||
|
MIMEExtension: mime.Extension,
|
||||||
|
EXIF: exifData,
|
||||||
|
}
|
||||||
|
if p.OriginalName != nil {
|
||||||
|
patch.OriginalName = p.OriginalName
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, err := s.files.Update(ctx, id, patch)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
objType := fileObjectType
|
||||||
|
_ = s.audit.Log(ctx, "file_replace", &objType, &id, nil)
|
||||||
|
return updated, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// List delegates to FileRepo with the given params.
|
||||||
|
func (s *FileService) List(ctx context.Context, params domain.FileListParams) (*domain.FilePage, error) {
|
||||||
|
return s.files.List(ctx, params)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Content / thumbnail / preview streaming
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// GetContent opens the raw file for download, enforcing view ACL.
|
||||||
|
func (s *FileService) GetContent(ctx context.Context, id uuid.UUID) (*ContentResult, error) {
|
||||||
|
f, err := s.Get(ctx, id) // ACL checked inside Get
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rc, err := s.storage.Read(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &ContentResult{
|
||||||
|
Body: rc,
|
||||||
|
MIMEType: f.MIMEType,
|
||||||
|
OriginalName: f.OriginalName,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetThumbnail returns the thumbnail JPEG, enforcing view ACL.
|
||||||
|
func (s *FileService) GetThumbnail(ctx context.Context, id uuid.UUID) (io.ReadCloser, error) {
|
||||||
|
if _, err := s.Get(ctx, id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.storage.Thumbnail(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPreview returns the preview JPEG, enforcing view ACL.
|
||||||
|
func (s *FileService) GetPreview(ctx context.Context, id uuid.UUID) (io.ReadCloser, error) {
|
||||||
|
if _, err := s.Get(ctx, id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.storage.Preview(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tag operations
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// ListFileTags returns the tags on a file, enforcing view ACL.
|
||||||
|
func (s *FileService) ListFileTags(ctx context.Context, fileID uuid.UUID) ([]domain.Tag, error) {
|
||||||
|
if _, err := s.Get(ctx, fileID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.files.ListTags(ctx, fileID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetFileTags replaces all tags on a file (full replace semantics), enforcing edit ACL.
|
||||||
|
func (s *FileService) SetFileTags(ctx context.Context, fileID uuid.UUID, tagIDs []uuid.UUID) ([]domain.Tag, error) {
|
||||||
|
userID, isAdmin, _ := domain.UserFromContext(ctx)
|
||||||
|
|
||||||
|
f, err := s.files.GetByID(ctx, fileID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ok, err := s.acl.CanEdit(ctx, userID, isAdmin, f.CreatorID, fileObjectTypeID, fileID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return nil, domain.ErrForbidden
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.files.SetTags(ctx, fileID, tagIDs); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
objType := fileObjectType
|
||||||
|
_ = s.audit.Log(ctx, "file_tag_add", &objType, &fileID, nil)
|
||||||
|
return s.files.ListTags(ctx, fileID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddTag adds a single tag to a file, enforcing edit ACL.
|
||||||
|
func (s *FileService) AddTag(ctx context.Context, fileID, tagID uuid.UUID) ([]domain.Tag, error) {
|
||||||
|
userID, isAdmin, _ := domain.UserFromContext(ctx)
|
||||||
|
|
||||||
|
f, err := s.files.GetByID(ctx, fileID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ok, err := s.acl.CanEdit(ctx, userID, isAdmin, f.CreatorID, fileObjectTypeID, fileID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return nil, domain.ErrForbidden
|
||||||
|
}
|
||||||
|
|
||||||
|
current, err := s.files.ListTags(ctx, fileID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Only add if not already present.
|
||||||
|
for _, t := range current {
|
||||||
|
if t.ID == tagID {
|
||||||
|
return current, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ids := make([]uuid.UUID, 0, len(current)+1)
|
||||||
|
for _, t := range current {
|
||||||
|
ids = append(ids, t.ID)
|
||||||
|
}
|
||||||
|
ids = append(ids, tagID)
|
||||||
|
|
||||||
|
if err := s.files.SetTags(ctx, fileID, ids); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
objType := fileObjectType
|
||||||
|
_ = s.audit.Log(ctx, "file_tag_add", &objType, &fileID, map[string]any{"tag_id": tagID})
|
||||||
|
return s.files.ListTags(ctx, fileID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveTag removes a single tag from a file, enforcing edit ACL.
|
||||||
|
func (s *FileService) RemoveTag(ctx context.Context, fileID, tagID uuid.UUID) error {
|
||||||
|
userID, isAdmin, _ := domain.UserFromContext(ctx)
|
||||||
|
|
||||||
|
f, err := s.files.GetByID(ctx, fileID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ok, err := s.acl.CanEdit(ctx, userID, isAdmin, f.CreatorID, fileObjectTypeID, fileID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return domain.ErrForbidden
|
||||||
|
}
|
||||||
|
|
||||||
|
current, err := s.files.ListTags(ctx, fileID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ids := make([]uuid.UUID, 0, len(current))
|
||||||
|
for _, t := range current {
|
||||||
|
if t.ID != tagID {
|
||||||
|
ids = append(ids, t.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.files.SetTags(ctx, fileID, ids); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
objType := fileObjectType
|
||||||
|
_ = s.audit.Log(ctx, "file_tag_remove", &objType, &fileID, map[string]any{"tag_id": tagID})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Bulk operations
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// BulkDelete soft-deletes multiple files. Files the caller cannot edit are silently skipped.
|
||||||
|
func (s *FileService) BulkDelete(ctx context.Context, fileIDs []uuid.UUID) error {
|
||||||
|
for _, id := range fileIDs {
|
||||||
|
if err := s.Delete(ctx, id); err != nil {
|
||||||
|
// Skip files not found or forbidden; surface real errors.
|
||||||
|
if err == domain.ErrNotFound || err == domain.ErrForbidden {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BulkSetTags adds or removes the given tags on multiple files.
|
||||||
|
// For "add": tags are appended to each file's existing set.
|
||||||
|
// For "remove": tags are removed from each file's existing set.
|
||||||
|
// Returns the tag IDs that were applied (the input tagIDs, for add).
|
||||||
|
func (s *FileService) BulkSetTags(ctx context.Context, fileIDs []uuid.UUID, action string, tagIDs []uuid.UUID) ([]uuid.UUID, error) {
|
||||||
|
for _, fileID := range fileIDs {
|
||||||
|
switch action {
|
||||||
|
case "add":
|
||||||
|
for _, tagID := range tagIDs {
|
||||||
|
if _, err := s.AddTag(ctx, fileID, tagID); err != nil {
|
||||||
|
if err == domain.ErrNotFound || err == domain.ErrForbidden {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "remove":
|
||||||
|
for _, tagID := range tagIDs {
|
||||||
|
if err := s.RemoveTag(ctx, fileID, tagID); err != nil {
|
||||||
|
if err == domain.ErrNotFound || err == domain.ErrForbidden {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return nil, domain.ErrValidation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if action == "add" {
|
||||||
|
return tagIDs, nil
|
||||||
|
}
|
||||||
|
return []uuid.UUID{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommonTags loads the tag sets for all given files and splits them into:
|
||||||
|
// - common: tag IDs present on every file
|
||||||
|
// - partial: tag IDs present on some but not all files
|
||||||
|
func (s *FileService) CommonTags(ctx context.Context, fileIDs []uuid.UUID) (common, partial []uuid.UUID, err error) {
|
||||||
|
if len(fileIDs) == 0 {
|
||||||
|
return nil, nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count how many files each tag appears on.
|
||||||
|
counts := map[uuid.UUID]int{}
|
||||||
|
for _, fid := range fileIDs {
|
||||||
|
tags, err := s.files.ListTags(ctx, fid)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
for _, t := range tags {
|
||||||
|
counts[t.ID]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
n := len(fileIDs)
|
||||||
|
for id, cnt := range counts {
|
||||||
|
if cnt == n {
|
||||||
|
common = append(common, id)
|
||||||
|
} else {
|
||||||
|
partial = append(partial, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if common == nil {
|
||||||
|
common = []uuid.UUID{}
|
||||||
|
}
|
||||||
|
if partial == nil {
|
||||||
|
partial = []uuid.UUID{}
|
||||||
|
}
|
||||||
|
return common, partial, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Import
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Import scans a server-side directory and uploads all supported files.
|
||||||
|
// If path is empty, the configured default import path is used.
|
||||||
|
func (s *FileService) Import(ctx context.Context, path string) (*ImportResult, error) {
|
||||||
|
dir := path
|
||||||
|
if dir == "" {
|
||||||
|
dir = s.importPath
|
||||||
|
}
|
||||||
|
if dir == "" {
|
||||||
|
return nil, domain.ErrValidation
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("FileService.Import: read dir %q: %w", dir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &ImportResult{Errors: []ImportFileError{}}
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() {
|
||||||
|
result.Skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
fullPath := filepath.Join(dir, entry.Name())
|
||||||
|
|
||||||
|
mt, err := mimetype.DetectFile(fullPath)
|
||||||
|
if err != nil {
|
||||||
|
result.Errors = append(result.Errors, ImportFileError{
|
||||||
|
Filename: entry.Name(),
|
||||||
|
Reason: fmt.Sprintf("MIME detection failed: %s", err),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
mimeStr := mt.String()
|
||||||
|
// Strip parameters (e.g. "text/plain; charset=utf-8" → "text/plain").
|
||||||
|
if idx := len(mimeStr); idx > 0 {
|
||||||
|
for i, c := range mimeStr {
|
||||||
|
if c == ';' {
|
||||||
|
mimeStr = mimeStr[:i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := s.mimes.GetByName(ctx, mimeStr); err != nil {
|
||||||
|
result.Skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := os.Open(fullPath)
|
||||||
|
if err != nil {
|
||||||
|
result.Errors = append(result.Errors, ImportFileError{
|
||||||
|
Filename: entry.Name(),
|
||||||
|
Reason: fmt.Sprintf("open failed: %s", err),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
name := entry.Name()
|
||||||
|
_, uploadErr := s.Upload(ctx, UploadParams{
|
||||||
|
Reader: f,
|
||||||
|
MIMEType: mimeStr,
|
||||||
|
OriginalName: &name,
|
||||||
|
})
|
||||||
|
f.Close()
|
||||||
|
|
||||||
|
if uploadErr != nil {
|
||||||
|
result.Errors = append(result.Errors, ImportFileError{
|
||||||
|
Filename: entry.Name(),
|
||||||
|
Reason: uploadErr.Error(),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result.Imported++
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Internal helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// extractEXIFWithDatetime parses EXIF from raw bytes, returning both the JSON
|
||||||
|
// representation and the DateTimeOriginal (if present). Both may be nil.
|
||||||
|
func extractEXIFWithDatetime(data []byte) (json.RawMessage, *time.Time) {
|
||||||
|
x, err := exif.Decode(bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
b, err := x.MarshalJSON()
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var dt *time.Time
|
||||||
|
if t, err := x.DateTime(); err == nil {
|
||||||
|
dt = &t
|
||||||
|
}
|
||||||
|
return json.RawMessage(b), dt
|
||||||
|
}
|
||||||
257
backend/internal/storage/disk.go
Normal file
257
backend/internal/storage/disk.go
Normal file
@ -0,0 +1,257 @@
|
|||||||
|
// Package storage provides a local-filesystem implementation of port.FileStorage.
|
||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/jpeg"
|
||||||
|
_ "image/gif" // register GIF decoder
|
||||||
|
_ "image/png" // register PNG decoder
|
||||||
|
_ "golang.org/x/image/webp" // register WebP decoder
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/disintegration/imaging"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"tanabata/backend/internal/domain"
|
||||||
|
"tanabata/backend/internal/port"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DiskStorage implements port.FileStorage using the local filesystem.
|
||||||
|
//
|
||||||
|
// Directory layout:
|
||||||
|
//
|
||||||
|
// {filesPath}/{id} — original file (UUID basename, no extension)
|
||||||
|
// {thumbsPath}/{id}_thumb.jpg — thumbnail cache
|
||||||
|
// {thumbsPath}/{id}_preview.jpg — preview cache
|
||||||
|
type DiskStorage struct {
|
||||||
|
filesPath string
|
||||||
|
thumbsPath string
|
||||||
|
thumbWidth int
|
||||||
|
thumbHeight int
|
||||||
|
previewWidth int
|
||||||
|
previewHeight int
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ port.FileStorage = (*DiskStorage)(nil)
|
||||||
|
|
||||||
|
// NewDiskStorage creates a DiskStorage and ensures both directories exist.
|
||||||
|
func NewDiskStorage(
|
||||||
|
filesPath, thumbsPath string,
|
||||||
|
thumbW, thumbH, prevW, prevH int,
|
||||||
|
) (*DiskStorage, error) {
|
||||||
|
for _, p := range []string{filesPath, thumbsPath} {
|
||||||
|
if err := os.MkdirAll(p, 0o755); err != nil {
|
||||||
|
return nil, fmt.Errorf("storage: create directory %q: %w", p, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &DiskStorage{
|
||||||
|
filesPath: filesPath,
|
||||||
|
thumbsPath: thumbsPath,
|
||||||
|
thumbWidth: thumbW,
|
||||||
|
thumbHeight: thumbH,
|
||||||
|
previewWidth: prevW,
|
||||||
|
previewHeight: prevH,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// port.FileStorage implementation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Save writes r to {filesPath}/{id} and returns the number of bytes written.
|
||||||
|
func (s *DiskStorage) Save(_ context.Context, id uuid.UUID, r io.Reader) (int64, error) {
|
||||||
|
dst := s.originalPath(id)
|
||||||
|
f, err := os.Create(dst)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("storage.Save create %q: %w", dst, err)
|
||||||
|
}
|
||||||
|
n, copyErr := io.Copy(f, r)
|
||||||
|
closeErr := f.Close()
|
||||||
|
if copyErr != nil {
|
||||||
|
os.Remove(dst)
|
||||||
|
return 0, fmt.Errorf("storage.Save write: %w", copyErr)
|
||||||
|
}
|
||||||
|
if closeErr != nil {
|
||||||
|
os.Remove(dst)
|
||||||
|
return 0, fmt.Errorf("storage.Save close: %w", closeErr)
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read opens the original file for reading. The caller must close the result.
|
||||||
|
func (s *DiskStorage) Read(_ context.Context, id uuid.UUID) (io.ReadCloser, error) {
|
||||||
|
f, err := os.Open(s.originalPath(id))
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, domain.ErrNotFound
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("storage.Read: %w", err)
|
||||||
|
}
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes the original file. Returns ErrNotFound if it does not exist.
|
||||||
|
func (s *DiskStorage) Delete(_ context.Context, id uuid.UUID) error {
|
||||||
|
if err := os.Remove(s.originalPath(id)); err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return domain.ErrNotFound
|
||||||
|
}
|
||||||
|
return fmt.Errorf("storage.Delete: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thumbnail returns a JPEG that fits within the configured max width×height
|
||||||
|
// (never upscaled, never cropped). Generated on first call and cached.
|
||||||
|
// Video files are thumbnailed via ffmpeg; other non-image files get a placeholder.
|
||||||
|
func (s *DiskStorage) Thumbnail(ctx context.Context, id uuid.UUID) (io.ReadCloser, error) {
|
||||||
|
return s.serveGenerated(ctx, id, s.thumbCachePath(id), s.thumbWidth, s.thumbHeight)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preview returns a JPEG that fits within the configured max width×height
|
||||||
|
// (never upscaled, never cropped). Generated on first call and cached.
|
||||||
|
// Video files are thumbnailed via ffmpeg; other non-image files get a placeholder.
|
||||||
|
func (s *DiskStorage) Preview(ctx context.Context, id uuid.UUID) (io.ReadCloser, error) {
|
||||||
|
return s.serveGenerated(ctx, id, s.previewCachePath(id), s.previewWidth, s.previewHeight)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Internal helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// serveGenerated is the shared implementation for Thumbnail and Preview.
|
||||||
|
// imaging.Thumbnail fits the source within maxW×maxH without upscaling or cropping.
|
||||||
|
//
|
||||||
|
// Resolution order:
|
||||||
|
// 1. Return cached JPEG if present.
|
||||||
|
// 2. Decode as still image (JPEG/PNG/GIF via imaging).
|
||||||
|
// 3. Extract a frame with ffmpeg (video files).
|
||||||
|
// 4. Solid-colour placeholder (archives, unrecognised formats, etc.).
|
||||||
|
func (s *DiskStorage) serveGenerated(ctx context.Context, id uuid.UUID, cachePath string, maxW, maxH int) (io.ReadCloser, error) {
|
||||||
|
// Fast path: cache hit.
|
||||||
|
if f, err := os.Open(cachePath); err == nil {
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the original file exists before doing any work.
|
||||||
|
srcPath := s.originalPath(id)
|
||||||
|
if _, err := os.Stat(srcPath); err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, domain.ErrNotFound
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("storage: stat %q: %w", srcPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Try still-image decode (JPEG/PNG/GIF).
|
||||||
|
// 2. Try video frame extraction via ffmpeg.
|
||||||
|
// 3. Fall back to placeholder.
|
||||||
|
var img image.Image
|
||||||
|
if decoded, err := imaging.Open(srcPath, imaging.AutoOrientation(true)); err == nil {
|
||||||
|
img = imaging.Thumbnail(decoded, maxW, maxH, imaging.Lanczos)
|
||||||
|
} else if frame, err := extractVideoFrame(ctx, srcPath); err == nil {
|
||||||
|
img = imaging.Thumbnail(frame, maxW, maxH, imaging.Lanczos)
|
||||||
|
} else {
|
||||||
|
img = placeholder(maxW, maxH)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write to cache atomically (temp→rename) and return an open reader.
|
||||||
|
if rc, err := writeCache(cachePath, img); err == nil {
|
||||||
|
return rc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache write failed (read-only fs, disk full, …). Serve from an
|
||||||
|
// in-memory buffer so the request still succeeds.
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 85}); err != nil {
|
||||||
|
return nil, fmt.Errorf("storage: encode in-memory JPEG: %w", err)
|
||||||
|
}
|
||||||
|
return io.NopCloser(&buf), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeCache encodes img as JPEG to cachePath via an atomic temp→rename write,
|
||||||
|
// then opens and returns the cache file.
|
||||||
|
func writeCache(cachePath string, img image.Image) (io.ReadCloser, error) {
|
||||||
|
dir := filepath.Dir(cachePath)
|
||||||
|
tmp, err := os.CreateTemp(dir, ".cache-*.tmp")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("storage: create temp file: %w", err)
|
||||||
|
}
|
||||||
|
tmpName := tmp.Name()
|
||||||
|
|
||||||
|
encErr := jpeg.Encode(tmp, img, &jpeg.Options{Quality: 85})
|
||||||
|
closeErr := tmp.Close()
|
||||||
|
if encErr != nil {
|
||||||
|
os.Remove(tmpName)
|
||||||
|
return nil, fmt.Errorf("storage: encode cache JPEG: %w", encErr)
|
||||||
|
}
|
||||||
|
if closeErr != nil {
|
||||||
|
os.Remove(tmpName)
|
||||||
|
return nil, fmt.Errorf("storage: close temp file: %w", closeErr)
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmpName, cachePath); err != nil {
|
||||||
|
os.Remove(tmpName)
|
||||||
|
return nil, fmt.Errorf("storage: rename cache file: %w", err)
|
||||||
|
}
|
||||||
|
f, err := os.Open(cachePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("storage: open cache file: %w", err)
|
||||||
|
}
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractVideoFrame uses ffmpeg to extract a single frame from a video file.
|
||||||
|
// It seeks 1 second in (keyframe-accurate fast seek) and pipes the frame out
|
||||||
|
// as PNG. If the video is shorter than 1 s the seek is silently ignored by
|
||||||
|
// ffmpeg and the first available frame is returned instead.
|
||||||
|
// Returns an error if ffmpeg is not installed or produces no output.
|
||||||
|
func extractVideoFrame(ctx context.Context, srcPath string) (image.Image, error) {
|
||||||
|
var out bytes.Buffer
|
||||||
|
cmd := exec.CommandContext(ctx, "ffmpeg",
|
||||||
|
"-ss", "1", // fast input seek; ignored gracefully on short files
|
||||||
|
"-i", srcPath,
|
||||||
|
"-vframes", "1",
|
||||||
|
"-f", "image2",
|
||||||
|
"-vcodec", "png",
|
||||||
|
"pipe:1",
|
||||||
|
)
|
||||||
|
cmd.Stdout = &out
|
||||||
|
cmd.Stderr = io.Discard // suppress ffmpeg progress output
|
||||||
|
|
||||||
|
if err := cmd.Run(); err != nil || out.Len() == 0 {
|
||||||
|
return nil, fmt.Errorf("ffmpeg frame extract: %w", err)
|
||||||
|
}
|
||||||
|
return imaging.Decode(&out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Path helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func (s *DiskStorage) originalPath(id uuid.UUID) string {
|
||||||
|
return filepath.Join(s.filesPath, id.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DiskStorage) thumbCachePath(id uuid.UUID) string {
|
||||||
|
return filepath.Join(s.thumbsPath, id.String()+"_thumb.jpg")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DiskStorage) previewCachePath(id uuid.UUID) string {
|
||||||
|
return filepath.Join(s.thumbsPath, id.String()+"_preview.jpg")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Image helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// placeholder returns a solid-colour image of size w×h for files that cannot
|
||||||
|
// be decoded as images. Uses #444455 from the design palette.
|
||||||
|
func placeholder(w, h int) *image.NRGBA {
|
||||||
|
return imaging.New(w, h, color.NRGBA{R: 0x44, G: 0x44, B: 0x55, A: 0xFF})
|
||||||
|
}
|
||||||
@ -1,5 +1,17 @@
|
|||||||
-- +goose Up
|
-- +goose Up
|
||||||
|
|
||||||
|
INSERT INTO core.mime_types (name, extension) VALUES
|
||||||
|
('image/jpeg', 'jpg'),
|
||||||
|
('image/png', 'png'),
|
||||||
|
('image/gif', 'gif'),
|
||||||
|
('image/webp', 'webp'),
|
||||||
|
('video/mp4', 'mp4'),
|
||||||
|
('video/quicktime', 'mov'),
|
||||||
|
('video/x-msvideo', 'avi'),
|
||||||
|
('video/webm', 'webm'),
|
||||||
|
('video/3gpp', '3gp'),
|
||||||
|
('video/x-m4v', 'm4v');
|
||||||
|
|
||||||
INSERT INTO core.object_types (name) VALUES
|
INSERT INTO core.object_types (name) VALUES
|
||||||
('file'), ('tag'), ('category'), ('pool');
|
('file'), ('tag'), ('category'), ('pool');
|
||||||
|
|
||||||
@ -34,3 +46,4 @@ INSERT INTO core.users (name, password, is_admin, can_create) VALUES
|
|||||||
DELETE FROM core.users WHERE name = 'admin';
|
DELETE FROM core.users WHERE name = 'admin';
|
||||||
DELETE FROM activity.action_types;
|
DELETE FROM activity.action_types;
|
||||||
DELETE FROM core.object_types;
|
DELETE FROM core.object_types;
|
||||||
|
DELETE FROM core.mime_types;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user