feat(12.2-03): defer relation work on unsaved records and add child file routes

- record id 0 with X-Session-Key manages deferrable relations: create, link, unlink, delete and pivot edits are held in deferred_bindings
- the record's create save applies relation bindings with the file bindings; an ineligible link is a 422 on the relation-manager field
- child forms upload files through .../records/{child}/files/{field} keyed by X-Child-Session-Key; the child save commits them
- boot refuses a deferrable relation with create whose related model no plugin lists in Models()
This commit is contained in:
Jakub Zych
2026-10-02 19:08:16 +02:00
parent afb05b6ee4
commit fe9e8baaf1
16 changed files with 3643 additions and 436 deletions

View File

@@ -582,128 +582,128 @@ func fileItem(ctx context.Context, bucket *blob.Bucket, cf *compiledFile, f *att
// fileList serves GET .../{id}/files/{field} (dispatched by nestedGet).
func (s *service) fileList(w http.ResponseWriter, r *http.Request) {
s.protect(w, r, func(cc *CompiledController) {
db, err := s.db()
s.protect(w, r, func(cc *CompiledController) { s.fileListOn(w, r, cc, parentFiles(cc)) })
}
func (s *service) fileListOn(w http.ResponseWriter, r *http.Request, cc *CompiledController, fr fileRoute) {
db, err := s.db()
if err != nil {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
bucket := s.bucket()
var items []FileItem
err = lagoon.Transaction(r.Context(), db, func(ctx context.Context, tx *gorm.DB) error {
ctx = withTx(ctx, tx)
sc, err := fr.scope(ctx, tx, r)
if err != nil {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
return err
}
bucket := s.bucket()
var items []FileItem
err = lagoon.Transaction(r.Context(), db, func(ctx context.Context, tx *gorm.DB) error {
ctx = withTx(ctx, tx)
sc, err := parentFileScope(ctx, tx, r, cc)
if err != nil {
return err
}
files, pending, err := sc.visibleFiles(tx)
if err != nil {
return lifecycleFailure(cc, err)
}
items = make([]FileItem, 0, len(files))
for i := range files {
items = append(items, fileItem(ctx, bucket, sc.file, &files[i], pending[files[i].ID]))
}
return nil
})
files, pending, err := sc.visibleFiles(tx)
if err != nil {
writeCRUDError(w, err)
return
return lifecycleFailure(cc, err)
}
WriteData(w, http.StatusOK, items, nil)
items = make([]FileItem, 0, len(files))
for i := range files {
items = append(items, fileItem(ctx, bucket, sc.file, &files[i], pending[files[i].ID]))
}
return nil
})
if err != nil {
writeCRUDError(w, err)
return
}
WriteData(w, http.StatusOK, items, nil)
}
// fileUpload serves POST .../{id}/files/{field}: it stores one multipart
// file_data part with attach.Store and binds it to the session key (D-03).
// The record's next save attaches it.
func (s *service) fileUpload(w http.ResponseWriter, r *http.Request) {
s.protect(w, r, func(cc *CompiledController) {
cf := cc.files[r.PathValue("field")]
if cf == nil {
writeNotFound(w, r)
return
}
if _, ok, err := sessionKeyFrom(r); err != nil || !ok {
if err == nil {
err = &ValidationError{Details: map[string]any{"session_key": []string{"The session key field is required."}}}
}
writeCRUDError(w, err)
return
}
db, err := s.db()
s.protect(w, r, func(cc *CompiledController) { s.fileUploadOn(w, r, cc, parentFiles(cc)) })
}
func (s *service) fileUploadOn(w http.ResponseWriter, r *http.Request, cc *CompiledController, fr fileRoute) {
cf := fr.field(r)
if cf == nil {
writeNotFound(w, r)
return
}
if !requireKey(w, r, fr) {
return
}
db, err := s.db()
if err != nil {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
bucket := s.bucket()
if bucket == nil {
slog.Default().ErrorContext(r.Context(), "cabana: file upload without a storage bucket", "controller", controllerID(cc))
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
body := &bodyReader{r: http.MaxBytesReader(w, r.Body, s.uploadCap(cf))}
r.Body = io.NopCloser(body)
mr, err := r.MultipartReader()
if err != nil {
writeCRUDError(w, invalidBody())
return
}
part, err := mr.NextPart()
if err != nil {
s.writeFileError(w, r, cf, body, err)
return
}
if part.FormName() != "file_data" || strings.TrimSpace(part.FileName()) == "" {
writeCRUDError(w, invalidBody())
return
}
var stored *attach.File
var item FileItem
err = lagoon.Transaction(r.Context(), db, func(ctx context.Context, tx *gorm.DB) error {
ctx = withTx(ctx, tx)
sc, err := fr.scope(ctx, tx, r)
if err != nil {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
return err
}
bucket := s.bucket()
if bucket == nil {
slog.Default().ErrorContext(r.Context(), "cabana: file upload without a storage bucket", "controller", controllerID(cc))
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
body := &bodyReader{r: http.MaxBytesReader(w, r.Body, s.uploadCap(cf))}
r.Body = io.NopCloser(body)
mr, err := r.MultipartReader()
if err != nil {
writeCRUDError(w, invalidBody())
return
}
part, err := mr.NextPart()
if err != nil {
s.writeFileError(w, r, cf, body, err)
return
}
if part.FormName() != "file_data" || strings.TrimSpace(part.FileName()) == "" {
writeCRUDError(w, invalidBody())
return
}
var stored *attach.File
var item FileItem
err = lagoon.Transaction(r.Context(), db, func(ctx context.Context, tx *gorm.DB) error {
ctx = withTx(ctx, tx)
sc, err := parentFileScope(ctx, tx, r, cc)
if cf.relation.Many && cf.maxFiles > 0 {
files, _, err := sc.visibleFiles(tx)
if err != nil {
return err
}
if cf.relation.Many && cf.maxFiles > 0 {
files, _, err := sc.visibleFiles(tx)
if err != nil {
return lifecycleFailure(cc, err)
}
if len(files) >= cf.maxFiles {
return &ValidationError{Details: fieldDetail(cf.name, fieldMessage(ctx, s.translator(), "max.array", cf.name, map[string]string{"max": strconv.Itoa(cf.maxFiles)}))}
}
}
f, err := attach.Store(ctx, tx, bucket, attach.Upload{FileName: part.FileName(), Body: part, Public: cf.relation.Public}, cf.limits)
if err != nil {
return err
}
stored = f
// Exactly one part: anything after file_data is refused.
if _, err := mr.NextPart(); !errors.Is(err, io.EOF) {
if err == nil {
return invalidBody()
}
return err
}
if err := lagoon.DeferredBind(ctx, tx, sc.key, cf.name, lagoon.DeferredFileType, uitoa(f.ID), nil); err != nil {
return lifecycleFailure(cc, err)
}
item = fileItem(ctx, bucket, cf, f, true)
return nil
})
if err != nil {
// attach.Store wrote the blob before the row; a rolled-back
// transaction leaves it to this caller (12.2-01).
if stored != nil {
_ = attach.DeleteKeys(context.WithoutCancel(r.Context()), bucket, attach.BlobKeys(*stored))
if len(files) >= cf.maxFiles {
return &ValidationError{Details: fieldDetail(cf.name, fieldMessage(ctx, s.translator(), "max.array", cf.name, map[string]string{"max": strconv.Itoa(cf.maxFiles)}))}
}
s.writeFileError(w, r, cf, body, err)
return
}
WriteData(w, http.StatusCreated, item, nil)
f, err := attach.Store(ctx, tx, bucket, attach.Upload{FileName: part.FileName(), Body: part, Public: cf.relation.Public}, cf.limits)
if err != nil {
return err
}
stored = f
// Exactly one part: anything after file_data is refused.
if _, err := mr.NextPart(); !errors.Is(err, io.EOF) {
if err == nil {
return invalidBody()
}
return err
}
if err := lagoon.DeferredBind(ctx, tx, sc.key, cf.name, lagoon.DeferredFileType, uitoa(f.ID), nil); err != nil {
return lifecycleFailure(cc, err)
}
item = fileItem(ctx, bucket, cf, f, true)
return nil
})
if err != nil {
// attach.Store wrote the blob before the row; a rolled-back
// transaction leaves it to this caller (12.2-01).
if stored != nil {
_ = attach.DeleteKeys(context.WithoutCancel(r.Context()), bucket, attach.BlobKeys(*stored))
}
s.writeFileError(w, r, cf, body, err)
return
}
WriteData(w, http.StatusCreated, item, nil)
}
// uploadCap is the request body cap of an upload: the smaller of
@@ -916,7 +916,7 @@ func (sc *fileScope) findFile(ctx context.Context, tx *gorm.DB, id uint, lock bo
// withFileScope runs fn on the resolved scope of a file route inside one
// transaction and writes the error envelope on failure.
func (s *service) withFileScope(w http.ResponseWriter, r *http.Request, cc *CompiledController, fn func(ctx context.Context, tx *gorm.DB, sc *fileScope) error) bool {
func (s *service) withFileScope(w http.ResponseWriter, r *http.Request, cc *CompiledController, fr fileRoute, fn func(ctx context.Context, tx *gorm.DB, sc *fileScope) error) bool {
db, err := s.db()
if err != nil {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
@@ -924,25 +924,26 @@ func (s *service) withFileScope(w http.ResponseWriter, r *http.Request, cc *Comp
}
err = lagoon.Transaction(r.Context(), db, func(ctx context.Context, tx *gorm.DB) error {
ctx = withTx(ctx, tx)
sc, err := parentFileScope(ctx, tx, r, cc)
sc, err := fr.scope(ctx, tx, r)
if err != nil {
return err
}
return fn(ctx, tx, sc)
})
if err != nil {
s.writeFileError(w, r, cc.files[r.PathValue("field")], nil, err)
s.writeFileError(w, r, fr.field(r), nil, err)
return false
}
return true
}
// requireSessionKey answers 422 on session_key when the request has no
// valid X-Session-Key.
func requireSessionKey(w http.ResponseWriter, r *http.Request) bool {
_, ok, err := sessionKeyFrom(r)
// requireKey answers 422 when the request has no valid file session key:
// X-Session-Key on a record's file routes, X-Child-Session-Key on a
// relation child's.
func requireKey(w http.ResponseWriter, r *http.Request, fr fileRoute) bool {
_, ok, err := fr.keyFrom(r)
if err == nil && !ok {
err = &ValidationError{Details: map[string]any{"session_key": []string{"The session key field is required."}}}
err = &ValidationError{Details: map[string]any{fr.keyName: []string{"The " + strings.ReplaceAll(fr.keyName, "_", " ") + " field is required."}}}
}
if err != nil {
writeCRUDError(w, err)
@@ -951,6 +952,132 @@ func requireSessionKey(w http.ResponseWriter, r *http.Request) bool {
return true
}
// fileRoute is the target of a file route: a record's own fileupload fields
// (the record file routes) or the manage form fields of a relation child
// (the child file routes, D-17). The handlers are shared; the route decides
// which fields exist, which header carries the file session key and how the
// owner is scoped.
type fileRoute struct {
files map[string]*compiledFile
keyFrom func(*http.Request) (string, bool, error)
keyName string
scope func(ctx context.Context, tx *gorm.DB, r *http.Request) (*fileScope, error)
}
// field is the route's fileupload field, or nil.
func (fr fileRoute) field(r *http.Request) *compiledFile { return fr.files[r.PathValue("field")] }
// parentFiles is the file route of the record's own fileupload fields.
func parentFiles(cc *CompiledController) fileRoute {
return fileRoute{
files: cc.files,
keyFrom: sessionKeyFrom,
keyName: "session_key",
scope: func(ctx context.Context, tx *gorm.DB, r *http.Request) (*fileScope, error) {
return parentFileScope(ctx, tx, r, cc)
},
}
}
// childFiles is the file route of a relation child form's fileupload
// fields (D-17), after the capability check: child 0 (a child not created
// yet) needs the create button, else 404 as for a record's id 0; a saved
// child needs the update button to write and update or a view form to
// read, else 403. An unknown relation or child form is 404.
func (s *service) childFiles(w http.ResponseWriter, r *http.Request, cc *CompiledController, write bool) (fileRoute, bool) {
cr, err := relationOf(cc, r.PathValue("name"))
if err == nil && cr.child == nil {
err = recordNotFound{}
}
if err != nil {
writeCRUDError(w, err)
return fileRoute{}, false
}
n, err := strconv.ParseUint(strings.TrimSpace(r.PathValue("child")), 10, 64)
if err != nil {
writeNotFound(w, r)
return fileRoute{}, false
}
childID := uint(n)
switch {
case childID == 0 && !cr.allows("create"):
writeNotFound(w, r)
return fileRoute{}, false
case childID > 0 && (write && !cr.allows("update") || !write && cr.childForm() == nil):
if principal, _ := bouncer.User(r.Context()); principal != nil {
s.logAuth(r, "denied", principal.ID)
}
WriteError(w, http.StatusForbidden, "forbidden", msgForbidden)
return fileRoute{}, false
}
return fileRoute{
files: cr.child.files,
keyFrom: childSessionKeyFrom,
keyName: "child_session_key",
scope: func(ctx context.Context, tx *gorm.DB, r *http.Request) (*fileScope, error) {
return childFileScope(ctx, tx, r, cc, cr, childID)
},
}, true
}
// relationChildFileList and the six handlers below serve the file routes
// of a relation child form under .../relations/{name}/records/{child}.
func (s *service) relationChildFileList(w http.ResponseWriter, r *http.Request) {
s.protect(w, r, func(cc *CompiledController) {
if fr, ok := s.childFiles(w, r, cc, false); ok {
s.fileListOn(w, r, cc, fr)
}
})
}
func (s *service) relationChildFileUpload(w http.ResponseWriter, r *http.Request) {
s.protect(w, r, func(cc *CompiledController) {
if fr, ok := s.childFiles(w, r, cc, true); ok {
s.fileUploadOn(w, r, cc, fr)
}
})
}
func (s *service) relationChildFileUpdate(w http.ResponseWriter, r *http.Request) {
s.protect(w, r, func(cc *CompiledController) {
if fr, ok := s.childFiles(w, r, cc, true); ok {
s.fileUpdateOn(w, r, cc, fr)
}
})
}
func (s *service) relationChildFileRemove(w http.ResponseWriter, r *http.Request) {
s.protect(w, r, func(cc *CompiledController) {
if fr, ok := s.childFiles(w, r, cc, true); ok {
s.fileRemoveOn(w, r, cc, fr)
}
})
}
func (s *service) relationChildFileReorder(w http.ResponseWriter, r *http.Request) {
s.protect(w, r, func(cc *CompiledController) {
if fr, ok := s.childFiles(w, r, cc, true); ok {
s.fileReorderOn(w, r, cc, fr)
}
})
}
func (s *service) relationChildFileDownload(w http.ResponseWriter, r *http.Request) {
s.protect(w, r, func(cc *CompiledController) {
if fr, ok := s.childFiles(w, r, cc, false); ok {
s.serveProtectedFileOn(w, r, cc, fr, false)
}
})
}
func (s *service) relationChildFileThumb(w http.ResponseWriter, r *http.Request) {
s.protect(w, r, func(cc *CompiledController) {
if fr, ok := s.childFiles(w, r, cc, false); ok {
s.serveProtectedFileOn(w, r, cc, fr, true)
}
})
}
// deleteBlobsAfterCommit removes a deleted file's blob and thumbnails once
// the surrounding transaction has committed.
func deleteBlobsAfterCommit(ctx context.Context, tx *gorm.DB, bucket *blob.Bucket, f attach.File) {
@@ -970,41 +1097,43 @@ func deleteBlobsAfterCommit(ctx context.Context, tx *gorm.DB, bucket *blob.Bucke
// removal of an attached file to the next save, or cancels a pending upload
// at once (its row now, its blob after commit).
func (s *service) fileRemove(w http.ResponseWriter, r *http.Request) {
s.protect(w, r, func(cc *CompiledController) {
if cc.files[r.PathValue("field")] == nil {
writeNotFound(w, r)
return
}
if !requireSessionKey(w, r) {
return
}
fileID, err := pathFileID(r)
s.protect(w, r, func(cc *CompiledController) { s.fileRemoveOn(w, r, cc, parentFiles(cc)) })
}
func (s *service) fileRemoveOn(w http.ResponseWriter, r *http.Request, cc *CompiledController, fr fileRoute) {
if fr.field(r) == nil {
writeNotFound(w, r)
return
}
if !requireKey(w, r, fr) {
return
}
fileID, err := pathFileID(r)
if err != nil {
writeCRUDError(w, err)
return
}
bucket := s.bucket()
ok := s.withFileScope(w, r, cc, fr, func(ctx context.Context, tx *gorm.DB, sc *fileScope) error {
f, err := sc.findFile(ctx, tx, fileID, true)
if err != nil {
writeCRUDError(w, err)
return
return err
}
bucket := s.bucket()
ok := s.withFileScope(w, r, cc, func(ctx context.Context, tx *gorm.DB, sc *fileScope) error {
f, err := sc.findFile(ctx, tx, fileID, true)
if err != nil {
cancelled, err := lagoon.DeferredUnbind(ctx, tx, sc.key, sc.file.name, lagoon.DeferredFileType, uitoa(f.ID))
if err != nil {
return err
}
if cancelled != nil && f.AttachmentID == "" {
if err := tx.Session(&gorm.Session{NewDB: true, Context: ctx}).Where("id = ?", f.ID).Delete(&attach.File{}).Error; err != nil {
return err
}
cancelled, err := lagoon.DeferredUnbind(ctx, tx, sc.key, sc.file.name, lagoon.DeferredFileType, uitoa(f.ID))
if err != nil {
return err
}
if cancelled != nil && f.AttachmentID == "" {
if err := tx.Session(&gorm.Session{NewDB: true, Context: ctx}).Where("id = ?", f.ID).Delete(&attach.File{}).Error; err != nil {
return err
}
deleteBlobsAfterCommit(ctx, tx, bucket, *f)
}
return nil
})
if ok {
WriteData(w, http.StatusOK, FileMutationResult{Removed: 1}, nil)
deleteBlobsAfterCommit(ctx, tx, bucket, *f)
}
return nil
})
if ok {
WriteData(w, http.StatusOK, FileMutationResult{Removed: 1}, nil)
}
}
// jsonCap is the body cap of the JSON file routes: http.body_limits.
@@ -1039,122 +1168,126 @@ func (s *service) decodeStrictBody(w http.ResponseWriter, r *http.Request, dest
// title and description at once (WinterCMS's onSaveAttachmentConfig). The
// field must declare useCaption.
func (s *service) fileUpdate(w http.ResponseWriter, r *http.Request) {
s.protect(w, r, func(cc *CompiledController) {
cf := cc.files[r.PathValue("field")]
if cf == nil {
writeNotFound(w, r)
return
}
if !cf.field.UseCaption {
WriteError(w, http.StatusForbidden, "forbidden", msgForbidden)
return
}
fileID, err := pathFileID(r)
s.protect(w, r, func(cc *CompiledController) { s.fileUpdateOn(w, r, cc, parentFiles(cc)) })
}
func (s *service) fileUpdateOn(w http.ResponseWriter, r *http.Request, cc *CompiledController, fr fileRoute) {
cf := fr.field(r)
if cf == nil {
writeNotFound(w, r)
return
}
if !cf.field.UseCaption {
WriteError(w, http.StatusForbidden, "forbidden", msgForbidden)
return
}
fileID, err := pathFileID(r)
if err != nil {
writeCRUDError(w, err)
return
}
var in AdminFileCaptionRequest
if err := s.decodeStrictBody(w, r, &in); err != nil {
s.writeFileError(w, r, cf, nil, err)
return
}
bucket := s.bucket()
var item FileItem
ok := s.withFileScope(w, r, cc, fr, func(ctx context.Context, tx *gorm.DB, sc *fileScope) error {
f, err := sc.findFile(ctx, tx, fileID, true)
if err != nil {
writeCRUDError(w, err)
return
return err
}
var in AdminFileCaptionRequest
if err := s.decodeStrictBody(w, r, &in); err != nil {
s.writeFileError(w, r, cf, nil, err)
return
updates := map[string]any{}
if in.Title != nil {
updates["title"] = *in.Title
f.Title = in.Title
}
bucket := s.bucket()
var item FileItem
ok := s.withFileScope(w, r, cc, func(ctx context.Context, tx *gorm.DB, sc *fileScope) error {
f, err := sc.findFile(ctx, tx, fileID, true)
if err != nil {
if in.Description != nil {
updates["description"] = *in.Description
f.Description = in.Description
}
if len(updates) > 0 {
if err := tx.Session(&gorm.Session{NewDB: true, Context: ctx}).Model(&attach.File{}).Where("id = ?", f.ID).Updates(updates).Error; err != nil {
return err
}
updates := map[string]any{}
if in.Title != nil {
updates["title"] = *in.Title
f.Title = in.Title
}
if in.Description != nil {
updates["description"] = *in.Description
f.Description = in.Description
}
if len(updates) > 0 {
if err := tx.Session(&gorm.Session{NewDB: true, Context: ctx}).Model(&attach.File{}).Where("id = ?", f.ID).Updates(updates).Error; err != nil {
return err
}
}
item = fileItem(ctx, bucket, cf, f, f.AttachmentID == "")
return nil
})
if ok {
WriteData(w, http.StatusOK, item, nil)
}
item = fileItem(ctx, bucket, cf, f, f.AttachmentID == "")
return nil
})
if ok {
WriteData(w, http.StatusOK, item, nil)
}
}
// fileReorder serves POST .../{id}/files/{field}/reorder: the submitted ids
// must be exactly the field's visible files, and they receive the visible
// files' existing sort_order values, ascending, in the submitted order.
func (s *service) fileReorder(w http.ResponseWriter, r *http.Request) {
s.protect(w, r, func(cc *CompiledController) {
cf := cc.files[r.PathValue("field")]
if cf == nil {
writeNotFound(w, r)
return
s.protect(w, r, func(cc *CompiledController) { s.fileReorderOn(w, r, cc, parentFiles(cc)) })
}
func (s *service) fileReorderOn(w http.ResponseWriter, r *http.Request, cc *CompiledController, fr fileRoute) {
cf := fr.field(r)
if cf == nil {
writeNotFound(w, r)
return
}
if !cf.relation.Many {
WriteError(w, http.StatusForbidden, "forbidden", msgForbidden)
return
}
var in AdminIDsRequest
if err := s.decodeStrictBody(w, r, &in); err != nil {
s.writeFileError(w, r, cf, nil, err)
return
}
bucket := s.bucket()
var items []FileItem
ok := s.withFileScope(w, r, cc, fr, func(ctx context.Context, tx *gorm.DB, sc *fileScope) error {
files, _, err := sc.visibleFiles(tx)
if err != nil {
return err
}
if !cf.relation.Many {
WriteError(w, http.StatusForbidden, "forbidden", msgForbidden)
return
byID := make(map[uint]int, len(files))
orders := make([]int, len(files))
for i, f := range files {
byID[f.ID] = i
orders[i] = f.SortOrder
}
var in AdminIDsRequest
if err := s.decodeStrictBody(w, r, &in); err != nil {
s.writeFileError(w, r, cf, nil, err)
return
seen := map[uint]bool{}
valid := len(in.IDs) == len(files)
for _, raw := range in.IDs {
id := uint(raw)
if _, known := byID[id]; !known || seen[id] || uint64(id) != raw {
valid = false
break
}
seen[id] = true
}
bucket := s.bucket()
var items []FileItem
ok := s.withFileScope(w, r, cc, func(ctx context.Context, tx *gorm.DB, sc *fileScope) error {
files, _, err := sc.visibleFiles(tx)
if err != nil {
if !valid {
return &ValidationError{Details: map[string]any{"ids": []string{"The ids field must list every file of the field exactly once."}}}
}
slices.Sort(orders)
q := tx.Session(&gorm.Session{NewDB: true, Context: ctx})
for i, raw := range in.IDs {
if err := q.Model(&attach.File{}).Where("id = ?", uint(raw)).Update("sort_order", orders[i]).Error; err != nil {
return err
}
byID := make(map[uint]int, len(files))
orders := make([]int, len(files))
for i, f := range files {
byID[f.ID] = i
orders[i] = f.SortOrder
}
seen := map[uint]bool{}
valid := len(in.IDs) == len(files)
for _, raw := range in.IDs {
id := uint(raw)
if _, known := byID[id]; !known || seen[id] || uint64(id) != raw {
valid = false
break
}
seen[id] = true
}
if !valid {
return &ValidationError{Details: map[string]any{"ids": []string{"The ids field must list every file of the field exactly once."}}}
}
slices.Sort(orders)
q := tx.Session(&gorm.Session{NewDB: true, Context: ctx})
for i, raw := range in.IDs {
if err := q.Model(&attach.File{}).Where("id = ?", uint(raw)).Update("sort_order", orders[i]).Error; err != nil {
return err
}
}
files, pending, err := sc.visibleFiles(tx)
if err != nil {
return err
}
items = make([]FileItem, 0, len(files))
for i := range files {
items = append(items, fileItem(ctx, bucket, cf, &files[i], pending[files[i].ID]))
}
return nil
})
if ok {
WriteData(w, http.StatusOK, items, nil)
}
files, pending, err := sc.visibleFiles(tx)
if err != nil {
return err
}
items = make([]FileItem, 0, len(files))
for i := range files {
items = append(items, fileItem(ctx, bucket, cf, &files[i], pending[files[i].ID]))
}
return nil
})
if ok {
WriteData(w, http.StatusOK, items, nil)
}
}
// fileDownload serves GET .../{id}/files/{field}/{file}/download.
@@ -1175,83 +1308,85 @@ func (s *service) fileThumb(w http.ResponseWriter, r *http.Request) {
// application/octet-stream attachment. Every response is nosniff, private
// and no-store, under a sandboxing CSP.
func (s *service) serveProtectedFile(w http.ResponseWriter, r *http.Request, thumb bool) {
s.protect(w, r, func(cc *CompiledController) {
cf := cc.files[r.PathValue("field")]
if cf == nil {
s.protect(w, r, func(cc *CompiledController) { s.serveProtectedFileOn(w, r, cc, parentFiles(cc), thumb) })
}
func (s *service) serveProtectedFileOn(w http.ResponseWriter, r *http.Request, cc *CompiledController, fr fileRoute, thumb bool) {
cf := fr.field(r)
if cf == nil {
writeNotFound(w, r)
return
}
fileID, err := pathFileID(r)
if err != nil {
writeCRUDError(w, err)
return
}
bucket := s.bucket()
if bucket == nil {
slog.Default().ErrorContext(r.Context(), "cabana: protected file route without a storage bucket", "controller", controllerID(cc))
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
var f *attach.File
ok := s.withFileScope(w, r, cc, fr, func(ctx context.Context, tx *gorm.DB, sc *fileScope) error {
found, err := sc.findFile(ctx, tx, fileID, false)
if err != nil {
return err
}
if found.Public() {
return recordNotFound{}
}
f = found
return nil
})
if !ok {
return
}
ctx := r.Context()
key := attach.BlobKey(f.DiskName)
contentType := f.ContentType
if thumb {
if !slices.Contains(attach.AllowedImageMIMEs, f.ContentType) {
writeNotFound(w, r)
return
}
fileID, err := pathFileID(r)
key, err = f.ThumbKey(ctx, bucket, cf.thumbW, cf.thumbH, cf.thumbMode)
if err != nil {
writeCRUDError(w, err)
return
}
bucket := s.bucket()
if bucket == nil {
slog.Default().ErrorContext(r.Context(), "cabana: protected file route without a storage bucket", "controller", controllerID(cc))
slog.Default().ErrorContext(ctx, "cabana: protected thumbnail failed", "file_id", f.ID, "error", err)
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
var f *attach.File
ok := s.withFileScope(w, r, cc, func(ctx context.Context, tx *gorm.DB, sc *fileScope) error {
found, err := sc.findFile(ctx, tx, fileID, false)
if err != nil {
return err
}
if found.Public() {
return recordNotFound{}
}
f = found
return nil
})
if !ok {
contentType = ""
}
reader, err := bucket.NewReader(ctx, key, nil)
if err != nil {
if gcerrors.Code(err) == gcerrors.NotFound {
writeNotFound(w, r)
return
}
ctx := r.Context()
key := attach.BlobKey(f.DiskName)
contentType := f.ContentType
if thumb {
if !slices.Contains(attach.AllowedImageMIMEs, f.ContentType) {
writeNotFound(w, r)
return
}
key, err = f.ThumbKey(ctx, bucket, cf.thumbW, cf.thumbH, cf.thumbMode)
if err != nil {
slog.Default().ErrorContext(ctx, "cabana: protected thumbnail failed", "file_id", f.ID, "error", err)
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
contentType = ""
}
reader, err := bucket.NewReader(ctx, key, nil)
if err != nil {
if gcerrors.Code(err) == gcerrors.NotFound {
writeNotFound(w, r)
return
}
slog.Default().ErrorContext(ctx, "cabana: protected file read failed", "file_id", f.ID, "error", err)
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
defer reader.Close()
if contentType == "" {
contentType = reader.ContentType()
}
contentType = strings.ToLower(strings.TrimSpace(strings.SplitN(contentType, ";", 2)[0]))
h := w.Header()
h.Set("X-Content-Type-Options", "nosniff")
h.Set("Cache-Control", "private, no-store")
h.Set("Content-Security-Policy", "default-src 'none'; sandbox")
if slices.Contains(attach.AllowedImageMIMEs, contentType) {
h.Set("Content-Type", contentType)
} else {
h.Set("Content-Type", "application/octet-stream")
h.Set("Content-Disposition", "attachment; filename*=UTF-8''"+rfc5987(f.FileName))
}
h.Set("Content-Length", strconv.FormatInt(reader.Size(), 10))
w.WriteHeader(http.StatusOK)
_, _ = io.Copy(w, reader)
})
slog.Default().ErrorContext(ctx, "cabana: protected file read failed", "file_id", f.ID, "error", err)
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
defer reader.Close()
if contentType == "" {
contentType = reader.ContentType()
}
contentType = strings.ToLower(strings.TrimSpace(strings.SplitN(contentType, ";", 2)[0]))
h := w.Header()
h.Set("X-Content-Type-Options", "nosniff")
h.Set("Cache-Control", "private, no-store")
h.Set("Content-Security-Policy", "default-src 'none'; sandbox")
if slices.Contains(attach.AllowedImageMIMEs, contentType) {
h.Set("Content-Type", contentType)
} else {
h.Set("Content-Type", "application/octet-stream")
h.Set("Content-Disposition", "attachment; filename*=UTF-8''"+rfc5987(f.FileName))
}
h.Set("Content-Length", strconv.FormatInt(reader.Size(), 10))
w.WriteHeader(http.StatusOK)
_, _ = io.Copy(w, reader)
}
// rfc5987 percent-encodes a file name for a filename* parameter: only