From 0b6d757b40535704e83bc585c5148f25cfdd86d3 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Sun, 11 Oct 2026 17:36:54 +0200 Subject: [PATCH] feat(14.2-01): opt-in storage.uploads.serve route for public uploads - surf mounts GET {storage.uploads.public_path_prefix}/{path...} on attach.StaticHandlerPublic when storage.uploads.serve is true - bucket and database resolved per request; X-Content-Type-Options: nosniff - surf README, storage and configuration docs describe the key --- docs/services/storage.md | 14 +++++++- docs/setup/configuration.md | 1 + modules/surf/README.md | 2 ++ modules/surf/router.go | 3 ++ modules/surf/uploads.go | 64 +++++++++++++++++++++++++++++++++++++ 5 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 modules/surf/uploads.go diff --git a/docs/services/storage.md b/docs/services/storage.md index 44951a9..bbaa29f 100644 --- a/docs/services/storage.md +++ b/docs/services/storage.md @@ -41,7 +41,19 @@ Application code gets the bucket with `app.Lookup[*blob.Bucket]()` and reads and ## Serving files -The framework does not mount a file route by itself. The application decides where files are served: mount `attach.StaticHandlerPublic` under `public_path_prefix` to serve originals and thumbnails and answer 404 for files whose row is not public, or put a web server or CDN in front of the bucket directory. Serve uploads from a separate origin when you can; the [Attachments](../database/attachments.md) page explains why. +The framework mounts no file route unless you ask for one. Set `storage.uploads.serve` to `true` (environment `SUMMER_STORAGE__UPLOADS__SERVE=true`, default `false`) and the router registers `GET /{path...}` on `attach.StaticHandlerPublic`: + +```yaml +storage: + uploads: + bucket_url: file://./storage/app/uploads/public + public_path_prefix: /storage/app/uploads/public + serve: true +``` + +The route serves originals and thumbnails and answers 404 for a missing file, an invalid path and a file whose `system_files` row is not public (`is_public` false). Every response carries `X-Content-Type-Options: nosniff`, so a browser does not sniff a stored file into HTML or script. The route looks up the bucket and the database on each request: `route:list` lists it without opening either, and it answers 404 until `serve` has published them. A plugin route with the same pattern fails boot as a duplicate. + +Without the key, the application decides where files are served: mount `attach.StaticHandlerPublic` itself, or put a web server or CDN in front of the bucket directory. Either way, serve uploads from a separate origin when you can; the [Attachments](../database/attachments.md) page explains why. The built-in route is meant for development and for deployments that cannot split the origin yet. ## Upload size diff --git a/docs/setup/configuration.md b/docs/setup/configuration.md index 30b97ae..8b2b41b 100644 --- a/docs/setup/configuration.md +++ b/docs/setup/configuration.md @@ -54,6 +54,7 @@ These are the keys most applications set. Each module's reference page lists all | `http.body_limits.default_bytes`, `http.body_limits.upload_bytes` | Request body limits. Required by `serve` and `route:list`. | [surf](../../modules/surf/README.md) | | `http.cors.*`, `http.trusted_proxies` | CORS and the proxies whose forwarded client IP is trusted. | [surf](../../modules/surf/README.md) | | `storage.uploads.bucket_url` | Uploads bucket, `file://` or `mem://`. Required by `serve`. | [lagoon](../../modules/lagoon/README.md) | +| `storage.uploads.serve` | Mounts `attach.StaticHandlerPublic` under `storage.uploads.public_path_prefix` with `X-Content-Type-Options: nosniff`. Defaults to `false`. | [surf](../../modules/surf/README.md) | | `admin.jwt.secret` | Secret for admin tokens. Required as soon as a plugin registers an admin controller. | [cabana](../../modules/cabana/README.md) | | `backend.uri` | Path the admin is served under. Defaults to `/backend`. | [cabana](../../modules/cabana/README.md) | | `mail.driver`, `mail.from`, `mail.smtp.*` | Mail delivery: `memory`, `log` or `smtp`. | [postcard](../../modules/postcard/README.md) | diff --git a/modules/surf/README.md b/modules/surf/README.md index a8e0d21..492d61a 100644 --- a/modules/surf/README.md +++ b/modules/surf/README.md @@ -34,6 +34,7 @@ Use cases: - Path-scoped CORS configured with the same keys as Laravel's `config/cors.php` (`surf.CORSConfig`), including preflight handling. Every `OPTIONS` request on a CORS path is answered with 204 before routing, with the headers Laravel's `HandleCors` sends: `Cache-Control: no-cache, private` always and, on a preflight (an `Origin` and an `Access-Control-Request-Method`), the requested method (upper-cased) and headers echoed in `Access-Control-Allow-Methods` and `Access-Control-Allow-Headers` when `*` allows any, `Vary` on the request headers and PHP's default `Content-Type: text/html; charset=UTF-8`. - Optional `surf.LocaleResolver`: a compiled plugin publishes the interface through backpack; surf looks it up without importing the plugin. When present, it may strip an enabled locale prefix before routing and writes a validated code with `towel.WithLocale`. When absent, hosts keep the raw `Accept-Language` header and `surf.LocaleFromPrincipal` overlay. - `surf.LocaleFromPrincipal` switches the request locale to the signed-in user's preferred locale. +- Opt-in uploads route: with `storage.uploads.serve` true, `surf.BuildRouter` mounts `GET /{path...}` (owned by `summercms.surf`) on `attach.StaticHandlerPublic`, so a file is served only when its `system_files` row is public, and every response carries `X-Content-Type-Options: nosniff`. The bucket and the database are looked up per request, so `route:list` lists the route without opening either; until `serve` publishes them it answers 404. A plugin route on the same pattern fails boot as a duplicate. - A read-only route table (`surf.Router.Routes`), the admin mount status (`surf.Router.AdminPrefix`) and the `serve` and `route:list` commands. ## Usage @@ -154,6 +155,7 @@ The generated application `main` wires surf in with `surf.ServeCommand` and `sur | `http.cors.exposed_headers` | empty | Headers sent in `Access-Control-Expose-Headers`. | | `http.cors.max_age` | `0` | Preflight cache time in seconds. | | `http.cors.supports_credentials` | `false` | Sends `Access-Control-Allow-Credentials: true`. | +| `storage.uploads.serve` | `false` | Mounts the uploads route `GET /{path...}` (prefix default `/storage/uploads`) on `attach.StaticHandlerPublic` with `X-Content-Type-Options: nosniff`. Prefer a separate origin for uploads in production. Environment: `SUMMER_STORAGE__UPLOADS__SERVE`. | ```yaml http: diff --git a/modules/surf/router.go b/modules/surf/router.go index 2e1706e..cbf2b3d 100644 --- a/modules/surf/router.go +++ b/modules/surf/router.go @@ -545,6 +545,9 @@ func BuildRouter(app *backpack.App, plugins []party.Plugin) (*Router, error) { } } } + if err := mountUploads(r, app); err != nil { + return nil, err + } admin, err := cabana.Activate(app, plugins) if err != nil { return nil, err diff --git a/modules/surf/uploads.go b/modules/surf/uploads.go new file mode 100644 index 0000000..59494a0 --- /dev/null +++ b/modules/surf/uploads.go @@ -0,0 +1,64 @@ +package surf + +import ( + "net/http" + "strings" + + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/lagoon/attach" + "git.golem15.com/golem15/summercms/modules/pact" + "gocloud.dev/blob" + "gorm.io/gorm" +) + +// uploadsPluginID owns the framework uploads route in the route table. +const uploadsPluginID = "summercms.surf" + +// defaultUploadsPrefix matches the prefix attach.OpenBucket records when +// storage.uploads.public_path_prefix is empty. +const defaultUploadsPrefix = "/storage/uploads" + +// mountUploads registers GET {storage.uploads.public_path_prefix}/{path...} +// when storage.uploads.serve is true. The handler delegates to +// attach.StaticHandlerPublic, so missing rows and is_public=false rows 404, +// and adds X-Content-Type-Options: nosniff because the files are served on +// the app origin. The bucket and the database are looked up per request: +// boot and route:list build the router before serve publishes either. +// With the key false or absent nothing is registered. +func mountUploads(r *Router, app *backpack.App) error { + if r == nil || app == nil || app.Config == nil { + return nil + } + if !app.Config.Bool("storage.uploads.serve") { + return nil + } + prefix := strings.TrimSuffix(strings.TrimSpace(app.Config.String("storage.uploads.public_path_prefix")), "/") + if prefix == "" { + prefix = defaultUploadsPrefix + } + handler := uploadsHandler(app, prefix) + r.BindPlugin(uploadsPluginID) + r.GroupRaw(prefix, nil, func(g pact.Router) { + g.Get("/{path...}", handler) + }) + return nil +} + +// uploadsHandler resolves the published bucket and database on every request +// and answers 404 while either is missing. +func uploadsHandler(app *backpack.App, prefix string) http.HandlerFunc { + return func(w http.ResponseWriter, req *http.Request) { + w.Header().Set("X-Content-Type-Options", "nosniff") + bucket, ok := app.Lookup[*blob.Bucket]() + if !ok || bucket == nil { + http.NotFound(w, req) + return + } + gdb, ok := app.Lookup[*gorm.DB]() + if !ok || gdb == nil { + http.NotFound(w, req) + return + } + attach.StaticHandlerPublic(bucket, prefix, gdb).ServeHTTP(w, req) + } +}