feat(12.2-02): add the fileupload field with deferred uploads committed on save
- type: fileupload compiles the D-08 keys and binds to the model's attach.Relation at boot
- X-Session-Key (cabana.SessionKeyHeader) carries the form session key; RecordInput.SessionKey
- GET and POST .../{id}/files/{field}: list with pending uploads, multipart upload into attach.Store
- the create and update save attaches the session's pending files in its transaction
- swagger2openapi folds formData parameters into a multipart requestBody
- admin OpenAPI, TS types, conformance cases, README and forms docs
This commit is contained in:
115
modules/cabana/deferred.go
Normal file
115
modules/cabana/deferred.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package cabana
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// SessionKeyHeader carries the admin SPA's form session key (D-02): a
|
||||
// random key the SPA generates when a form opens and sends with every file
|
||||
// upload, file removal and the final save. Work bound to the key is applied
|
||||
// by the record's next create or update save, inside its transaction.
|
||||
const SessionKeyHeader = "X-Session-Key"
|
||||
|
||||
// sessionKeyPattern is the accepted key shape: 32 to 128 URL-safe
|
||||
// characters, at least 128 bits for a base64url key.
|
||||
var sessionKeyPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{32,128}$`)
|
||||
|
||||
// sessionKeyFrom reads the X-Session-Key header. An absent or empty header
|
||||
// is ("", false, nil); a malformed key is a validation error on session_key.
|
||||
func sessionKeyFrom(r *http.Request) (string, bool, error) {
|
||||
raw := strings.TrimSpace(r.Header.Get(SessionKeyHeader))
|
||||
if raw == "" {
|
||||
return "", false, nil
|
||||
}
|
||||
if !sessionKeyPattern.MatchString(raw) {
|
||||
return "", false, &ValidationError{Details: map[string]any{"session_key": []string{"The session key is invalid."}}}
|
||||
}
|
||||
return raw, true, nil
|
||||
}
|
||||
|
||||
// commitDeferred applies the file bindings of in.SessionKey to the saved
|
||||
// target inside the save transaction (D-04). It reads every binding of the
|
||||
// key, the authenticated admin and the controller's morph type whose
|
||||
// master_field is a fileupload field allowed in op, locked FOR UPDATE so two
|
||||
// saves with one key serialize; binds attach their pending file, and the
|
||||
// applied rows are deleted. Bindings of other fields stay for the purge.
|
||||
func (s CRUDService) commitDeferred(ctx context.Context, tx *gorm.DB, cc *CompiledController, target any, op string, in RecordInput) error {
|
||||
if cc == nil || len(cc.files) == 0 || in.SessionKey == "" {
|
||||
return nil
|
||||
}
|
||||
principal, _ := bouncer.User(ctx)
|
||||
if principal == nil || !principal.Backend || principal.ID == 0 {
|
||||
return nil
|
||||
}
|
||||
fields := make([]string, 0, len(cc.files))
|
||||
for _, field := range cc.Form.Fields {
|
||||
if cf := cc.files[field.Name]; cf != nil && contextAllows(cc, cf.name, op) {
|
||||
fields = append(fields, cf.name)
|
||||
}
|
||||
}
|
||||
if len(fields) == 0 {
|
||||
return nil
|
||||
}
|
||||
morph, err := lagoon.MorphType(tx, target)
|
||||
if err != nil {
|
||||
return lifecycleFailure(cc, err)
|
||||
}
|
||||
key := lagoon.DeferredKey{SessionKey: in.SessionKey, AdminID: principal.ID, MasterType: morph}
|
||||
rows, err := lagoon.DeferredBindings(ctx, tx, key, fields)
|
||||
if err != nil {
|
||||
return lifecycleFailure(cc, err)
|
||||
}
|
||||
ownerID := primaryText(target)
|
||||
if ownerID == "" {
|
||||
return nil
|
||||
}
|
||||
applied := make([]uint, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
cf := cc.files[row.MasterField]
|
||||
if cf == nil || row.SlaveType != lagoon.DeferredFileType || !row.IsBind {
|
||||
continue
|
||||
}
|
||||
if err := s.applyFileBind(ctx, tx, cf, morph, ownerID, row); err != nil {
|
||||
return lifecycleFailure(cc, err)
|
||||
}
|
||||
applied = append(applied, row.ID)
|
||||
}
|
||||
if err := lagoon.DeferredForget(ctx, tx, applied); err != nil {
|
||||
return lifecycleFailure(cc, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyFileBind attaches a pending upload to the owner. A row that is gone
|
||||
// or already attached somewhere is ignored.
|
||||
func (s CRUDService) applyFileBind(ctx context.Context, tx *gorm.DB, cf *compiledFile, morph, ownerID string, row lagoon.DeferredBinding) error {
|
||||
id, err := strconv.ParseUint(row.SlaveID, 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
return nil
|
||||
}
|
||||
f, err := lockFile(ctx, tx, uint(id))
|
||||
if err != nil || f == nil || f.AttachmentID != "" || f.AttachmentType != "" {
|
||||
return err
|
||||
}
|
||||
return tx.Session(&gorm.Session{NewDB: true, Context: ctx}).Model(&attach.File{}).
|
||||
Where("id = ?", f.ID).
|
||||
Updates(map[string]any{"attachment_type": morph, "attachment_id": ownerID, "field": cf.name}).Error
|
||||
}
|
||||
|
||||
// primaryText is the saved record's primary key as system_files stores it
|
||||
// in attachment_id (Winter keeps the morph key as a string).
|
||||
func primaryText(model any) string {
|
||||
if n := pkUint(model); n > 0 {
|
||||
return uitoa(n)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
Reference in New Issue
Block a user