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:
Jakub Zych
2026-10-02 18:04:34 +02:00
parent edda803dc1
commit 044e0450ef
22 changed files with 2183 additions and 21 deletions

View File

@@ -48,6 +48,11 @@ type service struct {
// insecureCookie drops Secure from the admin cookie (backend.cookie_secure
// false, development only); the zero value keeps the cookie Secure.
insecureCookie bool
// uploadBytes and defaultBytes are http.body_limits.upload_bytes and
// default_bytes (0 when not configured): the file routes cap their own
// bodies, since surf applies no body limit to raw routes.
uploadBytes int64
defaultBytes int64
}
// adminPrefix returns the mount path; a zero service uses the default.
@@ -92,6 +97,17 @@ func Activate(app *backpack.App, plugins []party.Plugin) (*Routes, error) {
if err != nil {
return nil, err
}
uploadBytes, err := configBytes(app, "http.body_limits.upload_bytes")
if err != nil {
return nil, err
}
defaultBytes, err := configBytes(app, "http.body_limits.default_bytes")
if err != nil {
return nil, err
}
if err := checkFileLimits(reg, uploadBytes); err != nil {
return nil, err
}
if err := compileContributions(reg, plugins); err != nil {
return nil, err
}
@@ -147,6 +163,8 @@ func Activate(app *backpack.App, plugins []party.Plugin) (*Routes, error) {
prefix: prefix,
insecureCookie: !secureCookie,
uploadBytes: uploadBytes,
defaultBytes: defaultBytes,
}
spa, err := boardwalk.Handler(prefix, http.HandlerFunc(writeNotFound))
if err != nil {
@@ -257,6 +275,10 @@ func (s *service) mount(r pact.Router) {
constrainRelation(g)
g.Post("/{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink", requireAjax(s.relationUnlink))
constrainRelation(g)
// File routes of `type: fileupload` fields (D-09). {id} 0 is the
// record being created in the X-Session-Key session.
g.Post("/{vendor}/{plugin}/{controller}/{id}/files/{field}", requireAjax(s.fileUpload))
constrainFile(g)
})
// The SPA shell: public, no guard. ServeMux prefers every API pattern
// above over the {path...} wildcard.
@@ -282,6 +304,11 @@ func constrainRelation(g pact.Router) {
g.Where("name", "[A-Za-z_][A-Za-z0-9_]*")
}
func constrainFile(g pact.Router) {
constrainController(g)
g.Where("field", "[A-Za-z_][A-Za-z0-9_]*")
}
func constrainNested(g pact.Router) {
constrainController(g)
g.Where("segment", "[A-Za-z_][A-Za-z0-9_]*")
@@ -291,6 +318,7 @@ func constrainNested(g pact.Router) {
// nestedGet serves the logical routes
//
// GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}
// GET /{vendor}/{plugin}/{controller}/{id}/files/{field}
// GET /{vendor}/{plugin}/{controller}/fields/{field}/options
// GET /{vendor}/{plugin}/{controller}/filters/{scope}/options
//
@@ -307,6 +335,9 @@ func (s *service) nestedGet(w http.ResponseWriter, r *http.Request) {
s.filterOptions(w, r)
case segment == "relations":
s.relationLinked(w, r)
case segment == "files":
r.SetPathValue("field", name)
s.fileList(w, r)
default:
writeNotFound(w, r)
}
@@ -635,6 +666,11 @@ func (s *service) create(w http.ResponseWriter, r *http.Request) {
if !s.operationDeclared(w, r, cc, "create") {
return
}
key, _, err := sessionKeyFrom(r)
if err != nil {
writeCRUDError(w, err)
return
}
body, err := decodeObject(r)
if err != nil {
writeCRUDError(w, err)
@@ -645,7 +681,7 @@ func (s *service) create(w http.ResponseWriter, r *http.Request) {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
rec, err := svc.CreateRecord(r.Context(), cc, RecordInput{Body: body})
rec, err := svc.CreateRecord(r.Context(), cc, RecordInput{Body: body, SessionKey: key})
if err != nil {
writeCRUDError(w, err)
return
@@ -664,6 +700,11 @@ func (s *service) update(w http.ResponseWriter, r *http.Request) {
writeCRUDError(w, err)
return
}
key, _, err := sessionKeyFrom(r)
if err != nil {
writeCRUDError(w, err)
return
}
body, err := decodeObject(r)
if err != nil {
writeCRUDError(w, err)
@@ -674,7 +715,7 @@ func (s *service) update(w http.ResponseWriter, r *http.Request) {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
rec, err := svc.UpdateRecord(r.Context(), cc, id, RecordInput{Body: body})
rec, err := svc.UpdateRecord(r.Context(), cc, id, RecordInput{Body: body, SessionKey: key})
if err != nil {
writeCRUDError(w, err)
return