Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
157bfbdd33 | ||
|
|
3a2eb1f4d2 |
+5
-1
@@ -1,4 +1,8 @@
|
||||
.git
|
||||
# .git is sent without its config. Without a VERSION build argument the
|
||||
# stage that compiles runs `git describe --tags --always` on .git, which
|
||||
# does not need .git/config; that file can hold a credential, such as a
|
||||
# password in a remote URL or the token the CI checkout step stores there.
|
||||
.git/config
|
||||
.gitignore
|
||||
.DS_Store
|
||||
.env*
|
||||
|
||||
+12
-20
@@ -112,15 +112,13 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
Appname string = "CHANGEME"
|
||||
Version string
|
||||
Buildarch string
|
||||
Appname string = "CHANGEME"
|
||||
Version string
|
||||
)
|
||||
|
||||
func main() {
|
||||
globals.Appname = Appname
|
||||
globals.Version = Version
|
||||
globals.Buildarch = Buildarch
|
||||
|
||||
fx.New(
|
||||
fx.Provide(
|
||||
@@ -859,7 +857,7 @@ func (l *Logger) Identify() {
|
||||
l.log.Info("starting",
|
||||
"appname", l.params.Globals.Appname,
|
||||
"version", l.params.Globals.Version,
|
||||
"buildarch", l.params.Globals.Buildarch,
|
||||
"arch", runtime.GOARCH,
|
||||
)
|
||||
}
|
||||
```
|
||||
@@ -979,23 +977,20 @@ import "go.uber.org/fx"
|
||||
|
||||
// Package-level variables (set from main)
|
||||
var (
|
||||
Appname string
|
||||
Version string
|
||||
Buildarch string
|
||||
Appname string
|
||||
Version string
|
||||
)
|
||||
|
||||
// Struct for DI
|
||||
type Globals struct {
|
||||
Appname string
|
||||
Version string
|
||||
Buildarch string
|
||||
Appname string
|
||||
Version string
|
||||
}
|
||||
|
||||
func New(lc fx.Lifecycle) (*Globals, error) {
|
||||
n := &Globals{
|
||||
Appname: Appname,
|
||||
Buildarch: Buildarch,
|
||||
Version: Version,
|
||||
Appname: Appname,
|
||||
Version: Version,
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
@@ -1006,15 +1001,13 @@ func New(lc fx.Lifecycle) (*Globals, error) {
|
||||
```go
|
||||
// cmd/httpd/main.go
|
||||
var (
|
||||
Appname string = "CHANGEME" // Default, overridden by build
|
||||
Version string // Set at build time
|
||||
Buildarch string // Set at build time
|
||||
Appname string = "CHANGEME" // Default, overridden by build
|
||||
Version string // Set at build time
|
||||
)
|
||||
|
||||
func main() {
|
||||
globals.Appname = Appname
|
||||
globals.Version = Version
|
||||
globals.Buildarch = Buildarch
|
||||
// ...
|
||||
}
|
||||
```
|
||||
@@ -1025,10 +1018,9 @@ Use ldflags to inject version information at build time:
|
||||
|
||||
```makefile
|
||||
VERSION := $(shell git describe --tags --always)
|
||||
BUILDARCH := $(shell go env GOARCH)
|
||||
|
||||
build:
|
||||
go build -ldflags "-X main.Version=$(VERSION) -X main.Buildarch=$(BUILDARCH)" ./cmd/httpd
|
||||
go build -ldflags "-X main.Version=$(VERSION)" ./cmd/httpd
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
+18
-7
@@ -43,13 +43,24 @@ COPY . .
|
||||
RUN make test
|
||||
|
||||
# VERSION is declared here, not earlier: a new value reruns only the
|
||||
# build, not script/bootstrap or the tests. CGO stays enabled for
|
||||
# govips; -trimpath keeps build paths out of the binary, and -s -w
|
||||
# leave out the symbol table and debug information.
|
||||
ARG VERSION=dev
|
||||
RUN CGO_ENABLED=1 GOTOOLCHAIN=auto go build -trimpath \
|
||||
-ldflags "-s -w -X main.Version=${VERSION}" \
|
||||
-o /pixad ./cmd/pixad
|
||||
# build, not script/bootstrap or the tests. Given none, the version is
|
||||
# `git describe --tags --always` of the .git in the build context (git
|
||||
# comes from script/bootstrap): the tag on a tagged commit, tag-N-gHASH
|
||||
# after one, the short commit when no tag is reachable. A context that
|
||||
# carries .git and still yields no version fails the build; one without
|
||||
# .git, as from a source tarball, stamps an empty version. CGO stays
|
||||
# enabled for govips; -trimpath keeps build paths out of the binary, and
|
||||
# -s -w leave out the symbol table and debug information.
|
||||
ARG VERSION
|
||||
RUN version="${VERSION:-$(git describe --tags --always)}"; \
|
||||
if [ -e .git ] && { [ -z "$version" ] || [ "$version" = dev ] || \
|
||||
[ "$version" = unknown ]; }; then \
|
||||
echo "the build context carries .git but yields no version" >&2; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
CGO_ENABLED=1 GOTOOLCHAIN=auto go build -trimpath \
|
||||
-ldflags "-s -w -X main.Version=${version}" \
|
||||
-o /pixad ./cmd/pixad
|
||||
|
||||
# Runtime stage
|
||||
# alpine:3.21, 2026-02-25
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -29,12 +29,13 @@ P2: security: referer blacklist
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- 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`.
|
||||
- 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`
|
||||
takes the version from `git describe --tags --always`, and fails the build if
|
||||
the context carries `.git` and no version comes out; `ARG VERSION` has no
|
||||
default; pixad logs its version, with its name and architecture, as its first
|
||||
log line at startup.
|
||||
- 2026-09-29 the container makes `/var/lib/pixa` usable by itself (closes
|
||||
#159): `deploy/docker-entrypoint.sh` creates the directory if it is missing,
|
||||
gives the directory and everything in it to `pixad` when the directory or one
|
||||
|
||||
+4
-1
@@ -56,6 +56,9 @@ func run(_ *cobra.Command, _ []string) {
|
||||
middleware.New,
|
||||
healthcheck.New,
|
||||
),
|
||||
fx.Invoke(func(*server.Server) {}),
|
||||
fx.Invoke(
|
||||
func(log *logger.Logger) { log.Identify() },
|
||||
func(*server.Server) {},
|
||||
),
|
||||
).Run()
|
||||
}
|
||||
|
||||
+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