Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f513bede8 | ||
|
|
b6b48bc94a |
@@ -238,7 +238,7 @@ variables set by the file's `env:` section are checked the same way.
|
||||
| `PIXA_UPSTREAM_FETCH_TIMEOUT` | `upstream_fetch_timeout` | Time allowed for one fetch from an upstream host; default `30s` |
|
||||
| `PIXA_UPSTREAM_MAX_RESPONSE_SIZE` | `upstream_max_response_size` | Largest upstream response accepted, in bytes; default 50 MiB |
|
||||
| `PIXA_DOWNSTREAM_TIMEOUT` | `downstream_timeout` | Time allowed for answering one client request; default `60s` |
|
||||
| `PIXA_ACCESS_CONTROL_ALLOW_ORIGIN` | `access_control_allow_origin` | CORS origin allowed to read image responses: `*` or one origin; default `*` |
|
||||
| `PIXA_ACCESS_CONTROL_ALLOW_ORIGIN` | `access_control_allow_origin` | CORS origin allowed to read responses: `*` or one origin; default `*` |
|
||||
| `PIXA_METRICS_USERNAME` | `metrics.username` | Username for `/metrics`, which is served only when both are set |
|
||||
| `PIXA_METRICS_PASSWORD` | `metrics.password` | Password for `/metrics`; set together with the username |
|
||||
| `PIXA_SENTRY_DSN` | `sentry_dsn` | Sentry DSN for error reporting; empty disables it |
|
||||
@@ -247,9 +247,8 @@ variables set by the file's `env:` section are checked the same way.
|
||||
|
||||
Key settings in more detail:
|
||||
|
||||
- `access_control_allow_origin` — the origin a browser lets read the responses
|
||||
of the image routes, `/v1/image/` and `/v1/e/`, sent as the CORS
|
||||
`Access-Control-Allow-Origin` header; no other route sends it. `*`, the
|
||||
- `access_control_allow_origin` — the origin a browser lets read pixa's
|
||||
responses, sent as the CORS `Access-Control-Allow-Origin` header: `*`, the
|
||||
default, is any site; otherwise one `http` or `https` origin such as
|
||||
`https://example.com`, whose host is a lowercase host name (letters,
|
||||
digits, hyphens and dots, with a letter in its last part) or an IP address
|
||||
|
||||
@@ -31,21 +31,15 @@ P2: security: referer blacklist
|
||||
|
||||
- 2026-10-03 shutdown sets the exit code and waits for image processing
|
||||
(closes #86): fx alone handles SIGINT and SIGTERM, and the server's own
|
||||
signal handler is gone; fx's `Run` in `cmd/pixad` exits with the shutdown's
|
||||
code: 0 for a signal, 1 when the HTTP server cannot listen or the app fails
|
||||
to start or to stop; the server's stop hook, which fx waits for, stops the
|
||||
HTTP server, waits for the images still being processed, both within 5
|
||||
seconds, then flushes Sentry; images still being processed after that are
|
||||
logged with their count and make the exit code 1; a Sentry DSN that cannot be
|
||||
used fails startup, so the stop hooks of what had already started run,
|
||||
instead of exiting the process from a goroutine; the eviction loop is left to
|
||||
#102.
|
||||
- 2026-09-29 only the image routes send CORS headers (closes #98): the CORS
|
||||
middleware, with the `access_control_allow_origin` origin, moved from the
|
||||
router root onto a `/v1` subrouter holding `/v1/image/` and `/v1/e/`, where it
|
||||
still answers a preflight `OPTIONS` request; the login and URL generator
|
||||
pages, `/metrics` and the other routes send no `Access-Control-Allow-Origin`;
|
||||
documented in `README.md` and `config.example.yml`.
|
||||
signal handler is gone; `cmd/pixad` starts the fx app, waits for a signal or
|
||||
a shutdown request, stops the app and exits with its code: 0 for a signal, 1
|
||||
when the HTTP server cannot listen or the app fails to start or to stop; the
|
||||
server's stop hook, which fx waits for, stops the HTTP server, waits for the
|
||||
images still being processed, both within 5 seconds, then flushes Sentry;
|
||||
images still being processed after that are logged with their count and make
|
||||
the exit code 1; a Sentry DSN that cannot be used fails startup, so the stop
|
||||
hooks of what had already started run, instead of exiting the process from a
|
||||
goroutine; the eviction loop is left to #102.
|
||||
- 2026-10-02 a plain `docker build .` stamps the tag or short commit, not
|
||||
`dev` (closes #166): `.dockerignore` lets `.git` into the build context,
|
||||
without `.git/config`; with no `VERSION` build argument the `Dockerfile`
|
||||
|
||||
+34
-2
@@ -2,6 +2,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
@@ -50,7 +51,7 @@ func run(_ *cobra.Command, _ []string) {
|
||||
// A write to a closed stdout or stderr must not end the process.
|
||||
signal.Ignore(syscall.SIGPIPE)
|
||||
|
||||
fx.New(
|
||||
app := fx.New(
|
||||
fx.Provide(
|
||||
config.New,
|
||||
database.New,
|
||||
@@ -65,5 +66,36 @@ func run(_ *cobra.Command, _ []string) {
|
||||
func(log *logger.Logger) { log.Identify() },
|
||||
func(*server.Server) {},
|
||||
),
|
||||
).Run()
|
||||
)
|
||||
|
||||
os.Exit(runApp(app))
|
||||
}
|
||||
|
||||
// runApp starts app, waits for SIGINT, SIGTERM or a shutdown request, then
|
||||
// stops app. It returns the exit code: the one the shutdown request carries,
|
||||
// 0 for a signal, or 1 when app fails to start or to stop; fx logs that
|
||||
// error itself. It does what fx's App.Run does, but returns the exit code
|
||||
// instead of exiting.
|
||||
func runApp(app *fx.App) int {
|
||||
startCtx, cancelStart := context.WithTimeout(
|
||||
context.Background(), app.StartTimeout())
|
||||
defer cancelStart()
|
||||
|
||||
err := app.Start(startCtx)
|
||||
if err != nil {
|
||||
return 1
|
||||
}
|
||||
|
||||
shutdown := <-app.Wait()
|
||||
|
||||
stopCtx, cancelStop := context.WithTimeout(
|
||||
context.Background(), app.StopTimeout())
|
||||
defer cancelStop()
|
||||
|
||||
err = app.Stop(stopCtx)
|
||||
if err != nil {
|
||||
return 1
|
||||
}
|
||||
|
||||
return shutdown.ExitCode
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/fx"
|
||||
)
|
||||
|
||||
// errTestHook is the error returned by the test hooks that fail.
|
||||
var errTestHook = errors.New("test hook failed")
|
||||
|
||||
// TestRunAppExitCode checks the exit code runApp returns: the one a
|
||||
// shutdown request carries, 0 for a request without one (as for SIGINT or
|
||||
// SIGTERM), and 1 when the app fails to start or to stop.
|
||||
func TestRunAppExitCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
hook func(shutdowner fx.Shutdowner) fx.Hook
|
||||
want int
|
||||
}{
|
||||
{
|
||||
name: "shutdown requested with exit code 1",
|
||||
hook: func(shutdowner fx.Shutdowner) fx.Hook {
|
||||
return fx.StartHook(func() error {
|
||||
return shutdowner.Shutdown(fx.ExitCode(1))
|
||||
})
|
||||
},
|
||||
want: 1,
|
||||
},
|
||||
{
|
||||
name: "shutdown requested without an exit code",
|
||||
hook: func(shutdowner fx.Shutdowner) fx.Hook {
|
||||
return fx.StartHook(func() error {
|
||||
return shutdowner.Shutdown()
|
||||
})
|
||||
},
|
||||
want: 0,
|
||||
},
|
||||
{
|
||||
name: "start fails",
|
||||
hook: func(fx.Shutdowner) fx.Hook {
|
||||
return fx.StartHook(func() error { return errTestHook })
|
||||
},
|
||||
want: 1,
|
||||
},
|
||||
{
|
||||
name: "stop fails",
|
||||
hook: func(shutdowner fx.Shutdowner) fx.Hook {
|
||||
return fx.StartStopHook(
|
||||
func() error { return shutdowner.Shutdown() },
|
||||
func() error { return errTestHook },
|
||||
)
|
||||
},
|
||||
want: 1,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app := fx.New(
|
||||
fx.NopLogger,
|
||||
fx.Invoke(func(lc fx.Lifecycle, shutdowner fx.Shutdowner) {
|
||||
lc.Append(tc.hook(shutdowner))
|
||||
}),
|
||||
)
|
||||
|
||||
got := runApp(app)
|
||||
t.Logf("runApp() = %d", got)
|
||||
|
||||
if got != tc.want {
|
||||
t.Errorf("runApp() = %d, want %d", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+2
-3
@@ -103,9 +103,8 @@ upstream_max_response_size: 52428800
|
||||
# longer than upstream_fetch_timeout plus 20 seconds.
|
||||
downstream_timeout: 60s
|
||||
|
||||
# The origin a browser lets read the responses of the image routes,
|
||||
# /v1/image/ and /v1/e/, sent as the CORS Access-Control-Allow-Origin
|
||||
# header; no other route sends it. "*" (the default) is any site;
|
||||
# The origin a browser lets read pixa's responses, sent as the CORS
|
||||
# Access-Control-Allow-Origin header: "*" (the default) is any site;
|
||||
# otherwise one http or https origin such as https://example.com, whose
|
||||
# host is a lowercase host name (letters, digits, hyphens and dots, with a
|
||||
# letter in its last part) or an IP address (IPv6 in brackets, in its
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestCORSOnlyOnImageRoutes verifies that the image routes answer with the
|
||||
// configured access_control_allow_origin, a preflight request included, and
|
||||
// that the login and URL generator pages send no Access-Control-Allow-Origin,
|
||||
// so no other site can read them. /metrics is left out: its middleware
|
||||
// registers with the process-wide Prometheus registry, which only one test
|
||||
// in this package can do.
|
||||
func TestCORSOnlyOnImageRoutes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const appOrigin = "https://app.example.com"
|
||||
|
||||
s := newTestServer(t)
|
||||
s.config.AccessControlAllowOrigin = appOrigin
|
||||
s.SetupRoutes()
|
||||
|
||||
requests := []struct {
|
||||
method string
|
||||
path string
|
||||
want string
|
||||
}{
|
||||
{http.MethodGet, unsignedImagePath, appOrigin},
|
||||
{http.MethodHead, unsignedImagePath, appOrigin},
|
||||
{http.MethodOptions, unsignedImagePath, appOrigin},
|
||||
{http.MethodGet, encryptedImagePath, appOrigin},
|
||||
{http.MethodGet, "/", ""},
|
||||
{http.MethodOptions, "/", ""},
|
||||
{http.MethodPost, "/generate", ""},
|
||||
{http.MethodGet, "/logout", ""},
|
||||
}
|
||||
|
||||
for _, tc := range requests {
|
||||
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req := httptest.NewRequestWithContext(
|
||||
t.Context(), tc.method, tc.path, nil)
|
||||
req.Header.Set("Origin", appOrigin)
|
||||
|
||||
// An OPTIONS request naming the method it asks about is the
|
||||
// preflight a browser sends before some cross-origin requests.
|
||||
if tc.method == http.MethodOptions {
|
||||
req.Header.Set("Access-Control-Request-Method", http.MethodGet)
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
s.ServeHTTP(rec, req)
|
||||
t.Logf("status %d", rec.Code)
|
||||
|
||||
got := rec.Header().Get("Access-Control-Allow-Origin")
|
||||
if got != tc.want {
|
||||
t.Errorf("Access-Control-Allow-Origin = %q, want %q",
|
||||
got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -13,10 +13,6 @@ import (
|
||||
// unsignedImagePath is an image URL that carries no signature.
|
||||
const unsignedImagePath = "/v1/image/cdn.example.com/cat.jpg/100x100.jpeg"
|
||||
|
||||
// encryptedImagePath is an encrypted image URL whose token cannot be
|
||||
// decrypted.
|
||||
const encryptedImagePath = "/v1/e/token/cat.jpg"
|
||||
|
||||
// TestMaintenanceModeRefusesImageRequests verifies that while maintenance
|
||||
// mode is on, both image routes answer 503 Service Unavailable with a
|
||||
// Retry-After header and the JSON error body the image handlers send.
|
||||
@@ -32,7 +28,7 @@ func TestMaintenanceModeRefusesImageRequests(t *testing.T) {
|
||||
}{
|
||||
{http.MethodGet, unsignedImagePath},
|
||||
{http.MethodHead, unsignedImagePath},
|
||||
{http.MethodGet, encryptedImagePath},
|
||||
{http.MethodGet, "/v1/e/token/cat.jpg"},
|
||||
}
|
||||
|
||||
for _, tc := range requests {
|
||||
@@ -99,7 +95,7 @@ func TestImageRequestsServedWithoutMaintenanceMode(t *testing.T) {
|
||||
}{
|
||||
{http.MethodGet, unsignedImagePath, http.StatusUnauthorized},
|
||||
{http.MethodHead, unsignedImagePath, http.StatusUnauthorized},
|
||||
{http.MethodGet, encryptedImagePath, http.StatusBadRequest},
|
||||
{http.MethodGet, "/v1/e/token/cat.jpg", http.StatusBadRequest},
|
||||
}
|
||||
|
||||
for _, tc := range requests {
|
||||
|
||||
+15
-23
@@ -38,6 +38,7 @@ func (s *Server) SetupRoutes() {
|
||||
s.router.Use(s.mw.Metrics())
|
||||
}
|
||||
|
||||
s.router.Use(s.mw.CORS())
|
||||
s.router.Use(middleware.Timeout(s.config.DownstreamTimeout))
|
||||
|
||||
if s.sentryEnabled {
|
||||
@@ -73,31 +74,22 @@ func (s *Server) SetupRoutes() {
|
||||
|
||||
s.router.Get("/logout", s.h.HandleLogout())
|
||||
|
||||
// Image routes, the only ones that send CORS headers, as pages on other
|
||||
// sites read them. They are a subrouter rather than a group: a group's
|
||||
// middleware runs only for a request that matches one of its routes,
|
||||
// and a browser's preflight OPTIONS request matches none, so the CORS
|
||||
// middleware could not answer it.
|
||||
s.router.Route("/v1", func(r chi.Router) {
|
||||
r.Use(s.mw.CORS())
|
||||
// Image routes, refused while maintenance mode is on. Only these: the
|
||||
// image's Docker HEALTHCHECK requests the health check, a 503 there
|
||||
// would make the container unhealthy, and upaas marks a deploy failed
|
||||
// when its container is unhealthy.
|
||||
s.router.Group(func(r chi.Router) {
|
||||
r.Use(s.refuseDuringMaintenance)
|
||||
|
||||
// Refused while maintenance mode is on. Only these: the image's
|
||||
// Docker HEALTHCHECK requests the health check, a 503 there would
|
||||
// make the container unhealthy, and upaas marks a deploy failed
|
||||
// when its container is unhealthy.
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(s.refuseDuringMaintenance)
|
||||
// Main image proxy route
|
||||
// /v1/image/<host>/<path>/<width>x<height>.<format>
|
||||
r.Get("/v1/image/*", s.h.HandleImage())
|
||||
r.Head("/v1/image/*", s.h.HandleImage())
|
||||
|
||||
// Main image proxy route
|
||||
// /v1/image/<host>/<path>/<width>x<height>.<format>
|
||||
r.Get("/image/*", s.h.HandleImage())
|
||||
r.Head("/image/*", s.h.HandleImage())
|
||||
|
||||
// Encrypted image URL route
|
||||
// The trailing filename (e.g., /img.jpg) is ignored but helps
|
||||
// browsers with content type
|
||||
r.Get("/e/{token}/*", s.h.HandleImageEnc())
|
||||
})
|
||||
// Encrypted image URL route
|
||||
// The trailing filename (e.g., /img.jpg) is ignored but helps
|
||||
// browsers with content type
|
||||
r.Get("/v1/e/{token}/*", s.h.HandleImageEnc())
|
||||
})
|
||||
|
||||
// Metrics endpoint with auth
|
||||
|
||||
Reference in New Issue
Block a user