1 Commits

Author SHA1 Message Date
c7d90314fd Update TODO.md
All checks were successful
check / check (push) Successful in 1m58s
2026-07-06 20:35:48 +02:00
7 changed files with 125 additions and 136 deletions

View File

@@ -1,29 +1,7 @@
# Lint stage
# golangci/golangci-lint:v2.10.1-alpine, 2026-02-17
FROM golangci/golangci-lint:v2.10.1-alpine@sha256:33bc6b6156d4c7da87175f187090019769903d04dd408833b83083ed214b0ddf AS lint
RUN apk add --no-cache make build-base vips-dev libheif-dev pkgconfig
WORKDIR /src
# Copy go mod files first for better layer caching
COPY go.mod go.sum ./
RUN go mod download
# Copy source code
COPY . .
# Run formatting check and linter
RUN make fmt-check
RUN make lint
# Build stage # Build stage
# golang:1.25.4-alpine, 2026-02-25 # golang:1.25.4-alpine, 2026-02-25
FROM golang:1.25.4-alpine@sha256:d3f0cf7723f3429e3f9ed846243970b20a2de7bae6a5b66fc5914e228d831bbb AS builder FROM golang:1.25.4-alpine@sha256:d3f0cf7723f3429e3f9ed846243970b20a2de7bae6a5b66fc5914e228d831bbb AS builder
# Depend on lint stage passing
COPY --from=lint /src/go.sum /dev/null
ARG VERSION=dev ARG VERSION=dev
# Install build dependencies for CGO image libraries # Install build dependencies for CGO image libraries
@@ -31,7 +9,25 @@ RUN apk add --no-cache \
build-base \ build-base \
vips-dev \ vips-dev \
libheif-dev \ libheif-dev \
pkgconfig pkgconfig \
curl
# golangci-lint v2.10.1, 2026-02-25
# SHA-256 checksums per architecture (amd64 / arm64)
RUN set -e; \
ARCH="$(uname -m)"; \
if [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then \
GOARCH="arm64"; \
HASH="6652b42ae02915eb2f9cb2a2e0cac99514c8eded8388d88ae3e06e1a52c00de8"; \
else \
GOARCH="amd64"; \
HASH="dfa775874cf0561b404a02a8f4481fc69b28091da95aa697259820d429b09c99"; \
fi; \
curl -sSfL "https://github.com/golangci/golangci-lint/releases/download/v2.10.1/golangci-lint-2.10.1-linux-${GOARCH}.tar.gz" -o /tmp/golangci-lint.tar.gz && \
echo "${HASH} /tmp/golangci-lint.tar.gz" | sha256sum -c - && \
tar -xzf /tmp/golangci-lint.tar.gz -C /tmp && \
mv "/tmp/golangci-lint-2.10.1-linux-${GOARCH}/golangci-lint" /usr/local/bin/ && \
rm -rf /tmp/golangci-lint*
WORKDIR /src WORKDIR /src
@@ -42,8 +38,8 @@ RUN GOTOOLCHAIN=auto go mod download
# Copy source code # Copy source code
COPY . . COPY . .
# Run tests # Run all checks (fmt-check, lint, test)
RUN make test RUN make check
# Build with CGO enabled # Build with CGO enabled
RUN CGO_ENABLED=1 GOTOOLCHAIN=auto go build -ldflags "-X main.Version=${VERSION}" -o /pixad ./cmd/pixad RUN CGO_ENABLED=1 GOTOOLCHAIN=auto go build -ldflags "-X main.Version=${VERSION}" -o /pixad ./cmd/pixad

View File

@@ -98,7 +98,9 @@ expiration 1704067200:
**Whitelist patterns:** **Whitelist patterns:**
- **Exact match only**: `cdn.example.com` — matches only that host - **Exact match**: `cdn.example.com` — matches only that host
- **Suffix match**: `.example.com` — matches `cdn.example.com`,
`images.example.com`, and `example.com`
### Configuration ### Configuration

101
TODO.md
View File

@@ -1,65 +1,64 @@
# Pixa 1.0 TODO # Status
Remaining tasks sorted by priority for a working 1.0 release. pre-1.0. No git tags. Full policy file set is present, but make check
fails on main with 10 gosec lint findings, so main is not green.
## P0: Critical for 1.0 # Next Step
### Image Processing Fix the 10 gosec findings so make check passes on main. Fix each issue
- [x] Add WebP encoding support (currently returns error) properly (no blanket nolint suppressions) and confirm lint, tests, and
- [x] Add AVIF encoding support (implemented via govips) CI are green. This restores the main-always-green policy.
### Manual Testing (verify auth/encrypted URLs work) # Completed Steps
- [ ] Manual test: visit `/`, see login form
- [ ] Manual test: enter wrong key, see error
- [ ] Manual test: enter correct signing key, see generator form
- [ ] Manual test: generate encrypted URL, verify it works
- [ ] Manual test: wait for expiration or use short TTL, verify expired URL returns 410
- [ ] Manual test: logout, verify redirected to login
### Cache Management - 2026-02-25: Docker arm64 build fixed via architecture detection for
- [ ] Implement cache size management/eviction (prevent disk from filling up) golangci-lint download (#16)
- 2026-02-25: full repo policy compliance pass (#14): LICENSE,
REPO_POLICIES.md, .editorconfig, Gitea Actions CI workflow, Docker
images pinned by hash, README restructured to policy sections, hooks
Makefile target, golangci-lint errors resolved
- 2026-02-20: stale local dev config files removed (#12)
- 2026-02-09: correctness fixes: negative cache checked in Service.Get()
before upstream fetch (#8); Stats() column scanning and HitRate
computation corrected (#9)
- 2026-02-08: AllowHTTP propagated to SourceURL() scheme selection (#6)
- WebP encoding support added (P0 item, checked off in TODO)
- 2026-01-08: initial implementation with Makefile and TODO checklist
### Configuration # Future Steps
- [ ] Validate configuration on startup (fail fast on bad config)
## P1: Important for Production Compliance:
### Security - After the gosec fix lands, verify CI runs make check green on main
- [ ] Implement blocked networks configuration (extend SSRF protection)
- [ ] Add rate limiting global concurrent fetches (prevent resource exhaustion)
### Image Processing P0, critical for 1.0 (from existing TODO.md):
- [ ] Implement EXIF/metadata stripping (privacy)
## P2: Nice to Have - Add AVIF encoding support (currently returns error)
- Manual test pass for auth and encrypted URLs:
- visit /, see login form
- wrong key shows error; correct signing key shows generator form
- generated encrypted URL works
- expired URL (short TTL) returns 410
- logout redirects to login
- Implement cache size management and eviction (prevent disk fill)
- Validate configuration on startup (fail fast on bad config)
### Security P1, important for production:
- [ ] Implement referer blacklist
- [ ] Add rate limiting per-IP
- [ ] Add rate limiting per-origin
### HTTP Response Handling - Blocked networks configuration (extend SSRF protection)
- [ ] Implement Last-Modified headers - Rate limit global concurrent fetches (resource exhaustion)
- [ ] Implement Vary header for content negotiation - EXIF/metadata stripping (privacy)
- [ ] Implement X-Request-ID propagation
### Additional Endpoints P2, nice to have:
- [ ] Implement auto-format selection (format=auto based on Accept header)
### Configuration - Referer blacklist; per-IP and per-origin rate limiting
- [ ] Add all configuration options from README - Last-Modified headers; Vary header; X-Request-ID propagation
- [ ] Implement environment variable overrides - Auto-format selection (format=auto based on Accept header)
- [ ] Implement YAML config file support - All configuration options from README; env var overrides; YAML config
file support
### Operational - Sentry error reporting (optional); comprehensive request logging;
- [ ] Implement Sentry error reporting (optional) Prometheus metrics
- [ ] Add comprehensive request logging - Integration tests for the image proxy flow; load tests for the
- [ ] Add performance metrics (Prometheus) 1-5k req/s target
- [ ] Write integration tests for image proxy flow - Docs: configuration options, API endpoints, deployment guide, example
- [ ] Write load tests to verify 1-5k req/s target nginx/caddy reverse proxy config
### Documentation
- [ ] Document configuration options
- [ ] Document API endpoints
- [ ] Document deployment guide
- [ ] Add example nginx/caddy reverse proxy config

View File

@@ -13,7 +13,8 @@ state_dir: ./data
# Generate with: openssl rand -base64 32 # Generate with: openssl rand -base64 32
signing_key: "CHANGE_ME_generate_with_openssl_rand_base64_32" signing_key: "CHANGE_ME_generate_with_openssl_rand_base64_32"
# Hosts that don't require signatures (exact match only) # Hosts that don't require signatures
# Use "." prefix for wildcard subdomain matching (e.g., ".example.com" matches "cdn.example.com")
whitelist_hosts: whitelist_hosts:
- s3.sneak.cloud - s3.sneak.cloud
- static.sneak.cloud - static.sneak.cloud

View File

@@ -6,19 +6,22 @@ import (
) )
// HostWhitelist implements the Whitelist interface for checking allowed source hosts. // HostWhitelist implements the Whitelist interface for checking allowed source hosts.
// Only exact host matches are supported. Leading dots in patterns are stripped
// (e.g. ".example.com" becomes an exact match for "example.com").
type HostWhitelist struct { type HostWhitelist struct {
// hosts contains hosts that must match exactly (e.g., "cdn.example.com") // exactHosts contains hosts that must match exactly (e.g., "cdn.example.com")
hosts map[string]struct{} exactHosts map[string]struct{}
// suffixHosts contains domain suffixes to match (e.g., ".example.com" matches "cdn.example.com")
suffixHosts []string
} }
// NewHostWhitelist creates a whitelist from a list of host patterns. // NewHostWhitelist creates a whitelist from a list of host patterns.
// All patterns are treated as exact matches. Leading dots are stripped // Patterns starting with "." are treated as suffix matches.
// for backwards compatibility (e.g. ".example.com" matches "example.com" only). // Examples:
// - "cdn.example.com" - exact match only
// - ".example.com" - matches cdn.example.com, images.example.com, etc.
func NewHostWhitelist(patterns []string) *HostWhitelist { func NewHostWhitelist(patterns []string) *HostWhitelist {
w := &HostWhitelist{ w := &HostWhitelist{
hosts: make(map[string]struct{}), exactHosts: make(map[string]struct{}),
suffixHosts: make([]string, 0),
} }
for _, pattern := range patterns { for _, pattern := range patterns {
@@ -27,22 +30,17 @@ func NewHostWhitelist(patterns []string) *HostWhitelist {
continue continue
} }
// Strip leading dot — suffix matching is not supported. if strings.HasPrefix(pattern, ".") {
// ".example.com" is treated as exact match for "example.com". w.suffixHosts = append(w.suffixHosts, pattern)
pattern = strings.TrimPrefix(pattern, ".") } else {
w.exactHosts[pattern] = struct{}{}
if pattern == "" {
continue
} }
w.hosts[pattern] = struct{}{}
} }
return w return w
} }
// IsWhitelisted checks if a URL's host is in the whitelist. // IsWhitelisted checks if a URL's host is in the whitelist.
// Only exact host matches are supported.
func (w *HostWhitelist) IsWhitelisted(u *url.URL) bool { func (w *HostWhitelist) IsWhitelisted(u *url.URL) bool {
if u == nil { if u == nil {
return false return false
@@ -53,17 +51,32 @@ func (w *HostWhitelist) IsWhitelisted(u *url.URL) bool {
return false return false
} }
_, ok := w.hosts[host] // Check exact match
if _, ok := w.exactHosts[host]; ok {
return true
}
return ok // Check suffix match
for _, suffix := range w.suffixHosts {
if strings.HasSuffix(host, suffix) {
return true
}
// Also match if host equals the suffix without the leading dot
// e.g., pattern ".example.com" should match "example.com"
if host == strings.TrimPrefix(suffix, ".") {
return true
}
}
return false
} }
// IsEmpty returns true if the whitelist has no entries. // IsEmpty returns true if the whitelist has no entries.
func (w *HostWhitelist) IsEmpty() bool { func (w *HostWhitelist) IsEmpty() bool {
return len(w.hosts) == 0 return len(w.exactHosts) == 0 && len(w.suffixHosts) == 0
} }
// Count returns the total number of whitelist entries. // Count returns the total number of whitelist entries.
func (w *HostWhitelist) Count() int { func (w *HostWhitelist) Count() int {
return len(w.hosts) return len(w.exactHosts) + len(w.suffixHosts)
} }

View File

@@ -31,46 +31,40 @@ func TestHostWhitelist_IsWhitelisted(t *testing.T) {
want: false, want: false,
}, },
{ {
name: "dot prefix does not enable suffix matching", name: "suffix match",
patterns: []string{".example.com"}, patterns: []string{".example.com"},
testURL: "https://cdn.example.com/image.jpg", testURL: "https://cdn.example.com/image.jpg",
want: false, want: true,
}, },
{ {
name: "dot prefix does not match deep subdomain", name: "suffix match deep subdomain",
patterns: []string{".example.com"}, patterns: []string{".example.com"},
testURL: "https://cdn.images.example.com/image.jpg", testURL: "https://cdn.images.example.com/image.jpg",
want: false, want: true,
}, },
{ {
name: "dot prefix stripped matches apex domain exactly", name: "suffix match apex domain",
patterns: []string{".example.com"}, patterns: []string{".example.com"},
testURL: "https://example.com/image.jpg", testURL: "https://example.com/image.jpg",
want: true, want: true,
}, },
{ {
name: "dot prefix does not match unrelated domain", name: "suffix match not found",
patterns: []string{".example.com"}, patterns: []string{".example.com"},
testURL: "https://notexample.com/image.jpg", testURL: "https://notexample.com/image.jpg",
want: false, want: false,
}, },
{ {
name: "dot prefix does not match partial domain", name: "suffix match partial not allowed",
patterns: []string{".example.com"}, patterns: []string{".example.com"},
testURL: "https://fakeexample.com/image.jpg", testURL: "https://fakeexample.com/image.jpg",
want: false, want: false,
}, },
{ {
name: "multiple patterns exact only", name: "multiple patterns",
patterns: []string{"cdn.example.com", "photos.images.org", "static.test.net"},
testURL: "https://photos.images.org/image.jpg",
want: true,
},
{
name: "multiple patterns no suffix match",
patterns: []string{"cdn.example.com", ".images.org", "static.test.net"}, patterns: []string{"cdn.example.com", ".images.org", "static.test.net"},
testURL: "https://photos.images.org/image.jpg", testURL: "https://photos.images.org/image.jpg",
want: false, want: true,
}, },
{ {
name: "empty whitelist", name: "empty whitelist",
@@ -96,12 +90,6 @@ func TestHostWhitelist_IsWhitelisted(t *testing.T) {
testURL: "https://cdn.example.com/image.jpg", testURL: "https://cdn.example.com/image.jpg",
want: true, want: true,
}, },
{
name: "whitespace dot prefix stripped matches exactly",
patterns: []string{" .other.com "},
testURL: "https://other.com/image.jpg",
want: true,
},
} }
for _, tt := range tests { for _, tt := range tests {
@@ -151,11 +139,6 @@ func TestHostWhitelist_IsEmpty(t *testing.T) {
patterns: []string{"example.com"}, patterns: []string{"example.com"},
want: false, want: false,
}, },
{
name: "dot prefix entry still counts",
patterns: []string{".example.com"},
want: false,
},
} }
for _, tt := range tests { for _, tt := range tests {
@@ -185,7 +168,7 @@ func TestHostWhitelist_Count(t *testing.T) {
want: 3, want: 3,
}, },
{ {
name: "dot prefix hosts treated as exact", name: "suffix hosts only",
patterns: []string{".a.com", ".b.com"}, patterns: []string{".a.com", ".b.com"},
want: 2, want: 2,
}, },
@@ -194,11 +177,6 @@ func TestHostWhitelist_Count(t *testing.T) {
patterns: []string{"exact.com", ".suffix.com"}, patterns: []string{"exact.com", ".suffix.com"},
want: 2, want: 2,
}, },
{
name: "dot prefix deduplicates with exact",
patterns: []string{"example.com", ".example.com"},
want: 1,
},
} }
for _, tt := range tests { for _, tt := range tests {

View File

@@ -48,7 +48,7 @@ fi
# Test 3: Wrong password shows error # Test 3: Wrong password shows error
echo "--- Test 3: Login with wrong password ---" echo "--- Test 3: Login with wrong password ---"
WRONG_LOGIN=$(curl -sf -X POST "$BASE_URL/" -d "key=wrong-key" -c "$COOKIE_JAR") WRONG_LOGIN=$(curl -sf -X POST "$BASE_URL/" -d "password=wrong-key" -c "$COOKIE_JAR")
if echo "$WRONG_LOGIN" | grep -qi "invalid\|error\|incorrect\|wrong"; then if echo "$WRONG_LOGIN" | grep -qi "invalid\|error\|incorrect\|wrong"; then
pass "Wrong password shows error message" pass "Wrong password shows error message"
else else
@@ -57,7 +57,7 @@ fi
# Test 4: Correct password redirects to generator # Test 4: Correct password redirects to generator
echo "--- Test 4: Login with correct signing key ---" 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 curl -sf -X POST "$BASE_URL/" -d "password=$SIGNING_KEY" -c "$COOKIE_JAR" -b "$COOKIE_JAR" -L -o /dev/null
GENERATOR_PAGE=$(curl -sf "$BASE_URL/" -b "$COOKIE_JAR") GENERATOR_PAGE=$(curl -sf "$BASE_URL/" -b "$COOKIE_JAR")
if echo "$GENERATOR_PAGE" | grep -qi "generate\|url\|source\|logout"; then if echo "$GENERATOR_PAGE" | grep -qi "generate\|url\|source\|logout"; then
pass "Correct password shows generator page" pass "Correct password shows generator page"
@@ -68,12 +68,12 @@ fi
# Test 5: Generate encrypted URL # Test 5: Generate encrypted URL
echo "--- Test 5: Generate encrypted URL ---" echo "--- Test 5: Generate encrypted URL ---"
GEN_RESULT=$(curl -sf -X POST "$BASE_URL/generate" -b "$COOKIE_JAR" \ GEN_RESULT=$(curl -sf -X POST "$BASE_URL/generate" -b "$COOKIE_JAR" \
-d "url=$TEST_IMAGE_URL" \ -d "source_url=$TEST_IMAGE_URL" \
-d "width=800" \ -d "width=800" \
-d "height=600" \ -d "height=600" \
-d "format=jpeg" \ -d "format=jpeg" \
-d "quality=85" \ -d "quality=85" \
-d "fit=cover" \ -d "fit_mode=cover" \
-d "ttl=3600") -d "ttl=3600")
if echo "$GEN_RESULT" | grep -q "/v1/e/"; then if echo "$GEN_RESULT" | grep -q "/v1/e/"; then
pass "Encrypted URL generated" pass "Encrypted URL generated"
@@ -121,10 +121,10 @@ fi
# Test 9: Generate short-TTL URL and verify expiration # Test 9: Generate short-TTL URL and verify expiration
echo "--- Test 9: Expired URL returns 410 ---" echo "--- Test 9: Expired URL returns 410 ---"
# Login again # Login again
curl -sf -X POST "$BASE_URL/" -d "key=$SIGNING_KEY" -c "$COOKIE_JAR" -b "$COOKIE_JAR" -L -o /dev/null curl -sf -X POST "$BASE_URL/" -d "password=$SIGNING_KEY" -c "$COOKIE_JAR" -b "$COOKIE_JAR" -L -o /dev/null
# Generate URL with 1 second TTL # Generate URL with 1 second TTL
GEN_RESULT=$(curl -sf -X POST "$BASE_URL/generate" -b "$COOKIE_JAR" \ GEN_RESULT=$(curl -sf -X POST "$BASE_URL/generate" -b "$COOKIE_JAR" \
-d "url=$TEST_IMAGE_URL" \ -d "source_url=$TEST_IMAGE_URL" \
-d "width=100" \ -d "width=100" \
-d "height=100" \ -d "height=100" \
-d "format=jpeg" \ -d "format=jpeg" \