fix(cabana): serve public image thumbnails on the admin thumb routes

The record and relation-child thumb routes now also serve the thumbnail
of a public JPEG, PNG, GIF or WebP file, under the same record or
pending-session scope and D-10 headers as a protected one, so the admin
can preview a public image whose public URL does not load. The download
routes still answer 404 for every public file, and a public non-image
thumb stays 404.

TestPublicImageThumb covers owner, foreign and hidden records, image
types, a public row under a protected field, another admin's pending
upload and the relation-child route.
This commit is contained in:
2026-10-20 01:05:42 +02:00
parent 5978998ea6
commit e4302bf60b
7 changed files with 128 additions and 32 deletions

View File

@@ -7252,7 +7252,7 @@
},
"/{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}/thumb": {
"get": {
"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.",
"description": "The preview thumbnail (imageWidth by imageHeight, 240 by 240 by default, in thumbOptions.mode) of a JPEG, PNG, GIF or WebP file of the field, protected or public, scoped like the download route (a record the admin may load, or an upload pending in the admin's own session) and sent with the download route's headers. The admin SPA uses it for protected images and for a public image whose public URL does not load. Any other file type is 404.",
"parameters": [
{
"description": "Vendor",
@@ -7375,7 +7375,7 @@
"BackendBearer": []
}
],
"summary": "Thumbnail of a protected image",
"summary": "Thumbnail of an image file",
"tags": [
"admin"
]
@@ -9672,7 +9672,7 @@
},
"/{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/{file}/thumb": {
"get": {
"description": "As the record thumb route, for a protected image of the related record or of the child session.",
"description": "As the record thumb route, for an image file, protected or public, of the related record or of the child session.",
"parameters": [
{
"description": "Vendor",
@@ -9821,7 +9821,7 @@
"BackendBearer": []
}
],
"summary": "Thumbnail of a related record's protected image",
"summary": "Thumbnail of a related record's image file",
"tags": [
"admin"
]

View File

@@ -3641,8 +3641,8 @@ export interface paths {
cookie?: never;
};
/**
* 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.
* Thumbnail of an image file
* @description The preview thumbnail (imageWidth by imageHeight, 240 by 240 by default, in thumbOptions.mode) of a JPEG, PNG, GIF or WebP file of the field, protected or public, scoped like the download route (a record the admin may load, or an upload pending in the admin's own session) and sent with the download route's headers. The admin SPA uses it for protected images and for a public image whose public URL does not load. Any other file type is 404.
*/
get: {
parameters: {
@@ -5178,8 +5178,8 @@ export interface paths {
cookie?: never;
};
/**
* Thumbnail of a related record's protected image
* @description As the record thumb route, for a protected image of the related record or of the child session.
* Thumbnail of a related record's image file
* @description As the record thumb route, for an image file, protected or public, of the related record or of the child session.
*/
get: {
parameters: {

View File

@@ -112,9 +112,9 @@ Public and protected files live in the same bucket. A protected file (`is_public
A `type: fileupload` field on a protected relation never shows a public URL in the admin; see [Forms](../backend/forms.md#file-uploads). The admin SPA reads such a file through two authenticated admin API routes under the `backend` guard:
- GET `{prefix}/api/v1/{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}/download` streams the original.
- GET `{prefix}/api/v1/{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}/thumb` streams the preview thumbnail, built with `attach.File.ThumbKey`; a file that is not a JPEG, PNG, GIF or WebP image has none.
- GET `{prefix}/api/v1/{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}/thumb` streams the preview thumbnail, built with `attach.File.ThumbKey`; a file that is not a JPEG, PNG, GIF or WebP image has none. The thumb route also serves the thumbnail of a public image, with the same scope and headers, so the admin can preview a public image whose public URL does not load; see [Forms](../backend/forms.md#file-uploads).
Both routes look the file up with one query scoped to its record, which the controller's `pact.FormExtendQuery` must let the administrator load, or to an upload pending in the administrator's own form session (`X-Session-Key`). A file of another record, a public file and a file outside that scope all answer 404. Every response carries `X-Content-Type-Options: nosniff`, `Cache-Control: private, no-store` and `Content-Security-Policy: default-src 'none'; sandbox`. Only JPEG, PNG, GIF and WebP are served inline with their own type; any other file, an SVG included, is sent as an `application/octet-stream` attachment, so a stored file never runs script in the admin's origin.
Both routes look the file up with one query scoped to its record, which the controller's `pact.FormExtendQuery` must let the administrator load, or to an upload pending in the administrator's own form session (`X-Session-Key`). A file of another record and a file outside that scope answer 404 on both routes, and a public file answers 404 on the download route. Every response carries `X-Content-Type-Options: nosniff`, `Cache-Control: private, no-store` and `Content-Security-Policy: default-src 'none'; sandbox`. Only JPEG, PNG, GIF and WebP are served inline with their own type; any other file, an SVG included, is sent as an `application/octet-stream` attachment, so a stored file never runs script in the admin's origin.
## Deleting files after commit

View File

@@ -110,7 +110,7 @@ Guarded routes run surf's `locale.from-principal` after the backend guard: an in
| 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. |
| GET `.../{id}/files/{field}/{file}/download`, GET `.../{id}/files/{field}/{file}/thumb` | Stream a protected file, or the preview thumbnail of any image file of the field; see the protected files note below. |
Every relation route accepts `{id}` 0 for the record being created when the relation is deferrable (a belongsToMany, or a hasMany with a nullable foreign key), the request carries `X-Session-Key` and the controller declares create: children created, linked, unlinked and deleted there are held against the key and applied by the record's first save, in its transaction, and an ineligible link answers 422 on the `relation-manager` field. A deferrable relation that declares `create` needs its related model in some plugin's `pact.HasModels` list, so `deferred:purge` can delete abandoned children; boot fails otherwise. Existing belongsToMany managers without `context: update` therefore appear on create screens.
@@ -118,7 +118,7 @@ Every relation child and pivot route loads the record through `pact.FormExtendQu
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.
Protected files (a relation with `Public` false) never get a public URL. The download route serves 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 serves the thumbnail of a protected or public JPEG, PNG, GIF or WebP file under the same scope and headers, so the admin previews a public image even when its public URL does not load; it answers 404 for any other type.
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.

View File

@@ -1298,10 +1298,10 @@ func AdminRelationChildFileReorder() {}
func AdminRelationChildFileDownload() {}
// AdminRelationChildFileThumb documents the thumbnail of a related
// record's protected image.
// record's image file, protected or public.
//
// @Summary Thumbnail of a related record's protected image
// @Description As the record thumb route, for a protected image of the related record or of the child session.
// @Summary Thumbnail of a related record's image file
// @Description As the record thumb route, for an image file, protected or public, of the related record or of the child session.
// @Tags admin
// @Produce octet-stream
// @Security BackendBearer
@@ -1490,10 +1490,11 @@ func AdminFileReorder() {}
// @Router /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}/download [get]
func AdminFileDownload() {}
// AdminFileThumb documents the thumbnail of a protected image.
// AdminFileThumb documents the thumbnail of an image file, protected or
// public.
//
// @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.
// @Summary Thumbnail of an image file
// @Description The preview thumbnail (imageWidth by imageHeight, 240 by 240 by default, in thumbOptions.mode) of a JPEG, PNG, GIF or WebP file of the field, protected or public, scoped like the download route (a record the admin may load, or an upload pending in the admin's own session) and sent with the download route's headers. The admin SPA uses it for protected images and for a public image whose public URL does not load. Any other file type is 404.
// @Tags admin
// @Produce octet-stream
// @Security BackendBearer

View File

@@ -1374,18 +1374,24 @@ func (s *service) fileDownload(w http.ResponseWriter, r *http.Request) {
s.serveProtectedFile(w, r, false)
}
// fileThumb serves GET .../{id}/files/{field}/{file}/thumb.
// fileThumb serves GET .../{id}/files/{field}/{file}/thumb: the preview
// thumbnail of an image file of the field, protected or public.
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
// serveProtectedFile streams a 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 file of another record is 404.
// The download route streams protected (is_public false) files only: a
// public file is 404 there. The thumb route serves the thumbnail of any
// JPEG, PNG, GIF or WebP file of the field, protected or public, under the
// same scope, so the admin SPA can preview a public image whose public URL
// does not load; a thumbnail of any other type is 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.
// and no-store, under a sandboxing CSP. relationChildFileDownload and
// relationChildFileThumb share this contract through serveProtectedFileOn.
func (s *service) serveProtectedFile(w http.ResponseWriter, r *http.Request, thumb bool) {
s.protect(w, r, func(cc *CompiledController) { s.serveProtectedFileOn(w, r, cc, parentFiles(cc), thumb) })
}
@@ -1413,7 +1419,7 @@ func (s *service) serveProtectedFileOn(w http.ResponseWriter, r *http.Request, c
if err != nil {
return err
}
if found.Public() {
if found.Public() && !thumb {
return recordNotFound{}
}
f = found

View File

@@ -59,9 +59,11 @@ func manualPath(gadget, file uint, rest string) string {
}
// TestProtectedFileScope: the protected download and thumb routes answer
// 404 for another gadget's file, for another admin's pending file on id 0
// and for any is_public=true row; the caption, remove and reorder routes
// answer 404 for another gadget's file id and change nothing (D-10, D-15).
// 404 for another gadget's file and for another admin's pending file on
// id 0; the download route (not the thumb route, see TestPublicImageThumb)
// answers 404 for any is_public=true row; the caption, remove and reorder
// routes answer 404 for another gadget's file id and change nothing (D-10,
// D-15).
func TestProtectedFileScope(t *testing.T) {
env := newDeferredEnv(t)
g1 := env.gadget(t, "g1-"+env.stamp, false)
@@ -97,13 +99,12 @@ func TestProtectedFileScope(t *testing.T) {
t.Run("public rows", func(t *testing.T) {
// A public photo, and a public row attached under the protected
// field, are never served by the protected routes.
// field, are never streamed by the download route (their thumbs are
// covered by TestPublicImageThumb).
mixed := env.storeFile(t, dfGadgetMorph, g2, "manual", "public.png", conformPNG(t), true)
for _, path := range []string{
dfPath(g2, fmt.Sprintf("/files/photos/%d/download", p2.ID)),
dfPath(g2, fmt.Sprintf("/files/photos/%d/thumb", p2.ID)),
manualPath(g2, mixed.ID, "/download"),
manualPath(g2, mixed.ID, "/thumb"),
} {
want(t, path, env.a.do(t, http.MethodGet, path, nil, nil), http.StatusNotFound)
}
@@ -128,6 +129,94 @@ func TestProtectedFileScope(t *testing.T) {
})
}
// TestPublicImageThumb: the admin thumb routes (record and relation child)
// also serve the thumbnail of a public JPEG, PNG, GIF or WebP file, under
// the same scope and D-10 headers as a protected one, so the admin can
// preview a public image whose public URL does not load. The download
// routes still refuse every public file.
func TestPublicImageThumb(t *testing.T) {
env := newDeferredEnv(t)
g := env.gadget(t, "g-"+env.stamp, false)
photoPath := func(gadget, file uint, rest string) string {
return dfPath(gadget, fmt.Sprintf("/files/photos/%d%s", file, rest))
}
imageThumb := func(t *testing.T, what string, rec *httptest.ResponseRecorder) {
t.Helper()
want(t, what, rec, http.StatusOK)
securityHeaders(t, what, rec)
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "image/") {
t.Fatalf("%s content type %q", what, ct)
}
if rec.Body.Len() == 0 {
t.Fatalf("%s empty body", what)
}
}
pub := env.storeFile(t, dfGadgetMorph, g, "photos", "pub.png", conformPNG(t), true)
t.Run("owner", func(t *testing.T) {
imageThumb(t, "owner thumb", env.a.do(t, http.MethodGet, photoPath(g, pub.ID, "/thumb"), nil, nil))
want(t, "owner download", env.a.do(t, http.MethodGet, photoPath(g, pub.ID, "/download"), nil, nil), http.StatusNotFound)
})
t.Run("foreign and hidden records", func(t *testing.T) {
g2 := env.gadget(t, "g2-"+env.stamp, false)
hidden := env.gadget(t, "hidden-"+env.stamp, true)
p2 := env.storeFile(t, dfGadgetMorph, g2, "photos", "g2.png", conformPNG(t), true)
ph := env.storeFile(t, dfGadgetMorph, hidden, "photos", "hidden.png", conformPNG(t), true)
before := env.state(t)
want(t, "G2's public photo through G", env.a.do(t, http.MethodGet, photoPath(g, p2.ID, "/thumb"), nil, nil), http.StatusNotFound)
want(t, "hidden gadget's public photo", env.a.do(t, http.MethodGet, photoPath(hidden, ph.ID, "/thumb"), nil, nil), http.StatusNotFound)
env.unchanged(t, "foreign public thumbs", before)
})
t.Run("image types", func(t *testing.T) {
svg := []byte(`<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>`)
txt := env.storeFile(t, dfGadgetMorph, g, "photos", "notes.txt", []byte("plain text\n"), true)
logo := env.storeFile(t, dfGadgetMorph, g, "photos", "logo.svg", svg, true)
before := env.state(t)
for _, f := range []uint{txt.ID, logo.ID} {
want(t, fmt.Sprintf("public non-image %d thumb", f), env.a.do(t, http.MethodGet, photoPath(g, f, "/thumb"), nil, nil), http.StatusNotFound)
}
env.unchanged(t, "public non-image thumbs", before)
for name, data := range map[string][]byte{"anim.gif": dfGIF(t), "pic.webp": dfWebP, "photo.jpg": dfJPEG(t)} {
f := env.storeFile(t, dfGadgetMorph, g, "photos", name, data, true)
imageThumb(t, name+" thumb", env.a.do(t, http.MethodGet, photoPath(g, f.ID, "/thumb"), nil, nil))
}
})
t.Run("public row under the protected field", func(t *testing.T) {
mixed := env.storeFile(t, dfGadgetMorph, g, "manual", "public.png", conformPNG(t), true)
imageThumb(t, "mixed thumb", env.a.do(t, http.MethodGet, manualPath(g, mixed.ID, "/thumb"), nil, nil))
want(t, "mixed download", env.a.do(t, http.MethodGet, manualPath(g, mixed.ID, "/download"), nil, nil), http.StatusNotFound)
})
t.Run("pending upload of another admin", func(t *testing.T) {
key := newSessionKey(t)
up := env.a.upload(t, dfPath(0, "/files/photos"), "pending.png", conformPNG(t), sk(key))
want(t, "A uploads a public photo to id 0", up, http.StatusCreated)
id := dataID(t, up.Body.Bytes())
imageThumb(t, "A's pending photo thumb", env.a.do(t, http.MethodGet, photoPath(0, id, "/thumb"), nil, sk(key)))
before := env.state(t)
want(t, "B's thumb of A's pending photo", env.b.do(t, http.MethodGet, photoPath(0, id, "/thumb"), nil, sk(key)), http.StatusNotFound)
want(t, "A's pending photo download", env.a.do(t, http.MethodGet, photoPath(0, id, "/download"), nil, sk(key)), http.StatusNotFound)
env.unchanged(t, "B's thumb with A's key", before)
})
t.Run("relation child", func(t *testing.T) {
g2 := env.gadget(t, "g2c-"+env.stamp, false)
p := env.part(t, g, "p-pub")
f := env.storeFile(t, dfPartMorph, p, "images", "part.png", conformPNG(t), true)
childPath := func(gadget uint, rest string) string {
return dfPath(gadget, fmt.Sprintf("/relations/parts/records/%d/files/images/%d%s", p, f.ID, rest))
}
imageThumb(t, "child thumb", env.a.do(t, http.MethodGet, childPath(g, "/thumb"), nil, ck(newSessionKey(t))))
before := env.state(t)
want(t, "child download", env.a.do(t, http.MethodGet, childPath(g, "/download"), nil, ck(newSessionKey(t))), http.StatusNotFound)
want(t, "child thumb through G2", env.a.do(t, http.MethodGet, childPath(g2, "/thumb"), nil, ck(newSessionKey(t))), http.StatusNotFound)
env.unchanged(t, "refused child file requests", before)
})
}
// TestProtectedFileHeaders: only jpeg, png, gif and webp are served inline
// with their own type; SVG, HTML and text download as an octet-stream
// attachment. Every response carries nosniff, private no-store and the