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
This commit is contained in:
@@ -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 <storage.uploads.public_path_prefix>/{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 <storage.uploads.public_path_prefix>/{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:
|
||||
|
||||
@@ -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
|
||||
|
||||
64
modules/surf/uploads.go
Normal file
64
modules/surf/uploads.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user