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:
Jakub Zych
2026-10-02 18:11:56 +02:00
parent 044e0450ef
commit e54fd257ee
17 changed files with 2237 additions and 82 deletions

View File

@@ -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)
}
}