6 Commits
Author SHA1 Message Date
sneak 1d2411dd2e Log out with a form and count logouts instead of timing them (part of #256)
check / check (push) Canceled after 0s
Logging out is now a POST in the CSRF-protected route group, sent by a
form on the generator page, so a link or image on another page of the
same site cannot end every login session. The session manager keeps a
count of logouts instead of the time of the latest one: each session
carries the count at its login and is refused once it has changed. No
clock is compared, so a login after a logout always works.

Model: opus-5-5
2026-10-15 14:09:16 +00:00
sneak fe3a32c63d Test that logging out needs the form's CSRF token (part of #256)
check / check (push) Canceled after 0s
A GET /logout, or a POST /logout without the CSRF token, must end no
login session; the generator page must carry a logout form with the
token; and after a logout from that form, a login opens the generator
page while a cookie copied before it is refused, also after a restart.

Model: opus-5-5
2026-10-15 13:55:25 +00:00
sneak d1482bf1eb Say that losing the state directory forgets the latest logout (part of #256)
The state directory bullet in README.md and the session package doc now
say that the time of the latest logout is kept in the database, which a
restart without a persistent volume loses.

Model: opus-5-5
2026-10-15 13:49:36 +00:00
sneak 2ab18c04c5 Test a logout after a restart and one that cannot be saved (part of #256)
The new server test logs in and out on pixa built as pixad builds it,
starts pixa again on the same state directory, and checks that a cookie
copied before the logout is refused, which fails if pixad keeps logouts
in memory only. The new handler test closes the database so the logout
cannot be saved, and checks for 500, no session cookie set, and the
session still valid.

Model: opus-5-5
2026-10-15 13:49:36 +00:00
sneak 51de9ee8ed Logging out ends every login session (closes #256)
A login session was a cookie with nothing kept on the server, so /logout
removed it from one browser and a copy kept working for 30 days. Every
login is made with the one signing key, so /logout now ends them all:
the session manager keeps the time of the latest logout, saves it in the
new logout table so it outlasts a restart, and refuses a session made
before it. A /logout without a valid session ends none and only removes
the cookie, so another site cannot log everyone out. A logout that
cannot be saved answers 500. session.NewManager, which existing tests
use, keeps logouts in memory only; pixad uses NewManagerWithDatabase.

Model: opus-5-5
2026-10-15 13:49:36 +00:00
sneak 865da99229 Test that logging out ends a copied login session (part of #256)
A copy of the session cookie taken before /logout must be refused after
it, a login after it must work, and both must still hold for a session
manager made again on the same database, as after a restart. A logout
from a request without a session cookie must end no session. These fail
until the fix: the session tests do not compile yet.

Model: opus-5-5
2026-10-15 13:49:36 +00:00
5 changed files with 7 additions and 216 deletions

No files matched your search

+3 -10
View File
@@ -124,12 +124,8 @@ What the [upaas](https://git.eeqj.de/sneak/upaas) app for pixa needs:
comma-separated; default none. Every image on a listed host is served to
anyone without a signature, so list only hosts you control, where nobody
else can put images.
- `PIXA_CACHE_MAX_BYTES`: disk cache limit in bytes; `0` disables it. Set
it: the volume is a directory on the host's filesystem, shared with the
host and every other app. The default is 75% of the sum of that
filesystem's free space and what the cache holds, and at least 500 MiB, so
the cache can grow to three quarters or more of the space they all have
left, and to all of it when that is 500 MiB or less.
- `PIXA_CACHE_MAX_BYTES`: disk cache limit in bytes; `0` disables it;
default 75% of (free space + what the cache holds)
- the rest are in the table under Configuration below
- **Health check:** the image's `HEALTHCHECK` requests
`/.well-known/healthcheck.json`. upaas reads the container's health 60 seconds
@@ -656,10 +652,7 @@ See `configs/config.example.yml` for all options with defaults.
- **Metrics**: Prometheus, at `/metrics`: generic HTTP request metrics
(duration, response size, requests in flight) and the Go runtime and process
metrics; requests are measured and `/metrics` is served only when
`metrics.username` and `metrics.password` are set. A request's `handler` label
is the pattern of the route it matches, such as `/v1/image/*`, never its path.
Requests that match no route share the label `unmatched`: those the router
answers 404 or 405, and CORS preflights
`metrics.username` and `metrics.password` are set
- **Logging**: stdlib slog
## Entrypoints
-16
View File
@@ -30,22 +30,6 @@ P2: security: per-origin rate limiting
# Completed Steps
- 2026-10-15 "Running under upaas" in `README.md` tells the operator to set
`PIXA_CACHE_MAX_BYTES` (closes #251): it listed the variable as optional, but
under upaas the volume is a directory on the host's filesystem. The default,
75% of the sum of that filesystem's free space and what the cache holds, and
at least 500 MiB, let one pixa take three quarters or more of the space the
host and every other app had left, and all of it when that was 500 MiB or
less. The section now says so. The default is unchanged.
- 2026-10-15 the request metrics are labelled by route, not by path (closes
#254): their `handler` label was each request's path, so every new path, a 404
or an encrypted URL, added series to the Prometheus registry that were never
removed, and `/metrics` listed every encrypted URL's token. The label is now
the pattern of the chi route the request matches, such as `/v1/image/*`, or
`unmatched` for a request that matches none: one the router answers 404 or
405, or a CORS preflight. The metrics library takes the label before the
request is routed, so the metrics middleware asks the router which route
matches.
- 2026-10-15 logging out ends every login session (closes #256): a login session
was a cookie with nothing kept on the server, so `/logout` removed it from one
browser and a copy of it kept working for its 30 days. Every login is made
+3 -58
View File
@@ -8,15 +8,12 @@ import (
"net/http"
"net/netip"
"regexp"
"strings"
"time"
basicauth "github.com/99designs/basicauth-go"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
"github.com/go-chi/httprate"
"github.com/prometheus/client_golang/prometheus"
metrics "github.com/slok/go-http-metrics/metrics/prometheus"
ghmm "github.com/slok/go-http-metrics/middleware"
"github.com/slok/go-http-metrics/middleware/std"
@@ -66,10 +63,6 @@ type Middleware struct {
log *slog.Logger
config *config.Config
clientIP *clientip.Resolver
// metricsRegistry is the registry Metrics registers its metrics with.
// nil is Prometheus' default registry, the one /metrics serves.
metricsRegistry prometheus.Registerer
}
// New creates a new Middleware instance.
@@ -216,65 +209,17 @@ func (s *Middleware) CORS() func(http.Handler) http.Handler {
})
}
// Metrics returns a Prometheus metrics middleware. Its handler label is the
// pattern of the chi route a request matches, such as /v1/image/*, or
// "unmatched" for a request that matches none, so the routes fix how many
// values the label has. It is never the path, with which any client could
// add series to the registry without limit.
// Metrics returns a Prometheus metrics middleware.
func (s *Middleware) Metrics() func(http.Handler) http.Handler {
mdlw := ghmm.New(ghmm.Config{
Recorder: metrics.NewRecorder(metrics.Config{
Registry: s.metricsRegistry,
}),
Recorder: metrics.NewRecorder(metrics.Config{}),
})
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
std.Handler(routePattern(r), mdlw, next).ServeHTTP(w, r)
})
return std.Handler("", mdlw, next)
}
}
// routePattern returns the pattern of the chi route r matches, or
// "unmatched" when it matches none or is not served by a chi router. The
// metrics library takes the label before the request is routed, so the
// router is asked which route matches, with the path it routes on:
// r.URL.RawPath when set, else r.URL.Path, else /.
func routePattern(r *http.Request) string {
pattern := ""
rctx := chi.RouteContext(r.Context())
if rctx != nil {
path := r.URL.RawPath
if path == "" {
path = r.URL.Path
}
if path == "" {
path = "/"
}
pattern = rctx.Routes.Find(chi.NewRouteContext(), r.Method, path)
// For the path a subrouter is mounted at, such as /v1 or /v1/, Find
// answers that path without asking the subrouter, which chi hands
// the request as /.
for _, route := range rctx.Routes.Routes() {
if route.SubRoutes != nil &&
route.Pattern == strings.TrimSuffix(pattern, "/")+"/*" &&
!route.SubRoutes.Match(chi.NewRouteContext(), r.Method, "/") {
pattern = ""
}
}
}
if pattern == "" {
return "unmatched"
}
return pattern
}
// MetricsAuth returns a basic auth middleware for the metrics endpoint.
func (s *Middleware) MetricsAuth() func(http.Handler) http.Handler {
return basicauth.New(
@@ -3,15 +3,12 @@ package middleware
import (
"bytes"
"log/slog"
"maps"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/go-chi/chi/v5"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
"sneak.berlin/go/pixa/internal/config"
@@ -201,7 +198,7 @@ func TestMetricsRecordsServedRequest(t *testing.T) {
// The line /metrics shows once one GET /test has been served.
const want = `http_request_duration_seconds_count{` +
`code="200",handler="unmatched",method="GET",service=""} 1`
`code="200",handler="/test",method="GET",service=""} 1`
mw := &Middleware{log: slog.Default(), config: &config.Config{}}
@@ -223,97 +220,6 @@ func TestMetricsRecordsServedRequest(t *testing.T) {
}
}
// TestMetricsLabelsRequestsByRoute checks that the metrics middleware labels
// each request with the pattern of the route that answers it, never with its
// path: two paths one route answers share that route's pattern, and requests
// no route answers, a 404 or a 405, share "unmatched", among them requests for
// the path the subrouter is mounted at, /v1 and /v1/. A request with an empty
// path is answered by the / route, as chi routes it as /, and is labelled /.
// With a label per path, any client could add series to the registry without
// limit. The middleware registers with a registry of its own here, as another
// test in this package registers with Prometheus' default registry, which can
// be done only once.
func TestMetricsLabelsRequestsByRoute(t *testing.T) {
t.Parallel()
registry := prometheus.NewRegistry()
mw := &Middleware{
log: slog.Default(),
config: &config.Config{},
metricsRegistry: registry,
}
// The image route is in a subrouter, as in pixa's router.
router := chi.NewRouter()
router.Use(mw.Metrics())
router.Get("/", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})
router.Route("/v1", func(r chi.Router) {
r.Get("/image/*", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})
})
for _, req := range []struct{ method, path string }{
{http.MethodGet, "/v1/image/a.example.com/one.jpg/1x1.png"},
{http.MethodGet, "/v1/image/b.example.com/two.jpg/1x1.png"},
{http.MethodGet, "/no-such-route"},
{http.MethodGet, "/v1/no-such-route"},
{http.MethodGet, "/v1"},
{http.MethodGet, "/v1/"},
{http.MethodPost, "/v1/image/a.example.com/one.jpg/1x1.png"},
// The request line GET http://pixa.example HTTP/1.1 leaves both
// r.URL.Path and r.URL.RawPath empty.
{http.MethodGet, "http://pixa.example"},
} {
r := httptest.NewRequestWithContext(
t.Context(), req.method, req.path, nil)
t.Logf("%s %s: r.URL.Path %q, r.URL.RawPath %q",
req.method, req.path, r.URL.Path, r.URL.RawPath)
router.ServeHTTP(httptest.NewRecorder(), r)
}
got := requestCountsByHandler(t, registry)
t.Logf("requests counted by handler label: %v", got)
want := map[string]uint64{"/": 1, "/v1/image/*": 2, "unmatched": 5}
if !maps.Equal(got, want) {
t.Errorf("requests counted by handler label = %v, want %v", got, want)
}
}
// requestCountsByHandler returns how many requests the request duration
// histogram in registry counted under each value of its handler label.
func requestCountsByHandler(
t *testing.T, registry *prometheus.Registry,
) map[string]uint64 {
t.Helper()
families, err := registry.Gather()
if err != nil {
t.Fatalf("registry.Gather() error = %v", err)
}
counts := map[string]uint64{}
for _, family := range families {
if family.GetName() != "http_request_duration_seconds" {
continue
}
for _, metric := range family.GetMetric() {
for _, label := range metric.GetLabel() {
if label.GetName() == "handler" {
counts[label.GetValue()] += metric.GetHistogram().GetSampleCount()
}
}
}
}
return counts
}
// TestLoggingLeavesOutSubmittedSigningKey checks that a login, a POST /
// whose form carries the signing key, leaves no trace of the key in the
// request's log line.
-37
View File
@@ -4,7 +4,6 @@ import (
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"regexp"
"strings"
"testing"
@@ -231,39 +230,3 @@ func TestLogoutOutlastsRestart(t *testing.T) {
"still opens the generator page")
}
}
// TestLostStateDirectoryEndsEveryLoginSession verifies that a pixa started on
// an empty state directory, as pixad is after a restart that lost its state
// directory, refuses a session cookie from before, and that a login on it
// opens the generator page.
func TestLostStateDirectoryEndsEveryLoginSession(t *testing.T) {
t.Parallel()
s := newTestServer(t)
before := logIn(t, s)
if !opensGeneratorPage(t, s, before) {
t.Fatal("logging in did not open the generator page")
}
emptyStateDir := *s.config
emptyStateDir.StateDir = t.TempDir()
emptyStateDir.DBURL = "file:" +
filepath.Join(emptyStateDir.StateDir, "state.sqlite3")
restarted := startPixa(t, &emptyStateDir)
opens := opensGeneratorPage(t, restarted, before)
t.Logf("on an empty state directory, the cookie from before opens the "+
"generator page: %v", opens)
if opens {
t.Error("on an empty state directory, a cookie from before still " +
"opens the generator page")
}
if !opensGeneratorPage(t, restarted, logIn(t, restarted)) {
t.Error("a login on an empty state directory does not open the " +
"generator page")
}
}