4 Commits
Author SHA1 Message Date
clawbot 25c6a959b5 Build the image cache's config from the config in newCacheConfig
check / check (push) Failing after 3s
initImageService built it inline, so the choice between the default
limit, an explicit limit and the disk cache off had no test of its own.
It is now one small function, which the previous commit tests.

Model: opus-5-5
2026-10-04 16:27:12 +00:00
clawbot b77b0eaf22 Test the cache config the handlers build from cache_max_bytes
check / check (push) Failing after 3s
With cache_max_bytes omitted the cache must work out the default
limit, with an explicit 0 the disk cache must be off, and an explicit
positive value must reach the cache unchanged. newCacheConfig does not
exist yet, so this commit does not compile; the next one adds it.

Model: opus-5-5
2026-10-04 16:26:38 +00:00
clawbot 602da7a45a Count what the cache holds in the default cache_max_bytes (closes #184)
The default limit was 75% of the space free at startup. The cache's
own files are not free space, so a fuller cache got a smaller limit
after a restart and eviction then deleted most of it. The default is
now 75% of the sum of the free space and what the cache already holds
by its own size accounting, at least 500 MiB. The cache works it out
when it opens, after the database is open, so the computation and its
tests moved from internal/config to internal/imgcache; the config only
records whether cache_max_bytes was set, and the handlers turn the
disk cache off only for an explicit 0.

Model: opus-5-5
2026-10-04 16:25:17 +00:00
clawbot 35e771e2b6 Test that the default cache_max_bytes counts what the cache holds
With a fake free-space probe, an empty cache with 4 GiB free gets a
3 GiB default, and the same cache once it holds those 3 GiB, with
1 GiB left free, must keep 3 GiB. The default is still 75% of the
free space alone, so the second check fails: this is the bug in
#184. computeDefaultMaxBytes
only calls the config's existing computation so the test compiles.

Model: opus-5-5
2026-10-04 16:25:17 +00:00
12 changed files with 166 additions and 336 deletions
+2 -66
View File
@@ -10,20 +10,14 @@ run:
linters:
default: all
enable:
# Successor to the deprecated gomodguard. Named explicitly, rather than
# left to `default: all`, because it carries the module policy below.
- gomodguard_v2
disable:
# Genuinely incompatible with project patterns
- exhaustruct # Requires all struct fields
- depguard # Dependency allow/block lists
- godot # Requires comments to end with periods
- wsl # Deprecated, replaced by wsl_v5
- wrapcheck # Too verbose for internal packages
- varnamelen # Short names like db, id are idiomatic Go
# Deprecated: the warning is attached to the old name, so it is
# silenced by disabling that name, not by enabling the successor.
- wsl # Deprecated, replaced by wsl_v5
- gomodguard # Deprecated, replaced by gomodguard_v2
settings:
lll:
line-length: 88
@@ -34,64 +28,6 @@ linters:
max-complexity: 15
dupl:
threshold: 100
depguard:
# Test-support code must not be compiled into the shipped binary. A
# test-support package exists to hand a test privileges the program
# itself must never have, so a file that is not a test must not import
# one. Test files, and the files inside a package whose directory name
# ends in `test`, are where that code belongs, and are exempt.
#
# The deny list below is the one part of this file a repository is
# expected to extend, and the only part it may. depguard matches an
# import path against a list of prefixes, so it cannot be told "any path
# whose last segment ends in test"; a repository's own test-support
# packages have to be named here one at a time, by full import path,
# under a module path that differs from repository to repository. Add
# them; change nothing else.
rules:
test-support:
list-mode: lax
files:
- "$all"
- "!$test"
- "!**/*test/**"
deny:
- pkg: net/http/httptest
desc: >-
Test-support code belongs in test files and in packages whose
directory name ends in test, not in the shipped binary.
# Only decisions already recorded in the Go package defaults are
# listed here. Every entry matches the module path exactly.
gomodguard_v2:
blocked:
- module: github.com/rs/zerolog
recommendations:
- log/slog
reason: "Structured logging is stdlib log/slog."
# One entry per pre-fork module path, because the later releases
# are separate paths. A prefix match would be shorter but would
# also reach github.com/go-redis/redismock, the test double for
# the successor these entries recommend.
- module: github.com/go-redis/redis
recommendations:
- github.com/redis/go-redis/v9
reason: "Pre-fork module; use the maintained go-redis v9."
- module: github.com/go-redis/redis/v7
recommendations:
- github.com/redis/go-redis/v9
reason: "Pre-fork module; use the maintained go-redis v9."
- module: github.com/go-redis/redis/v8
recommendations:
- github.com/redis/go-redis/v9
reason: "Pre-fork module; use the maintained go-redis v9."
- module: github.com/sergi/go-diff
recommendations:
- github.com/aymanbagabas/go-udiff
reason: "No unified diff output; use go-udiff."
- module: github.com/hexops/gotextdiff
recommendations:
- github.com/aymanbagabas/go-udiff
reason: "Unmaintained fork; use go-udiff."
issues:
max-issues-per-linter: 0
+2 -3
View File
@@ -192,9 +192,8 @@ path under `/v1/` answers 200, in maintenance mode too.
- `GET /.well-known/healthcheck.json` — JSON with `status` (`ok`), `now`,
`uptime_seconds`, `uptime_human`, `version`, `appname` and
`maintenance_mode`. Needs: nothing. Answers: 200, always.
- `GET /static/<file>` — the stylesheet and script the login and generator
pages load. Needs: nothing. Answers: 200, or 404 for a file that does not
exist.
- `GET /static/<file>` — the script the login and generator pages load. Needs:
nothing. Answers: 200, or 404 for a file that does not exist.
- `GET /metrics` — Prometheus metrics (see Architecture). Needs: HTTP basic
authentication with `metrics.username` and `metrics.password`. Answers: 200;
401 without them; 404 when they are not set, as the route then does not exist.
-14
View File
@@ -61,20 +61,6 @@ P2: security: referer blacklist
not exist is passed over; any other error, such as a directory on the path
that pixa may not enter, aborts startup naming the file, as a file that does
not parse already did.
- 2026-10-04 `.golangci.yml` re-vendored from the canonical copy (closes #57):
the deprecated `gomodguard` is switched off, so lint runs print no
deprecation warning; its successor `gomodguard_v2` runs with the shared
module block list, and `depguard` keeps `net/http/httptest` out of files that
are not tests. The tree needed no code changes.
- 2026-10-04 the Content-Security-Policy allows no inline script or style
(closes #125): `script-src` and `style-src` are `'self'` only. The generator
page's two inline `onclick` handlers moved into
`internal/static/generator.js`, attached with `addEventListener`; the bundled
Tailwind script, which built styles in the browser, is replaced by a small
hand-written `internal/static/style.css` with only the rules the login and
generator pages use, the templates carrying a few plain class names in place
of Tailwind's. No build step. The pages keep their layout, not every pixel of
it.
- 2026-10-04 deployment guide and example Caddy config (closes #89):
"Deployment" in `README.md` says what the reverse proxy in front of pixa must
do (terminate TLS; pass `Host`, `Origin` and `Referer` on unchanged; set
+6 -3
View File
@@ -35,10 +35,13 @@ const HSTSValue = "max-age=31536000; includeSubDomains"
// ContentSecurityPolicyValue is the Content-Security-Policy header value.
// default-src 'self' is the baseline and frame-ancestors 'none' is the primary
// clickjacking control.
// clickjacking control. 'unsafe-inline' is required in script-src and style-src
// because the served templates carry inline onclick handlers (generator page)
// and the bundled Tailwind asset injects a runtime <style> element; dropping it
// needs template changes outside this issue's scope.
const ContentSecurityPolicyValue = "default-src 'self'; " +
"script-src 'self'; " +
"style-src 'self'; " +
"script-src 'self' 'unsafe-inline'; " +
"style-src 'self' 'unsafe-inline'; " +
"object-src 'none'; " +
"base-uri 'self'; " +
"form-action 'self'; " +
@@ -325,13 +325,6 @@ func TestSecurityHeaders_PolicyHeaders(t *testing.T) {
handler.ServeHTTP(rec, req)
// The login and generator pages load their script and stylesheet from
// /static, so the policy allows no inline script or style.
csp := rec.Header().Get("Content-Security-Policy")
if strings.Contains(csp, "unsafe-inline") {
t.Errorf("Content-Security-Policy allows unsafe-inline: %q", csp)
}
tests := []struct {
header string
want string
@@ -340,8 +333,8 @@ func TestSecurityHeaders_PolicyHeaders(t *testing.T) {
{
"Content-Security-Policy",
"default-src 'self'; " +
"script-src 'self'; " +
"style-src 'self'; " +
"script-src 'self' 'unsafe-inline'; " +
"style-src 'self' 'unsafe-inline'; " +
"object-src 'none'; " +
"base-uri 'self'; " +
"form-action 'self'; " +
+1 -1
View File
@@ -53,7 +53,7 @@ func (s *Server) SetupRoutes() {
// Robots.txt
s.router.Get("/robots.txt", s.h.HandleRobotsTxt())
// The login and generator pages' stylesheet and script
// Static files (Tailwind CSS, etc.)
s.router.Handle("/static/*", http.StripPrefix("/static/", static.Handler()))
// Login/generator UI. The form routes carry CSRF protection; the
-10
View File
@@ -1,10 +0,0 @@
// Generator page: a click on the generated URL selects it, and the Copy
// button copies it. Both are on the page only once a URL has been generated.
const generatedURL = document.getElementById("generated-url");
if (generatedURL) {
generatedURL.addEventListener("click", () => generatedURL.select());
document.getElementById("copy-url").addEventListener("click", () => {
navigator.clipboard.writeText(generatedURL.value);
});
}
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"net/http"
)
//go:embed *.css *.js
//go:embed *.js
var files embed.FS
// FS returns the embedded filesystem containing static files.
-190
View File
@@ -1,190 +0,0 @@
/* The login and generator pages. */
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background: #f3f4f6;
font-family: system-ui, sans-serif;
line-height: 1.5;
}
h1 {
margin: 0;
font-size: 1.5rem;
line-height: 2rem;
font-weight: 700;
color: #1f2937;
}
label {
display: block;
margin-bottom: 0.25rem;
font-size: 0.875rem;
font-weight: 500;
color: #374151;
}
input,
select {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
box-shadow: 0 1px 2px rgb(0 0 0 / 5%);
font: inherit;
}
input:focus,
select:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 2px #3b82f6;
}
button {
width: 100%;
padding: 0.5rem 1rem;
border: none;
border-radius: 0.375rem;
background: #2563eb;
color: #fff;
font: inherit;
cursor: pointer;
transition: background-color 0.15s;
}
button:hover {
background: #1d4ed8;
}
button:focus {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
form > * + * {
margin-top: 1rem;
}
.card {
padding: 1.5rem;
border-radius: 0.5rem;
background: #fff;
box-shadow:
0 4px 6px -1px rgb(0 0 0 / 10%),
0 2px 4px -2px rgb(0 0 0 / 10%);
}
.error {
margin-bottom: 1rem;
padding: 0.75rem 1rem;
border: 1px solid #f87171;
border-radius: 0.25rem;
background: #fee2e2;
color: #b91c1c;
}
/* Login page: the card centred on the screen. */
.login {
display: flex;
align-items: center;
justify-content: center;
}
.login .card {
width: 100%;
max-width: 28rem;
padding: 2rem;
}
.login h1 {
margin-bottom: 1.5rem;
text-align: center;
}
/* Generator page. */
.page {
max-width: 42rem;
margin: 0 auto;
padding: 2rem 1rem;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}
header a {
font-size: 0.875rem;
color: #4b5563;
}
header a:hover {
color: #1f2937;
}
.result {
margin-bottom: 1.5rem;
padding: 1rem;
border: 1px solid #bbf7d0;
border-radius: 0.5rem;
background: #f0fdf4;
}
.result h2 {
margin: 0 0 0.5rem;
font-size: 0.875rem;
font-weight: 500;
color: #166534;
}
.result div {
display: flex;
gap: 0.5rem;
}
.result input {
flex: 1;
border-color: #86efac;
box-shadow: none;
font-family: ui-monospace, monospace;
font-size: 0.875rem;
}
.result button {
width: auto;
padding: 0.5rem 0.75rem;
background: #16a34a;
font-size: 0.875rem;
}
.result button:hover {
background: #15803d;
}
.result p {
margin: 0.5rem 0 0;
font-size: 0.75rem;
color: #16a34a;
}
.columns {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
.note {
margin-top: 1rem;
font-size: 0.75rem;
color: #6b7280;
text-align: center;
}
File diff suppressed because one or more lines are too long
+57 -31
View File
@@ -4,47 +4,52 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pixa - URL Generator</title>
<link rel="stylesheet" href="/static/style.css">
<script src="/static/tailwind.js"></script>
</head>
<body>
<div class="page">
<header>
<h1>Pixa URL Generator</h1>
<a href="/logout">
<body class="bg-gray-100 min-h-screen">
<div class="max-w-2xl mx-auto py-8 px-4">
<div class="flex justify-between items-center mb-8">
<h1 class="text-2xl font-bold text-gray-800">Pixa URL Generator</h1>
<a href="/logout" class="text-sm text-gray-600 hover:text-gray-800 underline">
Logout
</a>
</header>
</div>
{{if .GeneratedURL}}
<div class="result">
<h2>Generated URL</h2>
<div>
<div class="bg-green-50 border border-green-200 rounded-lg p-4 mb-6">
<h2 class="text-sm font-medium text-green-800 mb-2">Generated URL</h2>
<div class="flex gap-2">
<input
type="text"
readonly
value="{{.GeneratedURL}}"
id="generated-url"
class="flex-1 px-3 py-2 bg-white border border-green-300 rounded-md text-sm font-mono"
onclick="this.select()"
>
<button
onclick="navigator.clipboard.writeText(document.getElementById('generated-url').value)"
class="px-3 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 text-sm"
>
<button id="copy-url">
Copy
</button>
</div>
<p>
<p class="text-xs text-green-600 mt-2">
Expires: {{.ExpiresAt}}
</p>
</div>
{{end}}
{{if .Error}}
<div class="error">
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-6">
{{.Error}}
</div>
{{end}}
<form method="POST" action="/generate" class="card">
<form method="POST" action="/generate" class="bg-white rounded-lg shadow-md p-6 space-y-4">
{{ .CSRFField }}
<div>
<label for="url">
<label for="url" class="block text-sm font-medium text-gray-700 mb-1">
Source URL
</label>
<input
@@ -54,12 +59,13 @@
required
placeholder="https://example.com/image.jpg"
value="{{.FormURL}}"
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
</div>
<div class="columns">
<div class="grid grid-cols-2 gap-4">
<div>
<label for="width">
<label for="width" class="block text-sm font-medium text-gray-700 mb-1">
Width
</label>
<input
@@ -70,10 +76,11 @@
max="8192"
value="{{if .FormWidth}}{{.FormWidth}}{{else}}0{{end}}"
placeholder="0 = original"
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
</div>
<div>
<label for="height">
<label for="height" class="block text-sm font-medium text-gray-700 mb-1">
Height
</label>
<input
@@ -84,16 +91,21 @@
max="8192"
value="{{if .FormHeight}}{{.FormHeight}}{{else}}0{{end}}"
placeholder="0 = original"
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
</div>
</div>
<div class="columns">
<div class="grid grid-cols-2 gap-4">
<div>
<label for="format">
<label for="format" class="block text-sm font-medium text-gray-700 mb-1">
Format
</label>
<select id="format" name="format">
<select
id="format"
name="format"
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="orig" {{if eq .FormFormat "orig"}}selected{{end}}>Original</option>
<option value="jpeg" {{if eq .FormFormat "jpeg"}}selected{{end}}>JPEG</option>
<option value="png" {{if eq .FormFormat "png"}}selected{{end}}>PNG</option>
@@ -103,10 +115,14 @@
</select>
</div>
<div>
<label for="quality">
<label for="quality" class="block text-sm font-medium text-gray-700 mb-1">
Quality
</label>
<select id="quality" name="quality">
<select
id="quality"
name="quality"
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="25" {{if eq .FormQuality "25"}}selected{{end}}>Potato</option>
<option value="50" {{if eq .FormQuality "50"}}selected{{end}}>Low</option>
<option value="70" {{if eq .FormQuality "70"}}selected{{end}}>Medium</option>
@@ -116,12 +132,16 @@
</div>
</div>
<div class="columns">
<div class="grid grid-cols-2 gap-4">
<div>
<label for="fit">
<label for="fit" class="block text-sm font-medium text-gray-700 mb-1">
Fit Mode
</label>
<select id="fit" name="fit">
<select
id="fit"
name="fit"
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="cover" {{if eq .FormFit "cover"}}selected{{end}}>Cover</option>
<option value="contain" {{if eq .FormFit "contain"}}selected{{end}}>Contain</option>
<option value="fill" {{if eq .FormFit "fill"}}selected{{end}}>Fill</option>
@@ -130,10 +150,14 @@
</select>
</div>
<div>
<label for="ttl">
<label for="ttl" class="block text-sm font-medium text-gray-700 mb-1">
Expires In
</label>
<select id="ttl" name="ttl">
<select
id="ttl"
name="ttl"
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="0" {{if or (eq .FormTTL "0") (eq .FormTTL "")}}selected{{end}}>Never</option>
<option value="60" {{if eq .FormTTL "60"}}selected{{end}}>1 minute</option>
<option value="3600" {{if eq .FormTTL "3600"}}selected{{end}}>1 hour</option>
@@ -145,15 +169,17 @@
</div>
</div>
<button type="submit">
<button
type="submit"
class="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors"
>
Generate Encrypted URL
</button>
</form>
<p class="note">
<p class="text-xs text-gray-500 mt-4 text-center">
Generated URLs are encrypted and cannot be modified. They will expire at the specified time.
</p>
</div>
<script src="/static/generator.js"></script>
</body>
</html>
+12 -8
View File
@@ -4,22 +4,22 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pixa - Login</title>
<link rel="stylesheet" href="/static/style.css">
<script src="/static/tailwind.js"></script>
</head>
<body class="login">
<div class="card">
<h1>Pixa Image Proxy</h1>
<body class="bg-gray-100 min-h-screen flex items-center justify-center">
<div class="bg-white p-8 rounded-lg shadow-md w-full max-w-md">
<h1 class="text-2xl font-bold text-gray-800 mb-6 text-center">Pixa Image Proxy</h1>
{{if .Error}}
<div class="error">
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
{{.Error}}
</div>
{{end}}
<form method="POST" action="/">
<form method="POST" action="/" class="space-y-4">
{{ .CSRFField }}
<div>
<label for="key">
<label for="key" class="block text-sm font-medium text-gray-700 mb-1">
Signing Key
</label>
<input
@@ -28,11 +28,15 @@
name="key"
required
autocomplete="current-password"
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="Enter your signing key"
>
</div>
<button type="submit">
<button
type="submit"
class="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors"
>
Login
</button>
</form>