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:
@@ -49,6 +49,14 @@ All paths are relative to `<prefix>/api/v1`. A controller ID `vendor.plugin.cont
|
||||
| POST `.../{id}/relations/{name}/link`, POST `.../{id}/relations/{name}/unlink` | Link and unlink related records. |
|
||||
| GET `.../{id}/files/{field}` | The files of a `type: fileupload` field: attached files minus the session's pending removals, plus its pending uploads, in `sort_order`. `{id}` 0 is the record being created and needs `X-Session-Key`. |
|
||||
| POST `.../{id}/files/{field}` | Upload one multipart `file_data` part; it is bound to the `X-Session-Key` session (required) and attached by the record's next save. Answers 201 with the pending `cabana.FileItem`. |
|
||||
| PUT `.../{id}/files/{field}/{file}` | Save a file's `title` and `description` at once; the field must declare `useCaption` (403 otherwise). |
|
||||
| DELETE `.../{id}/files/{field}/{file}` | Remove a file: an attached file is removed by the next save with the same `X-Session-Key` (required), a pending upload is deleted at once. |
|
||||
| POST `.../{id}/files/{field}/reorder` | `{ids}` in the new order, exactly the field's visible files; they take the existing `sort_order` values at once. attachMany only (403 otherwise). |
|
||||
| GET `.../{id}/files/{field}/{file}/download`, GET `.../{id}/files/{field}/{file}/thumb` | Stream a protected file or its preview thumbnail; see the protected files note below. |
|
||||
|
||||
Every file route resolves its file with one query scoped to the record (loaded through `pact.FormExtendQuery`) or to the administrator's own pending uploads: a file of another record is `not_found`, never `forbidden`. The save applies the session's file work in its transaction: a pending upload on an attachOne field replaces the file attached before, a deferred removal deletes the attached file, and the blobs of deleted files are removed after commit. After that the save checks `maxFiles` and a `required` fileupload field (at least one file) and answers 422 on the field when either fails; the pending work stays for the next attempt.
|
||||
|
||||
Protected files (a relation with `Public` false) never get a public URL. The download and thumb routes serve only `is_public` false files, with `X-Content-Type-Options: nosniff`, `Cache-Control: private, no-store` and `Content-Security-Policy: default-src 'none'; sandbox`; JPEG, PNG, GIF and WebP are served inline with their type, every other type as an `application/octet-stream` attachment. The thumb route answers 404 for a file that is not one of those images.
|
||||
|
||||
Every path under the prefix that no API route matches is served by the admin SPA; unmatched API paths return the `not_found` error envelope instead.
|
||||
|
||||
@@ -167,7 +175,9 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS }
|
||||
| `cabana.RecordInput` | A create or update body (`Body`) and the form session key (`SessionKey`) whose file bindings the save applies. |
|
||||
| `cabana.FileItem` | One file of a fileupload field: id, name, size, content type, title, description, `sort_order`, `pending`, and `url`/`thumb_url` for public relations. |
|
||||
| `cabana.ThumbOptions` | A fileupload field's `thumbOptions` (the preview thumbnail `mode`). |
|
||||
| `cabana.AdminFileList` / `cabana.AdminFileUpload` | Swag annotations of the file list and upload routes. |
|
||||
| `cabana.FileMutationResult` | Answer of the file removal route: `removed`. |
|
||||
| `cabana.AdminFileCaptionRequest` | Body of the file caption route: optional `title` and `description`. Unknown keys are refused. |
|
||||
| `cabana.AdminFileList` / `cabana.AdminFileUpload` / `cabana.AdminFileUpdate` / `cabana.AdminFileRemove` / `cabana.AdminFileReorder` / `cabana.AdminFileDownload` / `cabana.AdminFileThumb` | Swag annotations of the file routes. |
|
||||
| `cabana.PartialView` / `cabana.PartialNode` | A rendered partial: a list of nodes, each an allowlisted element (`tag`, `attrs`, `children`) or a text node (`text`). |
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -662,3 +662,118 @@ func AdminFileList() {}
|
||||
// @Failure 422 {object} ErrorEnvelope
|
||||
// @Router /{vendor}/{plugin}/{controller}/{id}/files/{field} [post]
|
||||
func AdminFileUpload() {}
|
||||
|
||||
// AdminFileUpdate documents the caption route of a fileupload field.
|
||||
//
|
||||
// @Summary Save a file's title and description
|
||||
// @Description Saves at once (not deferred). The field must declare useCaption, otherwise 403. Omitted keys are left unchanged; unknown keys are refused.
|
||||
// @Tags admin
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BackendBearer
|
||||
// @Param vendor path string true "Vendor"
|
||||
// @Param plugin path string true "Plugin"
|
||||
// @Param controller path string true "Controller"
|
||||
// @Param id path integer true "Owner id (0 for the record being created)"
|
||||
// @Param field path string true "fileupload field name"
|
||||
// @Param file path integer true "File id"
|
||||
// @Param X-Session-Key header string false "Form session key; needed for a pending upload"
|
||||
// @Param body body AdminFileCaptionRequest true "Title and description"
|
||||
// @Success 200 {object} Envelope[FileItem]
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 404 {object} ErrorEnvelope
|
||||
// @Failure 413 {object} ErrorEnvelope
|
||||
// @Failure 422 {object} ErrorEnvelope
|
||||
// @Router /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file} [put]
|
||||
func AdminFileUpdate() {}
|
||||
|
||||
// AdminFileRemove documents the removal of a file from a fileupload field.
|
||||
//
|
||||
// @Summary Remove a file
|
||||
// @Description Removing an attached file is deferred to the record's next save with the same X-Session-Key; removing a pending upload deletes it at once.
|
||||
// @Tags admin
|
||||
// @Produce json
|
||||
// @Security BackendBearer
|
||||
// @Param vendor path string true "Vendor"
|
||||
// @Param plugin path string true "Plugin"
|
||||
// @Param controller path string true "Controller"
|
||||
// @Param id path integer true "Owner id (0 for the record being created)"
|
||||
// @Param field path string true "fileupload field name"
|
||||
// @Param file path integer true "File id"
|
||||
// @Param X-Session-Key header string true "Form session key"
|
||||
// @Success 200 {object} Envelope[FileMutationResult]
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 404 {object} ErrorEnvelope
|
||||
// @Failure 422 {object} ErrorEnvelope
|
||||
// @Router /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file} [delete]
|
||||
func AdminFileRemove() {}
|
||||
|
||||
// AdminFileReorder documents the reorder route of an attachMany field.
|
||||
//
|
||||
// @Summary Reorder the files of a field
|
||||
// @Description ids must be exactly the field's visible files (attached minus pending removals plus pending uploads); they receive the existing sort_order values in the submitted order, at once. attachMany only, otherwise 403.
|
||||
// @Tags admin
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BackendBearer
|
||||
// @Param vendor path string true "Vendor"
|
||||
// @Param plugin path string true "Plugin"
|
||||
// @Param controller path string true "Controller"
|
||||
// @Param id path integer true "Owner id (0 for the record being created)"
|
||||
// @Param field path string true "fileupload field name"
|
||||
// @Param X-Session-Key header string false "Form session key; needed for pending uploads"
|
||||
// @Param body body AdminIDsRequest true "File ids in the new order"
|
||||
// @Success 200 {object} Envelope[[]FileItem]
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 404 {object} ErrorEnvelope
|
||||
// @Failure 413 {object} ErrorEnvelope
|
||||
// @Failure 422 {object} ErrorEnvelope
|
||||
// @Router /{vendor}/{plugin}/{controller}/{id}/files/{field}/reorder [post]
|
||||
func AdminFileReorder() {}
|
||||
|
||||
// AdminFileDownload documents the download of a protected file.
|
||||
//
|
||||
// @Summary Download a protected file
|
||||
// @Description Streams a file of a protected (Public false) relation that belongs to a record the admin may load, or is pending in the admin's own session. Public files are 404. JPEG, PNG, GIF and WebP are served inline with their type; everything else as an application/octet-stream attachment. Responses carry X-Content-Type-Options nosniff, Cache-Control private, no-store and a sandboxing Content-Security-Policy.
|
||||
// @Tags admin
|
||||
// @Produce octet-stream
|
||||
// @Security BackendBearer
|
||||
// @Param vendor path string true "Vendor"
|
||||
// @Param plugin path string true "Plugin"
|
||||
// @Param controller path string true "Controller"
|
||||
// @Param id path integer true "Owner id (0 for the record being created)"
|
||||
// @Param field path string true "fileupload field name"
|
||||
// @Param file path integer true "File id"
|
||||
// @Param X-Session-Key header string false "Form session key; needed for a pending upload"
|
||||
// @Success 200 {file} file
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 404 {object} ErrorEnvelope
|
||||
// @Failure 422 {object} ErrorEnvelope
|
||||
// @Router /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}/download [get]
|
||||
func AdminFileDownload() {}
|
||||
|
||||
// AdminFileThumb documents the thumbnail of a protected image.
|
||||
//
|
||||
// @Summary Thumbnail of a protected image
|
||||
// @Description The preview thumbnail (imageWidth by imageHeight, 240 by 240 by default, in thumbOptions.mode) of a protected image file, scoped like the download route. A file that is not a JPEG, PNG, GIF or WebP image is 404.
|
||||
// @Tags admin
|
||||
// @Produce octet-stream
|
||||
// @Security BackendBearer
|
||||
// @Param vendor path string true "Vendor"
|
||||
// @Param plugin path string true "Plugin"
|
||||
// @Param controller path string true "Controller"
|
||||
// @Param id path integer true "Owner id (0 for the record being created)"
|
||||
// @Param field path string true "fileupload field name"
|
||||
// @Param file path integer true "File id"
|
||||
// @Param X-Session-Key header string false "Form session key; needed for a pending upload"
|
||||
// @Success 200 {file} file
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 404 {object} ErrorEnvelope
|
||||
// @Failure 422 {object} ErrorEnvelope
|
||||
// @Router /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}/thumb [get]
|
||||
func AdminFileThumb() {}
|
||||
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/phrasebook"
|
||||
"gocloud.dev/blob"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
@@ -20,6 +22,11 @@ import (
|
||||
// CRUDService runs schema-projected record and bulk operations.
|
||||
type CRUDService struct {
|
||||
DB *gorm.DB
|
||||
|
||||
// bucket deletes the blobs of files a save replaces or removes, after
|
||||
// commit; tr localizes the file limit messages. Both may be nil.
|
||||
bucket *blob.Bucket
|
||||
tr *phrasebook.Translator
|
||||
}
|
||||
|
||||
// RecordInput is a decoded JSON object. Keys are untrusted. SessionKey is
|
||||
|
||||
@@ -2,6 +2,7 @@ package cabana
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
@@ -11,6 +12,7 @@ import (
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// SessionKeyHeader carries the admin SPA's form session key (D-02): a
|
||||
@@ -37,60 +39,95 @@ func sessionKeyFrom(r *http.Request) (string, bool, error) {
|
||||
}
|
||||
|
||||
// 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.
|
||||
// target inside the save transaction (D-04), then rechecks the file limits.
|
||||
//
|
||||
// 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, and applies
|
||||
// them in id order: a bind attaches its pending upload (on an attachOne
|
||||
// field after deleting the file it replaces), an unbind deletes the attached
|
||||
// file. Blobs of deleted files are removed after commit, and the applied
|
||||
// rows are deleted. Bindings of other fields stay for the purge. Then every
|
||||
// fileupload field allowed in op must hold at most maxFiles files and, when
|
||||
// required, at least one; otherwise the save fails with a 422 on the field,
|
||||
// the transaction rolls back and the bindings stay in place.
|
||||
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 == "" {
|
||||
if cc == nil || cc.Form == nil || len(cc.files) == 0 {
|
||||
return nil
|
||||
}
|
||||
principal, _ := bouncer.User(ctx)
|
||||
if principal == nil || !principal.Backend || principal.ID == 0 {
|
||||
return nil
|
||||
}
|
||||
fields := make([]string, 0, len(cc.files))
|
||||
var fields []*compiledFile
|
||||
for _, field := range cc.Form.Fields {
|
||||
if cf := cc.files[field.Name]; cf != nil && contextAllows(cc, cf.name, op) {
|
||||
fields = append(fields, cf.name)
|
||||
fields = append(fields, cf)
|
||||
}
|
||||
}
|
||||
if len(fields) == 0 {
|
||||
ownerID := primaryText(target)
|
||||
if len(fields) == 0 || ownerID == "" {
|
||||
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
|
||||
principal, _ := bouncer.User(ctx)
|
||||
if in.SessionKey != "" && principal != nil && principal.Backend && principal.ID != 0 {
|
||||
names := make([]string, len(fields))
|
||||
for i, cf := range fields {
|
||||
names[i] = cf.name
|
||||
}
|
||||
if err := s.applyFileBind(ctx, tx, cf, morph, ownerID, row); err != nil {
|
||||
key := lagoon.DeferredKey{SessionKey: in.SessionKey, AdminID: principal.ID, MasterType: morph}
|
||||
rows, err := lagoon.DeferredBindings(ctx, tx, key, names)
|
||||
if err != nil {
|
||||
return lifecycleFailure(cc, err)
|
||||
}
|
||||
applied := make([]uint, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
cf := cc.files[row.MasterField]
|
||||
if cf == nil || row.SlaveType != lagoon.DeferredFileType {
|
||||
continue
|
||||
}
|
||||
if row.IsBind {
|
||||
err = s.applyFileBind(ctx, tx, cf, morph, ownerID, row)
|
||||
} else {
|
||||
err = s.applyFileUnbind(ctx, tx, cf, morph, ownerID, row)
|
||||
}
|
||||
if err != nil {
|
||||
return lifecycleFailure(cc, err)
|
||||
}
|
||||
applied = append(applied, row.ID)
|
||||
}
|
||||
if err := lagoon.DeferredForget(ctx, tx, applied); err != nil {
|
||||
return lifecycleFailure(cc, err)
|
||||
}
|
||||
applied = append(applied, row.ID)
|
||||
}
|
||||
if err := lagoon.DeferredForget(ctx, tx, applied); err != nil {
|
||||
return lifecycleFailure(cc, err)
|
||||
details := map[string]any{}
|
||||
for _, cf := range fields {
|
||||
if !cf.required && (!cf.relation.Many || cf.maxFiles == 0) {
|
||||
continue
|
||||
}
|
||||
var n int64
|
||||
err := tx.Session(&gorm.Session{NewDB: true, Context: ctx}).Model(&attach.File{}).
|
||||
Where("attachment_type = ? AND attachment_id = ? AND field = ?", morph, ownerID, cf.name).
|
||||
Count(&n).Error
|
||||
if err != nil {
|
||||
return lifecycleFailure(cc, err)
|
||||
}
|
||||
switch {
|
||||
case cf.required && n == 0:
|
||||
details[cf.name] = []string{fileMessage(ctx, s.tr, "required", cf.name, nil)}
|
||||
case cf.relation.Many && cf.maxFiles > 0 && n > int64(cf.maxFiles):
|
||||
details[cf.name] = []string{fileMessage(ctx, s.tr, "max.array", cf.name, map[string]string{"max": strconv.Itoa(cf.maxFiles)})}
|
||||
}
|
||||
}
|
||||
if len(details) > 0 {
|
||||
return &ValidationError{Details: details}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyFileBind attaches a pending upload to the owner. A row that is gone
|
||||
// or already attached somewhere is ignored.
|
||||
// or already attached somewhere is ignored. On an attachOne field the file
|
||||
// it replaces is deleted first (WinterCMS's AttachOne::add).
|
||||
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 {
|
||||
@@ -100,11 +137,56 @@ func (s CRUDService) applyFileBind(ctx context.Context, tx *gorm.DB, cf *compile
|
||||
if err != nil || f == nil || f.AttachmentID != "" || f.AttachmentType != "" {
|
||||
return err
|
||||
}
|
||||
return tx.Session(&gorm.Session{NewDB: true, Context: ctx}).Model(&attach.File{}).
|
||||
q := tx.Session(&gorm.Session{NewDB: true, Context: ctx})
|
||||
if !cf.relation.Many {
|
||||
var previous []attach.File
|
||||
err := q.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("attachment_type = ? AND attachment_id = ? AND field = ? AND id <> ?", morph, ownerID, cf.name, f.ID).
|
||||
Find(&previous).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, old := range previous {
|
||||
if err := s.deleteFile(ctx, tx, old); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return q.Model(&attach.File{}).
|
||||
Where("id = ?", f.ID).
|
||||
Updates(map[string]any{"attachment_type": morph, "attachment_id": ownerID, "field": cf.name}).Error
|
||||
}
|
||||
|
||||
// applyFileUnbind deletes a file attached to this owner and field; a file
|
||||
// that is not attached there is ignored.
|
||||
func (s CRUDService) applyFileUnbind(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
|
||||
}
|
||||
var f attach.File
|
||||
err = tx.Session(&gorm.Session{NewDB: true, Context: ctx}).
|
||||
Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("id = ? AND attachment_type = ? AND attachment_id = ? AND field = ?", id, morph, ownerID, cf.name).
|
||||
Take(&f).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.deleteFile(ctx, tx, f)
|
||||
}
|
||||
|
||||
// deleteFile deletes a file row now and its blobs after commit.
|
||||
func (s CRUDService) deleteFile(ctx context.Context, tx *gorm.DB, f attach.File) error {
|
||||
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, s.bucket, f)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
||||
@@ -2,6 +2,7 @@ package cabana
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -22,6 +23,7 @@ import (
|
||||
"git.golem15.com/golem15/summercms/modules/phrasebook"
|
||||
"github.com/goccy/go-yaml/ast"
|
||||
"gocloud.dev/blob"
|
||||
"gocloud.dev/gcerrors"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
@@ -734,6 +736,7 @@ func (s *service) writeFileError(w http.ResponseWriter, r *http.Request, cf *com
|
||||
ctx, tr := r.Context(), s.translator()
|
||||
var detail string
|
||||
switch {
|
||||
case cf == nil:
|
||||
case errors.Is(err, attach.ErrTooLarge):
|
||||
kb := strconv.FormatInt(cf.maxBytes/1024, 10)
|
||||
detail = fileMessage(ctx, tr, "max.file", cf.name, map[string]string{"max": kb})
|
||||
@@ -768,20 +771,13 @@ func (s *service) writeFileError(w http.ResponseWriter, r *http.Request, cf *com
|
||||
func logFileFailure(r *http.Request, err error) {
|
||||
var ve *ValidationError
|
||||
var missing recordNotFound
|
||||
var forbidden fileForbidden
|
||||
if errors.As(err, &ve) || errors.As(err, &missing) || errors.As(err, &forbidden) {
|
||||
if errors.As(err, &ve) || errors.As(err, &missing) {
|
||||
return
|
||||
}
|
||||
controller := r.PathValue("vendor") + "." + r.PathValue("plugin") + "." + r.PathValue("controller")
|
||||
slog.Default().ErrorContext(r.Context(), "cabana: file route failed", "controller", controller, "field", r.PathValue("field"), "error", err)
|
||||
}
|
||||
|
||||
// fileForbidden is a file operation the field does not declare (a caption
|
||||
// without useCaption, a reorder on attachOne): 403.
|
||||
type fileForbidden struct{}
|
||||
|
||||
func (fileForbidden) Error() string { return "cabana: file operation not declared" }
|
||||
|
||||
// bodyReader remembers the first read error of the request body other than
|
||||
// EOF, so a failed upload tells a malformed body from a storage failure.
|
||||
type bodyReader struct {
|
||||
@@ -860,3 +856,419 @@ func lockFile(ctx context.Context, tx *gorm.DB, id uint) (*attach.File, error) {
|
||||
}
|
||||
return &f, nil
|
||||
}
|
||||
|
||||
// AdminFileCaptionRequest is the body of the file caption route. A nil
|
||||
// field is left unchanged; unknown keys are refused.
|
||||
type AdminFileCaptionRequest struct {
|
||||
Title *string `json:"title,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
// pathFileID parses {file}; anything but a positive integer is not found.
|
||||
func pathFileID(r *http.Request) (uint, error) {
|
||||
n, err := strconv.ParseUint(strings.TrimSpace(r.PathValue("file")), 10, 64)
|
||||
if err != nil || n == 0 {
|
||||
return 0, recordNotFound{}
|
||||
}
|
||||
return uint(n), nil
|
||||
}
|
||||
|
||||
// findFile loads one file of the scope with a single parent-scoped query:
|
||||
// it must be attached to the scope's owner and field, or be a pending upload
|
||||
// bound to the scope's session key. Anything else, a file of another record
|
||||
// included, is recordNotFound (never 403).
|
||||
func (sc *fileScope) findFile(ctx context.Context, tx *gorm.DB, id uint, lock bool) (*attach.File, error) {
|
||||
fresh := func() *gorm.DB { return tx.Session(&gorm.Session{NewDB: true, Context: ctx}) }
|
||||
var group *gorm.DB
|
||||
if sc.ownerID > 0 {
|
||||
group = fresh().Where("attachment_type = ? AND attachment_id = ? AND field = ?", sc.morph, sc.ownerText(), sc.file.name)
|
||||
}
|
||||
if sc.hasKey {
|
||||
pending := "(attachment_id IS NULL OR attachment_id = '') AND CAST(id AS TEXT) IN (?)"
|
||||
slaves := lagoon.DeferredSlaves(tx, sc.key, sc.file.name, lagoon.DeferredFileType, true)
|
||||
if group == nil {
|
||||
group = fresh().Where(pending, slaves)
|
||||
} else {
|
||||
group = group.Or(pending, slaves)
|
||||
}
|
||||
}
|
||||
if group == nil {
|
||||
return nil, recordNotFound{}
|
||||
}
|
||||
q := fresh().Where("id = ?", id).Where(group)
|
||||
if lock {
|
||||
q = q.Clauses(clause.Locking{Strength: "UPDATE"})
|
||||
}
|
||||
var f attach.File
|
||||
err := q.Take(&f).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, recordNotFound{}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &f, nil
|
||||
}
|
||||
|
||||
// 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 {
|
||||
db, err := s.db()
|
||||
if err != nil {
|
||||
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
||||
return false
|
||||
}
|
||||
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
|
||||
}
|
||||
return fn(ctx, tx, sc)
|
||||
})
|
||||
if err != nil {
|
||||
s.writeFileError(w, r, cc.files[r.PathValue("field")], 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)
|
||||
if err == nil && !ok {
|
||||
err = &ValidationError{Details: map[string]any{"session_key": []string{"The session key field is required."}}}
|
||||
}
|
||||
if err != nil {
|
||||
writeCRUDError(w, err)
|
||||
return false
|
||||
}
|
||||
return 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) {
|
||||
keys := attach.BlobKeys(f)
|
||||
lagoon.AfterCommit(ctx, tx, func(ctx context.Context, _ *gorm.DB) {
|
||||
if bucket == nil {
|
||||
slog.Default().WarnContext(ctx, "cabana: no storage bucket; blobs of a deleted file were kept", "file_id", f.ID)
|
||||
return
|
||||
}
|
||||
if err := attach.DeleteKeys(ctx, bucket, keys); err != nil {
|
||||
slog.Default().WarnContext(ctx, "cabana: deleting a file's blobs failed", "file_id", f.ID, "error", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// fileRemove serves DELETE .../{id}/files/{field}/{file}: it defers the
|
||||
// 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)
|
||||
if err != nil {
|
||||
writeCRUDError(w, err)
|
||||
return
|
||||
}
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// jsonCap is the body cap of the JSON file routes: http.body_limits.
|
||||
// default_bytes, or 1 MiB when it is not configured.
|
||||
func (s *service) jsonCap() int64 {
|
||||
if s != nil && s.defaultBytes > 0 {
|
||||
return s.defaultBytes
|
||||
}
|
||||
return 1 << 20
|
||||
}
|
||||
|
||||
// decodeStrictBody decodes a capped JSON body into dest with unknown keys
|
||||
// and trailing data refused.
|
||||
func (s *service) decodeStrictBody(w http.ResponseWriter, r *http.Request, dest any) error {
|
||||
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, s.jsonCap()))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(dest); err != nil {
|
||||
var tooBig *http.MaxBytesError
|
||||
if errors.As(err, &tooBig) {
|
||||
return err
|
||||
}
|
||||
return invalidBody()
|
||||
}
|
||||
var trailing any
|
||||
if err := dec.Decode(&trailing); err != io.EOF {
|
||||
return invalidBody()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// fileUpdate serves PUT .../{id}/files/{field}/{file}: it saves a file's
|
||||
// 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)
|
||||
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, func(ctx context.Context, tx *gorm.DB, sc *fileScope) error {
|
||||
f, err := sc.findFile(ctx, tx, fileID, true)
|
||||
if 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
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, func(ctx context.Context, tx *gorm.DB, sc *fileScope) error {
|
||||
files, _, err := sc.visibleFiles(tx)
|
||||
if 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// fileDownload serves GET .../{id}/files/{field}/{file}/download.
|
||||
func (s *service) fileDownload(w http.ResponseWriter, r *http.Request) {
|
||||
s.serveProtectedFile(w, r, false)
|
||||
}
|
||||
|
||||
// fileThumb serves GET .../{id}/files/{field}/{file}/thumb.
|
||||
func (s *service) fileThumb(w http.ResponseWriter, r *http.Request) {
|
||||
s.serveProtectedFile(w, r, true)
|
||||
}
|
||||
|
||||
// serveProtectedFile streams a protected (is_public false) file or its
|
||||
// thumbnail (D-10). The file must belong to a record the admin may load
|
||||
// through FormExtendQuery, or be pending in the admin's own session; a
|
||||
// public file, a file of another record and a thumbnail of a non-image are
|
||||
// 404. Only JPEG, PNG, GIF and WebP are served inline; everything else is an
|
||||
// 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 {
|
||||
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, 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
|
||||
}
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
// rfc5987 percent-encodes a file name for a filename* parameter: only
|
||||
// RFC 5987 attr-char bytes stay literal.
|
||||
func rfc5987(name string) string {
|
||||
const hex = "0123456789ABCDEF"
|
||||
var b strings.Builder
|
||||
for i := 0; i < len(name); i++ {
|
||||
c := name[i]
|
||||
switch {
|
||||
case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9',
|
||||
strings.IndexByte("!#$&+-.^_`|~", c) >= 0:
|
||||
b.WriteByte(c)
|
||||
default:
|
||||
b.WriteByte('%')
|
||||
b.WriteByte(hex[c>>4])
|
||||
b.WriteByte(hex[c&15])
|
||||
}
|
||||
}
|
||||
if b.Len() == 0 {
|
||||
return "file"
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package cabana_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -9,6 +11,8 @@ import (
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func fileList(t *testing.T, rec *httptest.ResponseRecorder) []cabana.FileItem {
|
||||
@@ -116,3 +120,189 @@ func TestFileuploadSmokeForeignAdmin(t *testing.T) {
|
||||
t.Fatalf("owner's binding rows = %d, want 1", n)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *conformEnv) createGadget(t *testing.T, name, key string) uint {
|
||||
t.Helper()
|
||||
payload, _ := json.Marshal(map[string]any{"name": name})
|
||||
headers := map[string]string{}
|
||||
if key != "" {
|
||||
headers[cabana.SessionKeyHeader] = key
|
||||
}
|
||||
rec := e.sendWith(t, http.MethodPost, "/acme/conform/gadgets", payload, "application/json", headers)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("create status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
return dataID(t, rec.Body.Bytes())
|
||||
}
|
||||
|
||||
func (e *conformEnv) saveGadget(t *testing.T, id uint, name, key string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
payload, _ := json.Marshal(map[string]any{"name": name})
|
||||
return e.sendWith(t, http.MethodPut, fmt.Sprintf("/acme/conform/gadgets/%d", id), payload, "application/json", map[string]string{cabana.SessionKeyHeader: key})
|
||||
}
|
||||
|
||||
func (e *conformEnv) storedFile(t *testing.T, id uint) (attach.File, bool) {
|
||||
t.Helper()
|
||||
var f attach.File
|
||||
err := e.db.Where("id = ?", id).Take(&f).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return attach.File{}, false
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return f, true
|
||||
}
|
||||
|
||||
func (e *conformEnv) blobExists(t *testing.T, diskName string) bool {
|
||||
t.Helper()
|
||||
ok, err := e.bucket.Exists(context.Background(), attach.BlobKey(diskName))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
// TestFileuploadSmokeRemoveCancelsPending removes a pending upload: its
|
||||
// row is deleted and, after commit, its blob.
|
||||
func TestFileuploadSmokeRemoveCancelsPending(t *testing.T) {
|
||||
env := newConformEnv(t)
|
||||
env.loginAs(t, env.login)
|
||||
key := newSessionKey(t)
|
||||
up := env.upload(t, 0, "photos", "photo.png", conformPNG(t), key)
|
||||
if up.Code != http.StatusCreated {
|
||||
t.Fatalf("upload status=%d body=%s", up.Code, up.Body.String())
|
||||
}
|
||||
id := dataID(t, up.Body.Bytes())
|
||||
f, ok := env.storedFile(t, id)
|
||||
if !ok || !env.blobExists(t, f.DiskName) {
|
||||
t.Fatal("upload left no row or blob")
|
||||
}
|
||||
rec := env.sendWith(t, http.MethodDelete, fmt.Sprintf("/acme/conform/gadgets/0/files/photos/%d", id), nil, "", map[string]string{cabana.SessionKeyHeader: key})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("remove status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if _, ok := env.storedFile(t, id); ok {
|
||||
t.Fatal("cancelled upload row still exists")
|
||||
}
|
||||
if env.blobExists(t, f.DiskName) {
|
||||
t.Fatal("cancelled upload blob still exists")
|
||||
}
|
||||
if n := env.bindingCount(t, key); n != 0 {
|
||||
t.Fatalf("bindings after cancel = %d", n)
|
||||
}
|
||||
// The same file again is out of scope.
|
||||
again := env.sendWith(t, http.MethodDelete, fmt.Sprintf("/acme/conform/gadgets/0/files/photos/%d", id), nil, "", map[string]string{cabana.SessionKeyHeader: key})
|
||||
if again.Code != http.StatusNotFound {
|
||||
t.Fatalf("second remove status=%d", again.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileuploadSmokeAttachOneReplace saves a second file into an attachOne
|
||||
// field: the first file's row and blob are gone after the save, and a
|
||||
// deferred removal of an attached file applies on the next save.
|
||||
func TestFileuploadSmokeAttachOneReplace(t *testing.T) {
|
||||
env := newConformEnv(t)
|
||||
env.loginAs(t, env.login)
|
||||
gadget := env.createGadget(t, "replace-"+env.stamp, "")
|
||||
|
||||
first := newSessionKey(t)
|
||||
upA := env.upload(t, gadget, "manual", "a.txt", []byte("first manual\n"), first)
|
||||
if upA.Code != http.StatusCreated {
|
||||
t.Fatalf("upload a status=%d body=%s", upA.Code, upA.Body.String())
|
||||
}
|
||||
if rec := env.saveGadget(t, gadget, "replace-"+env.stamp, first); rec.Code != http.StatusOK {
|
||||
t.Fatalf("save a status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
a, _ := env.storedFile(t, dataID(t, upA.Body.Bytes()))
|
||||
if got := env.listFiles(t, gadget, "manual", ""); len(got) != 1 || got[0].ID != a.ID || got[0].URL != "" {
|
||||
t.Fatalf("after first save = %#v", got)
|
||||
}
|
||||
|
||||
second := newSessionKey(t)
|
||||
upB := env.upload(t, gadget, "manual", "b.txt", []byte("second manual\n"), second)
|
||||
if upB.Code != http.StatusCreated {
|
||||
t.Fatalf("upload b status=%d body=%s", upB.Code, upB.Body.String())
|
||||
}
|
||||
if rec := env.saveGadget(t, gadget, "replace-"+env.stamp, second); rec.Code != http.StatusOK {
|
||||
t.Fatalf("save b status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
got := env.listFiles(t, gadget, "manual", "")
|
||||
if len(got) != 1 || got[0].ID != dataID(t, upB.Body.Bytes()) {
|
||||
t.Fatalf("after replace = %#v", got)
|
||||
}
|
||||
if _, ok := env.storedFile(t, a.ID); ok {
|
||||
t.Fatal("replaced attachOne row still exists")
|
||||
}
|
||||
if env.blobExists(t, a.DiskName) {
|
||||
t.Fatal("replaced attachOne blob still exists")
|
||||
}
|
||||
|
||||
// A deferred removal hides the file in the session and deletes it on save.
|
||||
third := newSessionKey(t)
|
||||
b, _ := env.storedFile(t, got[0].ID)
|
||||
if rec := env.sendWith(t, http.MethodDelete, fmt.Sprintf("/acme/conform/gadgets/%d/files/manual/%d", gadget, b.ID), nil, "", map[string]string{cabana.SessionKeyHeader: third}); rec.Code != http.StatusOK {
|
||||
t.Fatalf("remove status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if len(env.listFiles(t, gadget, "manual", third)) != 0 || len(env.listFiles(t, gadget, "manual", "")) != 1 {
|
||||
t.Fatal("deferred removal is not scoped to its session")
|
||||
}
|
||||
if rec := env.saveGadget(t, gadget, "replace-"+env.stamp, third); rec.Code != http.StatusOK {
|
||||
t.Fatalf("save removal status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if _, ok := env.storedFile(t, b.ID); ok || env.blobExists(t, b.DiskName) {
|
||||
t.Fatal("deferred removal did not delete the file and its blob")
|
||||
}
|
||||
}
|
||||
|
||||
// TestProtectedFileSmoke downloads protected files through the admin route:
|
||||
// a file of another record is 404, a public file is 404, and an SVG stored
|
||||
// in file mode is an octet-stream attachment with nosniff.
|
||||
func TestProtectedFileSmoke(t *testing.T) {
|
||||
env := newConformEnv(t)
|
||||
env.loginAs(t, env.login)
|
||||
owner := env.createGadget(t, "owner-"+env.stamp, "")
|
||||
other := env.createGadget(t, "other-"+env.stamp, "")
|
||||
key := newSessionKey(t)
|
||||
svg := []byte(`<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>`)
|
||||
up := env.upload(t, owner, "manual", "logo one.svg", svg, key)
|
||||
if up.Code != http.StatusCreated {
|
||||
t.Fatalf("upload status=%d body=%s", up.Code, up.Body.String())
|
||||
}
|
||||
if rec := env.saveGadget(t, owner, "owner-"+env.stamp, key); rec.Code != http.StatusOK {
|
||||
t.Fatalf("save status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
fileID := dataID(t, up.Body.Bytes())
|
||||
|
||||
rec := env.sendWith(t, http.MethodGet, fmt.Sprintf("/acme/conform/gadgets/%d/files/manual/%d/download", owner, fileID), nil, "", nil)
|
||||
h := rec.Header()
|
||||
if rec.Code != http.StatusOK || h.Get("Content-Type") != "application/octet-stream" || h.Get("X-Content-Type-Options") != "nosniff" ||
|
||||
h.Get("Content-Disposition") != "attachment; filename*=UTF-8''logo%20one.svg" || h.Get("Cache-Control") != "private, no-store" ||
|
||||
h.Get("Content-Security-Policy") != "default-src 'none'; sandbox" || rec.Body.String() != string(svg) {
|
||||
t.Fatalf("svg download status=%d headers=%v body=%q", rec.Code, h, rec.Body.String())
|
||||
}
|
||||
if thumb := env.sendWith(t, http.MethodGet, fmt.Sprintf("/acme/conform/gadgets/%d/files/manual/%d/thumb", owner, fileID), nil, "", nil); thumb.Code != http.StatusNotFound {
|
||||
t.Fatalf("thumb of a non-image status=%d", thumb.Code)
|
||||
}
|
||||
for _, path := range []string{
|
||||
fmt.Sprintf("/acme/conform/gadgets/%d/files/manual/%d/download", other, fileID),
|
||||
fmt.Sprintf("/acme/conform/gadgets/%d/files/photos/%d/download", owner, fileID),
|
||||
fmt.Sprintf("/acme/conform/gadgets/0/files/manual/%d/download", fileID),
|
||||
} {
|
||||
if rec := env.sendWith(t, http.MethodGet, path, nil, "", map[string]string{cabana.SessionKeyHeader: key}); rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("%s status=%d, want 404", path, rec.Code)
|
||||
}
|
||||
}
|
||||
if rec := env.sendWith(t, http.MethodDelete, fmt.Sprintf("/acme/conform/gadgets/%d/files/manual/%d", other, fileID), nil, "", map[string]string{cabana.SessionKeyHeader: key}); rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("remove through another record status=%d", rec.Code)
|
||||
}
|
||||
|
||||
// A public file is never served by the protected route.
|
||||
pub := env.upload(t, owner, "photos", "photo.png", conformPNG(t), key)
|
||||
if pub.Code != http.StatusCreated {
|
||||
t.Fatalf("public upload status=%d body=%s", pub.Code, pub.Body.String())
|
||||
}
|
||||
if rec := env.sendWith(t, http.MethodGet, fmt.Sprintf("/acme/conform/gadgets/%d/files/photos/%d/download", owner, dataID(t, pub.Body.Bytes())), nil, "", map[string]string{cabana.SessionKeyHeader: key}); rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("public file through the protected route status=%d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,6 +279,16 @@ func (s *service) mount(r pact.Router) {
|
||||
// record being created in the X-Session-Key session.
|
||||
g.Post("/{vendor}/{plugin}/{controller}/{id}/files/{field}", requireAjax(s.fileUpload))
|
||||
constrainFile(g)
|
||||
g.Post("/{vendor}/{plugin}/{controller}/{id}/files/{field}/reorder", requireAjax(s.fileReorder))
|
||||
constrainFile(g)
|
||||
g.Put("/{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}", requireAjax(s.fileUpdate))
|
||||
constrainFileID(g)
|
||||
g.Delete("/{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}", requireAjax(s.fileRemove))
|
||||
constrainFileID(g)
|
||||
g.Get("/{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}/download", s.fileDownload)
|
||||
constrainFileID(g)
|
||||
g.Get("/{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}/thumb", s.fileThumb)
|
||||
constrainFileID(g)
|
||||
})
|
||||
// The SPA shell: public, no guard. ServeMux prefers every API pattern
|
||||
// above over the {path...} wildcard.
|
||||
@@ -309,6 +319,11 @@ func constrainFile(g pact.Router) {
|
||||
g.Where("field", "[A-Za-z_][A-Za-z0-9_]*")
|
||||
}
|
||||
|
||||
func constrainFileID(g pact.Router) {
|
||||
constrainFile(g)
|
||||
g.Where("file", "[0-9]+")
|
||||
}
|
||||
|
||||
func constrainNested(g pact.Router) {
|
||||
constrainController(g)
|
||||
g.Where("segment", "[A-Za-z_][A-Za-z0-9_]*")
|
||||
@@ -787,7 +802,7 @@ func (s *service) crud() (CRUDService, error) {
|
||||
if err != nil {
|
||||
return CRUDService{}, err
|
||||
}
|
||||
return CRUDService{DB: db}, nil
|
||||
return CRUDService{DB: db, bucket: s.bucket(), tr: s.translator()}, nil
|
||||
}
|
||||
|
||||
func (s *service) list(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -89,6 +89,7 @@ func TestPhase10Coverage(t *testing.T) {
|
||||
sort.Strings(unsafe)
|
||||
want := []string{
|
||||
"DELETE /{vendor}/{plugin}/{controller}/{id}",
|
||||
"DELETE /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}",
|
||||
"POST /auth/login",
|
||||
"POST /auth/logout",
|
||||
"POST /auth/refresh",
|
||||
@@ -97,13 +98,15 @@ func TestPhase10Coverage(t *testing.T) {
|
||||
"POST /{vendor}/{plugin}/{controller}/toolbar/{action}",
|
||||
"POST /{vendor}/{plugin}/{controller}/widgets/{field}",
|
||||
"POST /{vendor}/{plugin}/{controller}/{id}/files/{field}",
|
||||
"POST /{vendor}/{plugin}/{controller}/{id}/files/{field}/reorder",
|
||||
"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/link",
|
||||
"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink",
|
||||
"PUT /settings/{code}",
|
||||
"PUT /{vendor}/{plugin}/{controller}/{id}",
|
||||
"PUT /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}",
|
||||
}
|
||||
if strings.Join(unsafe, "\n") != strings.Join(want, "\n") {
|
||||
t.Fatalf("unsafe routes changed; extend TestPhase10CSRF (it expects 12 besides login):\n%s", strings.Join(unsafe, "\n"))
|
||||
t.Fatalf("unsafe routes changed; extend TestPhase10CSRF (it expects 15 besides login):\n%s", strings.Join(unsafe, "\n"))
|
||||
}
|
||||
// The routes added in Phase 10 are safe reads: GET /lang and the shared
|
||||
// nested pattern serving field options, filter options and relation lists.
|
||||
|
||||
@@ -67,9 +67,10 @@ func TestPhase10CSRF(t *testing.T) {
|
||||
})
|
||||
}
|
||||
// refresh, logout, settings put, create, bulk-delete, widget action,
|
||||
// toolbar action, update, delete, link, unlink, file upload
|
||||
if unsafe != 12 {
|
||||
t.Fatalf("walked %d state-changing routes, want 12: %v", unsafe, router.order)
|
||||
// toolbar action, update, delete, link, unlink, file upload, file
|
||||
// reorder, file caption, file remove
|
||||
if unsafe != 15 {
|
||||
t.Fatalf("walked %d state-changing routes, want 15: %v", unsafe, router.order)
|
||||
}
|
||||
|
||||
loginHandler := router.handlers[login]
|
||||
|
||||
@@ -64,6 +64,11 @@ var phase09Routes = []adminRoute{
|
||||
{key: "POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink"},
|
||||
{key: "GET /{vendor}/{plugin}/{controller}/{id}/files/{field}", mounted: nestedGetRoute},
|
||||
{key: "POST /{vendor}/{plugin}/{controller}/{id}/files/{field}"},
|
||||
{key: "POST /{vendor}/{plugin}/{controller}/{id}/files/{field}/reorder"},
|
||||
{key: "PUT /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}"},
|
||||
{key: "DELETE /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}"},
|
||||
{key: "GET /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}/download"},
|
||||
{key: "GET /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}/thumb"},
|
||||
{key: "GET /assets/{vendor}/{plugin}/{file...}", public: true, spa: true},
|
||||
{key: "GET ", public: true, spa: true},
|
||||
{key: "GET /{path...}", public: true, spa: true},
|
||||
@@ -290,6 +295,11 @@ func phase09ProtectedCalls() []phase09Call {
|
||||
{"relation-unlink", (*service).relationUnlink},
|
||||
{"file-list", (*service).fileList},
|
||||
{"file-upload", (*service).fileUpload},
|
||||
{"file-reorder", (*service).fileReorder},
|
||||
{"file-update", (*service).fileUpdate},
|
||||
{"file-remove", (*service).fileRemove},
|
||||
{"file-download", (*service).fileDownload},
|
||||
{"file-thumb", (*service).fileThumb},
|
||||
{"settings-schema", (*service).settingsSchema},
|
||||
{"settings-get", (*service).settingsGet},
|
||||
{"settings-put", (*service).settingsPut},
|
||||
|
||||
Reference in New Issue
Block a user