Compare commits
2
Commits
9ed38b7c28
...
948ce4800f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
948ce4800f | ||
|
|
625fd42ace |
-1259
File diff suppressed because it is too large
Load Diff
@@ -18,7 +18,7 @@ make build
|
||||
# run with a config file: copy the example and set a real signing key
|
||||
# (the example placeholder is refused at startup), e.g. with
|
||||
# openssl rand -base64 32
|
||||
cp config.example.yml config.yml
|
||||
cp configs/config.example.yml config.yml
|
||||
$EDITOR config.yml # replace the signing_key placeholder
|
||||
./bin/pixad --config config.yml
|
||||
|
||||
@@ -490,6 +490,12 @@ Key settings in more detail:
|
||||
waits for an upstream connection and for a processing slot (up to 10 seconds
|
||||
each), so keep it longer than `upstream_fetch_timeout` plus 20 seconds
|
||||
- `signing_key` — HMAC secret for URL signatures
|
||||
- `db_url` — the SQLite database to open; omitted, it is
|
||||
`file:<state_dir>/state.sqlite3?_pragma=journal_mode(WAL)`, which keeps the
|
||||
database in WAL mode. pixa adds `_pragma=busy_timeout(5000)` to any `db_url`,
|
||||
so a write that finds another in progress waits up to five seconds for it
|
||||
instead of failing. WAL mode comes only from the URL: keep
|
||||
`_pragma=journal_mode(WAL)` in one you set
|
||||
- `cache_max_bytes` — disk cache size limit in bytes; `0` disables the
|
||||
disk cache entirely; omitted defaults to 75% of the sum of the free space on
|
||||
the filesystem containing `<state_dir>/cache/` and the bytes of source and
|
||||
@@ -514,7 +520,7 @@ Key settings in more detail:
|
||||
the container unhealthy, and upaas marks a deploy failed when its container
|
||||
is unhealthy. The login and URL generator pages and `/metrics` keep working
|
||||
|
||||
See `config.example.yml` for all options with defaults.
|
||||
See `configs/config.example.yml` for all options with defaults.
|
||||
|
||||
### Architecture
|
||||
|
||||
|
||||
@@ -31,6 +31,22 @@ P2: security: referer blacklist
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- 2026-10-04 fewer files in the repository root (closes #97):
|
||||
`config.example.yml` moved unchanged to `configs/config.example.yml`, and
|
||||
`README.md`, the comments in `internal/config/config.go` and the startup error
|
||||
for the placeholder signing key name the new path; `scripts/manual-test.sh`
|
||||
and its directory are deleted, as the handler tests in `internal/handlers`
|
||||
cover every check it made except two: fetching a real image from the
|
||||
internet, and a URL made on the generator page with a `ttl` answering 410 once
|
||||
the `ttl` has passed (https://git.eeqj.de/sneak/pixa/issues/199);
|
||||
`CONVENTIONS.md` is deleted, as `REPO_POLICIES.md` links the canonical Go HTTP
|
||||
server conventions.
|
||||
- 2026-10-04 SQLite writes no longer fail with "database is locked" (closes
|
||||
#198): pixa adds `_pragma=busy_timeout(5000)` to every `db_url`, so a write
|
||||
that finds another in progress on another connection waits up to five seconds
|
||||
for it, and the default `db_url` turns on WAL mode with
|
||||
`_pragma=journal_mode(WAL)`. The old default's `_journal_mode=WAL` is not a
|
||||
parameter the driver reads, so the database was never in WAL mode.
|
||||
- 2026-10-04 `TestPeriodicReconciliationAdoptsFileThatAppearsAfterStartup`
|
||||
only passes through a periodic pass (closes #189): it slept for three
|
||||
eviction intervals before writing its file, and a startup pass still running
|
||||
|
||||
@@ -34,9 +34,10 @@ maintenance_mode: false
|
||||
state_dir: ./data
|
||||
|
||||
# SQLite database URL (default:
|
||||
# file:<state_dir>/state.sqlite3?_journal_mode=WAL). An empty value aborts
|
||||
# startup; leave the key out to use the default.
|
||||
# db_url: "file:./data/state.sqlite3?_journal_mode=WAL"
|
||||
# file:<state_dir>/state.sqlite3?_pragma=journal_mode(WAL)). pixa adds
|
||||
# _pragma=busy_timeout(5000) to it. An empty value aborts startup; leave the
|
||||
# key out to use the default.
|
||||
# db_url: "file:./data/state.sqlite3?_pragma=journal_mode(WAL)"
|
||||
|
||||
# Image proxy settings
|
||||
# HMAC signing key for URL signatures (required, at least 32 characters)
|
||||
@@ -62,7 +62,7 @@ const (
|
||||
)
|
||||
|
||||
// placeholderSigningKey is the dummy signing_key shipped in
|
||||
// config.example.yml. It is 45 characters, so it passes the length
|
||||
// configs/config.example.yml. It is 45 characters, so it passes the length
|
||||
// check, but it is public in this repository and must be rejected at
|
||||
// startup so no deployment ever signs URLs with it.
|
||||
const placeholderSigningKey = "CHANGE_ME_generate_with_openssl_rand_base64_32"
|
||||
@@ -88,7 +88,7 @@ var (
|
||||
errMustBeAtLeastOne = errors.New("must be at least 1")
|
||||
errValueTooShort = errors.New("value too short")
|
||||
errPlaceholderKey = errors.New(
|
||||
"is the placeholder from config.example.yml; " +
|
||||
"is the placeholder from configs/config.example.yml; " +
|
||||
"generate a real key with: openssl rand -base64 32")
|
||||
errMustBeSetTogether = errors.New("must be set together")
|
||||
errMustNotBeNegative = errors.New("must not be negative")
|
||||
@@ -320,7 +320,8 @@ func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) {
|
||||
settingName(keyDBURL), errValueEmpty)
|
||||
}
|
||||
|
||||
c.DBURL = fmt.Sprintf("file:%s/state.sqlite3?_journal_mode=WAL", c.StateDir)
|
||||
// The driver sets the journal mode only through a _pragma parameter.
|
||||
c.DBURL = fmt.Sprintf("file:%s/state.sqlite3?_pragma=journal_mode(WAL)", c.StateDir)
|
||||
}
|
||||
|
||||
if loader.err != nil {
|
||||
@@ -553,8 +554,8 @@ func (c *Config) ensureStateDirWritable() error {
|
||||
}
|
||||
|
||||
// validateSigningKey checks that the signing key is present, long
|
||||
// enough, and not the public placeholder from config.example.yml. The
|
||||
// key value itself is never echoed in error messages.
|
||||
// enough, and not the public placeholder from configs/config.example.yml.
|
||||
// The key value itself is never echoed in error messages.
|
||||
func (c *Config) validateSigningKey() error {
|
||||
if c.SigningKey == "" {
|
||||
return fmt.Errorf("%s: %w", settingName(keySigningKey), errValueRequired)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -9,6 +10,8 @@ import (
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/smartconfig"
|
||||
|
||||
_ "modernc.org/sqlite" // SQLite driver registration
|
||||
)
|
||||
|
||||
// validTestSigningKey is a 32-character signing key that satisfies the
|
||||
@@ -94,12 +97,43 @@ func TestOmittedValuesUseDefaults(t *testing.T) {
|
||||
t.Errorf("AllowlistHosts = %v, want empty", c.AllowlistHosts)
|
||||
}
|
||||
|
||||
wantDBURL := "file:" + DefaultStateDir + "/state.sqlite3?_journal_mode=WAL"
|
||||
wantDBURL := "file:" + DefaultStateDir +
|
||||
"/state.sqlite3?_pragma=journal_mode(WAL)"
|
||||
if c.DBURL != wantDBURL {
|
||||
t.Errorf("DBURL = %q, want derived default %q", c.DBURL, wantDBURL)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultDBURLOpensTheDatabaseInWALMode opens the db_url derived from
|
||||
// state_dir with the SQLite driver pixad uses and checks that the database
|
||||
// is in WAL mode: the driver ignores any parameter it does not know.
|
||||
func TestDefaultDBURLOpensTheDatabaseInWALMode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c, err := configFromYAML(t, signingKeyLine+"state_dir: "+t.TempDir()+"\n")
|
||||
if err != nil {
|
||||
t.Fatalf("config with only state_dir set should be valid, got: %v", err)
|
||||
}
|
||||
|
||||
db, err := sql.Open("sqlite", c.DBURL)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to open %q: %v", c.DBURL, err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
var journalMode string
|
||||
|
||||
err = db.QueryRowContext(t.Context(), "PRAGMA journal_mode").Scan(&journalMode)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read the journal mode of %q: %v", c.DBURL, err)
|
||||
}
|
||||
|
||||
if journalMode != "wal" {
|
||||
t.Errorf("journal mode of %q = %q, want wal", c.DBURL, journalMode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExplicitValidValuesAreUsed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/pixa/internal/config"
|
||||
)
|
||||
|
||||
// TestConcurrentWritesAllSucceed opens a database the way pixad does and
|
||||
// writes to it from several goroutines at once, so the writes run on
|
||||
// separate connections, as one request's writes and the background eviction
|
||||
// pass do. Every write must succeed, none failing with "database is locked",
|
||||
// whether or not db_url already has parameters, and the parameters it has
|
||||
// must still apply.
|
||||
func TestConcurrentWritesAllSucceed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
query string
|
||||
wantJournalMode string
|
||||
}{
|
||||
{
|
||||
name: "db_url without parameters",
|
||||
query: "",
|
||||
wantJournalMode: "delete",
|
||||
},
|
||||
{
|
||||
name: "db_url with the WAL parameter",
|
||||
query: "?_pragma=journal_mode(WAL)",
|
||||
wantJournalMode: "wal",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dbURL := "file:" + filepath.Join(t.TempDir(), "state.sqlite3") + tt.query
|
||||
|
||||
d := &Database{
|
||||
log: slog.New(slog.DiscardHandler),
|
||||
config: &config.Config{DBURL: dbURL},
|
||||
}
|
||||
|
||||
err := d.connect(t.Context())
|
||||
if err != nil {
|
||||
t.Fatalf("failed to connect to %q: %v", dbURL, err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() { _ = d.db.Close() })
|
||||
|
||||
writeConcurrently(t, d.db)
|
||||
|
||||
var journalMode string
|
||||
|
||||
err = d.db.QueryRowContext(t.Context(), "PRAGMA journal_mode").
|
||||
Scan(&journalMode)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read the journal mode: %v", err)
|
||||
}
|
||||
|
||||
if journalMode != tt.wantJournalMode {
|
||||
t.Errorf("journal mode = %q, want %q", journalMode, tt.wantJournalMode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// writeConcurrently runs writeLikeOneRequest from several goroutines at once
|
||||
// and checks that every write was made.
|
||||
func writeConcurrently(t *testing.T, db *sql.DB) {
|
||||
t.Helper()
|
||||
|
||||
const (
|
||||
writers = 4
|
||||
requestsEach = 20
|
||||
totalRequests = writers * requestsEach
|
||||
)
|
||||
|
||||
ctx := t.Context()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for writer := range writers {
|
||||
wg.Go(func() {
|
||||
for request := range requestsEach {
|
||||
key := fmt.Sprintf("%d-%d", writer, request)
|
||||
|
||||
err := writeLikeOneRequest(ctx, db, key)
|
||||
if err != nil {
|
||||
t.Errorf("writer %d: %v", writer, err)
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
var hits, sources int
|
||||
|
||||
err := db.QueryRowContext(ctx, `
|
||||
SELECT hit_count, (SELECT COUNT(*) FROM source_content)
|
||||
FROM cache_stats WHERE id = 1
|
||||
`).Scan(&hits, &sources)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to count the writes: %v", err)
|
||||
}
|
||||
|
||||
if hits != totalRequests || sources != totalRequests {
|
||||
t.Errorf("hit_count = %d and %d source_content rows, want %d of each",
|
||||
hits, sources, totalRequests)
|
||||
}
|
||||
}
|
||||
|
||||
// writeLikeOneRequest makes the writes one request and the eviction pass
|
||||
// make: it counts a cache hit, stores a source, records a transformed image
|
||||
// and deletes that record again.
|
||||
func writeLikeOneRequest(ctx context.Context, db *sql.DB, key string) error {
|
||||
_, err := db.ExecContext(ctx,
|
||||
`UPDATE cache_stats SET hit_count = hit_count + 1 WHERE id = 1`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("counting a cache hit: %w", err)
|
||||
}
|
||||
|
||||
_, err = db.ExecContext(ctx, `INSERT INTO source_content
|
||||
(content_hash, content_type, size_bytes) VALUES (?, 'image/png', 1)`, key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("storing a source: %w", err)
|
||||
}
|
||||
|
||||
_, err = db.ExecContext(ctx, `INSERT INTO variant_content
|
||||
(cache_key, size_bytes, content_type) VALUES (?, 1, 'image/png')`, key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("recording a transformed image: %w", err)
|
||||
}
|
||||
|
||||
_, err = db.ExecContext(ctx,
|
||||
`DELETE FROM variant_content WHERE cache_key = ?`, key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("evicting a transformed image: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -243,7 +243,17 @@ func (s *Database) DB() *sql.DB {
|
||||
}
|
||||
|
||||
func (s *Database) connect(ctx context.Context) error {
|
||||
dbURL := s.config.DBURL
|
||||
// Requests and the eviction pass write on separate connections. With
|
||||
// a busy timeout, a write that finds another one in progress waits up
|
||||
// to five seconds for it instead of failing at once with "database is
|
||||
// locked". The driver runs each _pragma parameter on every connection
|
||||
// it opens.
|
||||
separator := "?"
|
||||
if strings.Contains(s.config.DBURL, "?") {
|
||||
separator = "&"
|
||||
}
|
||||
|
||||
dbURL := s.config.DBURL + separator + "_pragma=busy_timeout(5000)"
|
||||
|
||||
s.log.Info("connecting to database", "url", dbURL)
|
||||
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Manual test script for pixa server
|
||||
# Requires: server running on localhost:8080
|
||||
#
|
||||
set -e
|
||||
|
||||
BASE_URL="${BASE_URL:-http://localhost:8080}"
|
||||
SIGNING_KEY="${SIGNING_KEY:-test-signing-key-for-development-only}"
|
||||
TEST_IMAGE_URL="https://s3.sneak.cloud/sneak-public/2021/2021-04-18.untitled.a7r4.07723.jpg"
|
||||
COOKIE_JAR=$(mktemp)
|
||||
|
||||
cleanup() {
|
||||
rm -f "$COOKIE_JAR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
pass() {
|
||||
echo "✓ PASS: $1"
|
||||
}
|
||||
|
||||
fail() {
|
||||
echo "✗ FAIL: $1"
|
||||
exit 1
|
||||
}
|
||||
|
||||
echo "=== Pixa Manual Test Suite ==="
|
||||
echo "Base URL: $BASE_URL"
|
||||
echo ""
|
||||
|
||||
# Test 1: Healthcheck
|
||||
echo "--- Test 1: Healthcheck endpoint ---"
|
||||
HEALTH=$(curl -sf "$BASE_URL/.well-known/healthcheck.json")
|
||||
if echo "$HEALTH" | grep -q '"status"'; then
|
||||
pass "Healthcheck returns status"
|
||||
else
|
||||
fail "Healthcheck did not return expected response"
|
||||
fi
|
||||
|
||||
# Test 2: Login page displays
|
||||
echo "--- Test 2: Login page (GET /) ---"
|
||||
LOGIN_PAGE=$(curl -sf "$BASE_URL/")
|
||||
if echo "$LOGIN_PAGE" | grep -qi "password\|login\|sign"; then
|
||||
pass "Login page displays password form"
|
||||
else
|
||||
fail "Login page did not display expected content"
|
||||
fi
|
||||
|
||||
# Test 3: Wrong password shows error
|
||||
echo "--- Test 3: Login with wrong password ---"
|
||||
WRONG_LOGIN=$(curl -sf -X POST "$BASE_URL/" -d "key=wrong-key" -c "$COOKIE_JAR")
|
||||
if echo "$WRONG_LOGIN" | grep -qi "invalid\|error\|incorrect\|wrong"; then
|
||||
pass "Wrong password shows error message"
|
||||
else
|
||||
fail "Wrong password did not show error"
|
||||
fi
|
||||
|
||||
# Test 4: Correct password redirects to generator
|
||||
echo "--- Test 4: Login with correct signing key ---"
|
||||
curl -sf -X POST "$BASE_URL/" -d "key=$SIGNING_KEY" -c "$COOKIE_JAR" -b "$COOKIE_JAR" -L -o /dev/null
|
||||
GENERATOR_PAGE=$(curl -sf "$BASE_URL/" -b "$COOKIE_JAR")
|
||||
if echo "$GENERATOR_PAGE" | grep -qi "generate\|url\|source\|logout"; then
|
||||
pass "Correct password shows generator page"
|
||||
else
|
||||
fail "Generator page not displayed after login"
|
||||
fi
|
||||
|
||||
# Test 5: Generate encrypted URL
|
||||
echo "--- Test 5: Generate encrypted URL ---"
|
||||
GEN_RESULT=$(curl -sf -X POST "$BASE_URL/generate" -b "$COOKIE_JAR" \
|
||||
-d "url=$TEST_IMAGE_URL" \
|
||||
-d "width=800" \
|
||||
-d "height=600" \
|
||||
-d "format=jpeg" \
|
||||
-d "quality=85" \
|
||||
-d "fit=cover" \
|
||||
-d "ttl=3600")
|
||||
if echo "$GEN_RESULT" | grep -q "/v1/e/"; then
|
||||
pass "Encrypted URL generated"
|
||||
# Extract the encrypted URL
|
||||
ENC_URL=$(echo "$GEN_RESULT" | grep -o '/v1/e/[^"<]*' | head -1)
|
||||
echo " Generated URL: $ENC_URL"
|
||||
else
|
||||
fail "Failed to generate encrypted URL"
|
||||
fi
|
||||
|
||||
# Test 6: Fetch image via encrypted URL
|
||||
echo "--- Test 6: Fetch image via encrypted URL ---"
|
||||
if [ -n "$ENC_URL" ]; then
|
||||
HTTP_CODE=$(curl -sf -o /dev/null -w "%{http_code}" "$BASE_URL$ENC_URL")
|
||||
if [ "$HTTP_CODE" = "200" ]; then
|
||||
pass "Encrypted URL returns image (HTTP 200)"
|
||||
else
|
||||
fail "Encrypted URL returned HTTP $HTTP_CODE"
|
||||
fi
|
||||
else
|
||||
fail "No encrypted URL to test"
|
||||
fi
|
||||
|
||||
# Test 7: Fetch image via allowlisted host (direct proxy)
|
||||
echo "--- Test 7: Fetch image via direct proxy (allowlisted host) ---"
|
||||
# URL format: /v1/image/<host>/<path>/<WxH>.<format>
|
||||
PROXY_PATH="/v1/image/s3.sneak.cloud/sneak-public/2021/2021-04-18.untitled.a7r4.07723.jpg/400x300.jpeg"
|
||||
HTTP_CODE=$(curl -sf -o /dev/null -w "%{http_code}" "$BASE_URL$PROXY_PATH")
|
||||
if [ "$HTTP_CODE" = "200" ]; then
|
||||
pass "Direct proxy returns image (HTTP 200)"
|
||||
else
|
||||
fail "Direct proxy returned HTTP $HTTP_CODE"
|
||||
fi
|
||||
|
||||
# Test 8: Logout
|
||||
echo "--- Test 8: Logout ---"
|
||||
curl -sf "$BASE_URL/logout" -b "$COOKIE_JAR" -c "$COOKIE_JAR" -L -o /dev/null
|
||||
AFTER_LOGOUT=$(curl -sf "$BASE_URL/" -b "$COOKIE_JAR")
|
||||
if echo "$AFTER_LOGOUT" | grep -qi "password\|login"; then
|
||||
pass "Logout redirects to login page"
|
||||
else
|
||||
fail "Logout did not redirect to login"
|
||||
fi
|
||||
|
||||
# Test 9: Generate short-TTL URL and verify expiration
|
||||
echo "--- Test 9: Expired URL returns 410 ---"
|
||||
# Login again
|
||||
curl -sf -X POST "$BASE_URL/" -d "key=$SIGNING_KEY" -c "$COOKIE_JAR" -b "$COOKIE_JAR" -L -o /dev/null
|
||||
# Generate URL with 1 second TTL
|
||||
GEN_RESULT=$(curl -sf -X POST "$BASE_URL/generate" -b "$COOKIE_JAR" \
|
||||
-d "url=$TEST_IMAGE_URL" \
|
||||
-d "width=100" \
|
||||
-d "height=100" \
|
||||
-d "format=jpeg" \
|
||||
-d "ttl=1")
|
||||
SHORT_URL=$(echo "$GEN_RESULT" | grep -o '/v1/e/[^"<]*' | head -1)
|
||||
if [ -n "$SHORT_URL" ]; then
|
||||
echo " Waiting 2 seconds for URL to expire..."
|
||||
sleep 2
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "$BASE_URL$SHORT_URL")
|
||||
if [ "$HTTP_CODE" = "410" ]; then
|
||||
pass "Expired URL returns 410 Gone"
|
||||
else
|
||||
fail "Expired URL returned HTTP $HTTP_CODE (expected 410)"
|
||||
fi
|
||||
else
|
||||
fail "Could not generate short-TTL URL"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== All tests passed! ==="
|
||||
Reference in New Issue
Block a user