feat(12.2-02): add file removal, caption, reorder and protected downloads
- DELETE, PUT and POST reorder under .../{id}/files/{field}, each scoped by one parent query (404 for a foreign file)
- protected download and thumb routes: is_public=false only, nosniff, private no-store, sandbox CSP, inline only for jpeg/png/gif/webp
- the save applies deferred removals, replaces attachOne files and rechecks maxFiles and required
- blobs of deleted files are removed after commit
- swagger2openapi emits binary content for file responses
- admin OpenAPI, TS types, conformance, README and attachments docs
This commit is contained in:
@@ -45,6 +45,22 @@ type conformCase struct {
|
||||
ref string
|
||||
call func(t *testing.T, env *conformEnv) *httptest.ResponseRecorder
|
||||
decode func(dec *json.Decoder) error
|
||||
// raw, when set, checks a binary response instead of decoding JSON;
|
||||
// admin.json must document the success as binary.
|
||||
raw func(t *testing.T, rec *httptest.ResponseRecorder)
|
||||
}
|
||||
|
||||
// binaryFile checks a protected file response: status, content type and
|
||||
// the D-10 headers.
|
||||
func binaryFile(contentType string) func(t *testing.T, rec *httptest.ResponseRecorder) {
|
||||
return func(t *testing.T, rec *httptest.ResponseRecorder) {
|
||||
t.Helper()
|
||||
h := rec.Header()
|
||||
if h.Get("Content-Type") != contentType || h.Get("X-Content-Type-Options") != "nosniff" ||
|
||||
h.Get("Cache-Control") != "private, no-store" || !strings.Contains(h.Get("Content-Security-Policy"), "sandbox") || rec.Body.Len() == 0 {
|
||||
t.Fatalf("protected file headers=%v len=%d", h, rec.Body.Len())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func into[T any]() func(*json.Decoder) error {
|
||||
@@ -66,33 +82,33 @@ func TestPhase10OpenAPIConformance(t *testing.T) {
|
||||
cases := []conformCase{
|
||||
{"POST /auth/login", 200, "cabana.Envelope-cabana_AdminLoginData", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||
return e.send(t, http.MethodPost, "/auth/login", map[string]string{"login": e.login, "password": adminTestPassword}, false)
|
||||
}, into[cabana.Envelope[cabana.AdminLoginData]]()},
|
||||
}, into[cabana.Envelope[cabana.AdminLoginData]](), nil},
|
||||
{"POST /auth/refresh", 200, "cabana.Envelope-cabana_AdminLoginData", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||
rec := e.send(t, http.MethodPost, "/auth/refresh", nil, true)
|
||||
e.token = accessToken(t, rec.Body.Bytes())
|
||||
return rec
|
||||
}, into[cabana.Envelope[cabana.AdminLoginData]]()},
|
||||
}, into[cabana.Envelope[cabana.AdminLoginData]](), nil},
|
||||
{"GET /auth/me", 200, "cabana.Envelope-cabana_AdminProfile", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||
return e.send(t, http.MethodGet, "/auth/me", nil, true)
|
||||
}, into[cabana.Envelope[cabana.AdminProfile]]()},
|
||||
}, into[cabana.Envelope[cabana.AdminProfile]](), nil},
|
||||
{"GET /lang", 200, "cabana.Envelope-cabana_LangBundle", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||
return e.send(t, http.MethodGet, "/lang", nil, false)
|
||||
}, into[cabana.Envelope[cabana.LangBundle]]()},
|
||||
}, into[cabana.Envelope[cabana.LangBundle]](), nil},
|
||||
{"GET /navigation", 200, "cabana.Envelope-array_cabana_NavigationEntry", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||
return e.send(t, http.MethodGet, "/navigation", nil, true)
|
||||
}, into[cabana.Envelope[[]cabana.NavigationEntry]]()},
|
||||
}, into[cabana.Envelope[[]cabana.NavigationEntry]](), nil},
|
||||
{"GET /settings", 200, "cabana.Envelope-array_cabana_SettingsEntry", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||
return e.send(t, http.MethodGet, "/settings", nil, true)
|
||||
}, into[cabana.Envelope[[]cabana.SettingsEntry]]()},
|
||||
}, into[cabana.Envelope[[]cabana.SettingsEntry]](), nil},
|
||||
{"GET /settings/{code}/schema", 200, "cabana.Envelope-cabana_FormView", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||
return e.send(t, http.MethodGet, "/settings/conform/schema", nil, true)
|
||||
}, into[cabana.Envelope[cabana.FormView]]()},
|
||||
}, into[cabana.Envelope[cabana.FormView]](), nil},
|
||||
{"GET /settings/{code}", 200, "cabana.Envelope-cabana_SettingsResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||
return e.send(t, http.MethodGet, "/settings/conform", nil, true)
|
||||
}, into[cabana.Envelope[cabana.SettingsResult]]()},
|
||||
}, into[cabana.Envelope[cabana.SettingsResult]](), nil},
|
||||
{"PUT /settings/{code}", 200, "cabana.Envelope-cabana_SettingsResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||
return e.send(t, http.MethodPut, "/settings/conform", map[string]any{"enabled": true}, true)
|
||||
}, into[cabana.Envelope[cabana.SettingsResult]]()},
|
||||
}, into[cabana.Envelope[cabana.SettingsResult]](), nil},
|
||||
{"GET /{vendor}/{plugin}/{controller}/schema/list", 200, "cabana.Envelope-cabana_ListSchema", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||
rec := e.send(t, http.MethodGet, "/acme/conform/gadgets/schema/list", nil, true)
|
||||
var body cabana.Envelope[cabana.ListSchema]
|
||||
@@ -108,22 +124,24 @@ func TestPhase10OpenAPIConformance(t *testing.T) {
|
||||
e.assertPluginAsset(t, body.Data.Assets.Scripts[0], "text/javascript; charset=utf-8")
|
||||
e.assertPluginAsset(t, body.Data.Assets.Styles[0], "text/css; charset=utf-8")
|
||||
return rec
|
||||
}, into[cabana.Envelope[cabana.ListSchema]]()},
|
||||
}, into[cabana.Envelope[cabana.ListSchema]](), nil},
|
||||
{"GET /{vendor}/{plugin}/{controller}/schema/form", 200, "cabana.Envelope-cabana_FormView", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||
return e.send(t, http.MethodGet, "/acme/conform/gadgets/schema/form", nil, true)
|
||||
}, into[cabana.Envelope[cabana.FormView]]()},
|
||||
}, into[cabana.Envelope[cabana.FormView]](), nil},
|
||||
{"GET /{vendor}/{plugin}/{controller}/schema/relation/{name}", 200, "cabana.Envelope-cabana_RelationSchema", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||
return e.send(t, http.MethodGet, "/acme/conform/gadgets/schema/relation/members", nil, true)
|
||||
}, into[cabana.Envelope[cabana.RelationSchema]]()},
|
||||
}, into[cabana.Envelope[cabana.RelationSchema]](), nil},
|
||||
{"GET /{vendor}/{plugin}/{controller}/fields/{field}/options", 200, "cabana.ListEnvelope-array_cabana_RelationOption", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||
return e.send(t, http.MethodGet, "/acme/conform/gadgets/fields/group/options?search="+e.stamp, nil, true)
|
||||
}, into[cabana.ListEnvelope[[]cabana.RelationOption]]()},
|
||||
}, into[cabana.ListEnvelope[[]cabana.RelationOption]](), nil},
|
||||
{"GET /{vendor}/{plugin}/{controller}/filters/{scope}/options", 200, "cabana.Envelope-array_cabana_FilterOption", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||
return e.send(t, http.MethodGet, "/acme/conform/gadgets/filters/grouped/options", nil, true)
|
||||
}, into[cabana.Envelope[[]cabana.FilterOption]]()},
|
||||
}, into[cabana.Envelope[[]cabana.FilterOption]](), nil},
|
||||
{"POST /{vendor}/{plugin}/{controller}/{id}/files/{field}", 201, "cabana.Envelope-cabana_FileItem", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||
return e.upload(t, 0, "photos", "photo.png", conformPNG(t), e.sessionKey)
|
||||
}, into[cabana.Envelope[cabana.FileItem]]()},
|
||||
rec := e.upload(t, 0, "photos", "photo.png", conformPNG(t), e.sessionKey)
|
||||
e.photoID = dataID(t, rec.Body.Bytes())
|
||||
return rec
|
||||
}, into[cabana.Envelope[cabana.FileItem]](), nil},
|
||||
{"GET /{vendor}/{plugin}/{controller}/{id}/files/{field}", 200, "cabana.Envelope-array_cabana_FileItem", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||
rec := e.sendWith(t, http.MethodGet, "/acme/conform/gadgets/0/files/photos", nil, "", map[string]string{cabana.SessionKeyHeader: e.sessionKey})
|
||||
var body cabana.Envelope[[]cabana.FileItem]
|
||||
@@ -131,12 +149,34 @@ func TestPhase10OpenAPIConformance(t *testing.T) {
|
||||
t.Fatalf("pending file list = %s (%v)", rec.Body.String(), err)
|
||||
}
|
||||
return rec
|
||||
}, into[cabana.Envelope[[]cabana.FileItem]]()},
|
||||
}, into[cabana.Envelope[[]cabana.FileItem]](), nil},
|
||||
{"POST /{vendor}/{plugin}/{controller}/{id}/files/{field}/reorder", 200, "cabana.Envelope-array_cabana_FileItem", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||
body, _ := json.Marshal(map[string]any{"ids": []uint{e.photoID}})
|
||||
return e.sendWith(t, http.MethodPost, "/acme/conform/gadgets/0/files/photos/reorder", body, "application/json", map[string]string{cabana.SessionKeyHeader: e.sessionKey})
|
||||
}, into[cabana.Envelope[[]cabana.FileItem]](), nil},
|
||||
{"DELETE /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}", 200, "cabana.Envelope-cabana_FileMutationResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||
return e.sendWith(t, http.MethodDelete, fmt.Sprintf("/acme/conform/gadgets/0/files/photos/%d", e.photoID), nil, "", map[string]string{cabana.SessionKeyHeader: e.sessionKey})
|
||||
}, into[cabana.Envelope[cabana.FileMutationResult]](), nil},
|
||||
{"POST /{vendor}/{plugin}/{controller}", 201, "cabana.RecordEnvelope", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||
rec := e.send(t, http.MethodPost, "/acme/conform/gadgets", map[string]any{"name": "gadget-" + e.stamp, "active": true, "group": e.groupID}, true)
|
||||
e.gadgetID = dataID(t, rec.Body.Bytes())
|
||||
return rec
|
||||
}, into[cabana.RecordEnvelope]()},
|
||||
}, into[cabana.RecordEnvelope](), nil},
|
||||
{"PUT /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}", 200, "cabana.Envelope-cabana_FileItem", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||
up := e.upload(t, e.gadgetID, "manual", "manual.png", conformPNG(t), e.sessionKey)
|
||||
if up.Code != http.StatusCreated {
|
||||
t.Fatalf("manual upload status=%d body=%s", up.Code, up.Body.String())
|
||||
}
|
||||
e.manualID = dataID(t, up.Body.Bytes())
|
||||
body, _ := json.Marshal(map[string]any{"title": "Manual " + e.stamp})
|
||||
return e.sendWith(t, http.MethodPut, fmt.Sprintf("/acme/conform/gadgets/%d/files/manual/%d", e.gadgetID, e.manualID), body, "application/json", map[string]string{cabana.SessionKeyHeader: e.sessionKey})
|
||||
}, into[cabana.Envelope[cabana.FileItem]](), nil},
|
||||
{"GET /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}/download", 200, "", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||
return e.sendWith(t, http.MethodGet, fmt.Sprintf("/acme/conform/gadgets/%d/files/manual/%d/download", e.gadgetID, e.manualID), nil, "", map[string]string{cabana.SessionKeyHeader: e.sessionKey})
|
||||
}, nil, binaryFile("image/png")},
|
||||
{"GET /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}/thumb", 200, "", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||
return e.sendWith(t, http.MethodGet, fmt.Sprintf("/acme/conform/gadgets/%d/files/manual/%d/thumb", e.gadgetID, e.manualID), nil, "", map[string]string{cabana.SessionKeyHeader: e.sessionKey})
|
||||
}, nil, binaryFile("image/png")},
|
||||
{"POST /{vendor}/{plugin}/{controller}/widgets/{field}", 200, "cabana.Envelope-cabana_AdminActionResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||
rec := e.send(t, http.MethodPost, "/acme/conform/gadgets/widgets/lookup", map[string]any{"record_id": e.gadgetID, "values": map[string]any{"name": "x", "active": false}}, true)
|
||||
// The fixture action also returns active, which is outside the
|
||||
@@ -149,7 +189,7 @@ func TestPhase10OpenAPIConformance(t *testing.T) {
|
||||
t.Fatalf("widget fill = %#v, want only name", body.Data.Fill)
|
||||
}
|
||||
return rec
|
||||
}, into[cabana.Envelope[cabana.AdminActionResult]]()},
|
||||
}, into[cabana.Envelope[cabana.AdminActionResult]](), nil},
|
||||
{"GET /{vendor}/{plugin}/{controller}/partials/{name}", 200, "cabana.Envelope-cabana_PartialView", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||
rec := e.send(t, http.MethodGet, fmt.Sprintf("/acme/conform/gadgets/partials/summary?id=%d", e.gadgetID), nil, true)
|
||||
if !strings.Contains(rec.Body.String(), `"text":"gadget-`+e.stamp+`"`) {
|
||||
@@ -160,41 +200,41 @@ func TestPhase10OpenAPIConformance(t *testing.T) {
|
||||
t.Fatalf("header partial status=%d body=%s", header.Code, header.Body.String())
|
||||
}
|
||||
return rec
|
||||
}, into[cabana.Envelope[cabana.PartialView]]()},
|
||||
}, into[cabana.Envelope[cabana.PartialView]](), nil},
|
||||
{"GET /{vendor}/{plugin}/{controller}", 200, "cabana.ListEnvelope-array_cabana_AdminRecord", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||
return e.send(t, http.MethodGet, "/acme/conform/gadgets?search="+e.stamp, nil, true)
|
||||
}, into[cabana.ListEnvelope[[]cabana.AdminRecord]]()},
|
||||
}, into[cabana.ListEnvelope[[]cabana.AdminRecord]](), nil},
|
||||
{"GET /{vendor}/{plugin}/{controller}/{id}", 200, "cabana.RecordEnvelope", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||
return e.send(t, http.MethodGet, fmt.Sprintf("/acme/conform/gadgets/%d", e.gadgetID), nil, true)
|
||||
}, into[cabana.RecordEnvelope]()},
|
||||
}, into[cabana.RecordEnvelope](), nil},
|
||||
{"PUT /{vendor}/{plugin}/{controller}/{id}", 200, "cabana.RecordEnvelope", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||
return e.send(t, http.MethodPut, fmt.Sprintf("/acme/conform/gadgets/%d", e.gadgetID), map[string]any{"name": "gadget-" + e.stamp + "-renamed", "group": nil}, true)
|
||||
}, into[cabana.RecordEnvelope]()},
|
||||
}, into[cabana.RecordEnvelope](), nil},
|
||||
{"GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}/candidates", 200, "cabana.ListEnvelope-array_cabana_AdminRecord", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||
return e.send(t, http.MethodGet, fmt.Sprintf("/acme/conform/gadgets/%d/relations/members/candidates?search=%s", e.gadgetID, e.stamp), nil, true)
|
||||
}, into[cabana.ListEnvelope[[]cabana.AdminRecord]]()},
|
||||
}, into[cabana.ListEnvelope[[]cabana.AdminRecord]](), nil},
|
||||
{"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/link", 200, "cabana.Envelope-cabana_RelationMutationResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||
return e.send(t, http.MethodPost, fmt.Sprintf("/acme/conform/gadgets/%d/relations/members/link", e.gadgetID), map[string]any{"ids": []uint{e.memberID}}, true)
|
||||
}, into[cabana.Envelope[cabana.RelationMutationResult]]()},
|
||||
}, into[cabana.Envelope[cabana.RelationMutationResult]](), nil},
|
||||
{"GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}", 200, "cabana.ListEnvelope-array_cabana_AdminRecord", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||
return e.send(t, http.MethodGet, fmt.Sprintf("/acme/conform/gadgets/%d/relations/members", e.gadgetID), nil, true)
|
||||
}, into[cabana.ListEnvelope[[]cabana.AdminRecord]]()},
|
||||
}, into[cabana.ListEnvelope[[]cabana.AdminRecord]](), nil},
|
||||
{"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink", 200, "cabana.Envelope-cabana_RelationMutationResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||
return e.send(t, http.MethodPost, fmt.Sprintf("/acme/conform/gadgets/%d/relations/members/unlink", e.gadgetID), map[string]any{"ids": []uint{e.memberID}}, true)
|
||||
}, into[cabana.Envelope[cabana.RelationMutationResult]]()},
|
||||
}, into[cabana.Envelope[cabana.RelationMutationResult]](), nil},
|
||||
{"POST /{vendor}/{plugin}/{controller}/toolbar/{action}", 200, "cabana.Envelope-cabana_AdminActionResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||
return e.send(t, http.MethodPost, "/acme/conform/gadgets/toolbar/recount", map[string]any{}, true)
|
||||
}, into[cabana.Envelope[cabana.AdminActionResult]]()},
|
||||
}, into[cabana.Envelope[cabana.AdminActionResult]](), nil},
|
||||
{"POST /{vendor}/{plugin}/{controller}/bulk-delete", 200, "cabana.Envelope-cabana_BulkResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||
spare := e.send(t, http.MethodPost, "/acme/conform/gadgets", map[string]any{"name": "spare-" + e.stamp}, true)
|
||||
return e.send(t, http.MethodPost, "/acme/conform/gadgets/bulk-delete", map[string]any{"ids": []uint{dataID(t, spare.Body.Bytes())}}, true)
|
||||
}, into[cabana.Envelope[cabana.BulkResult]]()},
|
||||
}, into[cabana.Envelope[cabana.BulkResult]](), nil},
|
||||
{"DELETE /{vendor}/{plugin}/{controller}/{id}", 200, "cabana.Envelope-cabana_BulkResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||
return e.send(t, http.MethodDelete, fmt.Sprintf("/acme/conform/gadgets/%d", e.gadgetID), nil, true)
|
||||
}, into[cabana.Envelope[cabana.BulkResult]]()},
|
||||
}, into[cabana.Envelope[cabana.BulkResult]](), nil},
|
||||
{"POST /auth/logout", 200, "cabana.Envelope-cabana_AdminLogoutData", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||
return e.send(t, http.MethodPost, "/auth/logout", nil, true)
|
||||
}, into[cabana.Envelope[cabana.AdminLogoutData]]()},
|
||||
}, into[cabana.Envelope[cabana.AdminLogoutData]](), nil},
|
||||
}
|
||||
|
||||
inventory := map[string]bool{}
|
||||
@@ -223,12 +263,19 @@ func TestPhase10OpenAPIConformance(t *testing.T) {
|
||||
if rec.Code != tc.status {
|
||||
t.Fatalf("status=%d want %d body=%s", rec.Code, tc.status, rec.Body.String())
|
||||
}
|
||||
method, path, _ := strings.Cut(tc.key, " ")
|
||||
if tc.raw != nil {
|
||||
tc.raw(t, rec)
|
||||
if !spec.binary(path, strings.ToLower(method), fmt.Sprint(tc.status)) {
|
||||
t.Fatalf("admin.json does not document %s %d as binary", tc.key, tc.status)
|
||||
}
|
||||
return
|
||||
}
|
||||
dec := json.NewDecoder(bytes.NewReader(rec.Body.Bytes()))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := tc.decode(dec); err != nil {
|
||||
t.Fatalf("body does not match its documented type: %v\n%s", err, rec.Body.String())
|
||||
}
|
||||
method, path, _ := strings.Cut(tc.key, " ")
|
||||
got := spec.ref(path, strings.ToLower(method), fmt.Sprint(tc.status))
|
||||
if got != tc.ref {
|
||||
t.Fatalf("admin.json documents %q for %s %d, the handler writes %s", got, tc.key, tc.status, tc.ref)
|
||||
@@ -245,13 +292,20 @@ type conformSpecDoc struct {
|
||||
Responses map[string]struct {
|
||||
Content map[string]struct {
|
||||
Schema struct {
|
||||
Ref string `json:"$ref"`
|
||||
Ref string `json:"$ref"`
|
||||
Type string `json:"type"`
|
||||
Format string `json:"format"`
|
||||
} `json:"schema"`
|
||||
} `json:"content"`
|
||||
} `json:"responses"`
|
||||
} `json:"paths"`
|
||||
}
|
||||
|
||||
func (d conformSpecDoc) binary(path, method, status string) bool {
|
||||
schema := d.Paths[path][method].Responses[status].Content["application/octet-stream"].Schema
|
||||
return schema.Type == "string" && schema.Format == "binary"
|
||||
}
|
||||
|
||||
func (d conformSpecDoc) ref(path, method, status string) string {
|
||||
ref := d.Paths[path][method].Responses[status].Content["application/json"].Schema.Ref
|
||||
return strings.TrimPrefix(ref, "#/components/schemas/")
|
||||
@@ -282,6 +336,8 @@ type conformEnv struct {
|
||||
token string
|
||||
stamp string
|
||||
sessionKey string
|
||||
photoID uint
|
||||
manualID uint
|
||||
groupID uint
|
||||
memberID uint
|
||||
gadgetID uint
|
||||
@@ -491,7 +547,7 @@ type conformGadget struct {
|
||||
func (conformGadget) TableName() string { return "cabana_conform_gadgets" }
|
||||
func (conformGadget) MorphName() string { return "acme.conform.gadget" }
|
||||
func (conformGadget) AttachRelations() []attach.Relation {
|
||||
return []attach.Relation{{Name: "photos", Many: true, Public: true}}
|
||||
return []attach.Relation{{Name: "photos", Many: true, Public: true}, {Name: "manual"}}
|
||||
}
|
||||
func (conformGadget) Fillable() []string { return []string{"name", "active"} }
|
||||
func (conformGadget) Rules() map[string]string { return map[string]string{"name": "required"} }
|
||||
@@ -733,6 +789,12 @@ update:
|
||||
maxFilesize: 0.5
|
||||
thumbOptions:
|
||||
mode: crop
|
||||
manual:
|
||||
label: Manual
|
||||
type: fileupload
|
||||
fileTypes: [pdf, png, svg, txt]
|
||||
useCaption: true
|
||||
context: update
|
||||
`),
|
||||
"models/settings/fields.yaml": file(`fields:
|
||||
enabled:
|
||||
|
||||
Reference in New Issue
Block a user