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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user