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
# golang:1.25.4-alpine, 2026-02-25
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
# Install build dependencies for CGO image libraries
@@ -31,7 +9,25 @@ RUN apk add --no-cache \
build-base \
vips-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
@@ -42,8 +38,8 @@ RUN GOTOOLCHAIN=auto go mod download
# Copy source code
COPY . .
# Run tests
RUN make test
# Run all checks (fmt-check, lint, test)
RUN make check
# Build with CGO enabled
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:**
- **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

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
- [x] Add WebP encoding support (currently returns error)
- [x] Add AVIF encoding support (implemented via govips)
Fix the 10 gosec findings so make check passes on main. Fix each issue
properly (no blanket nolint suppressions) and confirm lint, tests, and
CI are green. This restores the main-always-green policy.
### Manual Testing (verify auth/encrypted URLs work)
- [ ] 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
# Completed Steps
### Cache Management
- [ ] Implement cache size management/eviction (prevent disk from filling up)
- 2026-02-25: Docker arm64 build fixed via architecture detection for
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
- [ ] Validate configuration on startup (fail fast on bad config)
# Future Steps
## P1: Important for Production
Compliance:
### Security
- [ ] Implement blocked networks configuration (extend SSRF protection)
- [ ] Add rate limiting global concurrent fetches (prevent resource exhaustion)
- After the gosec fix lands, verify CI runs make check green on main
### Image Processing
- [ ] Implement EXIF/metadata stripping (privacy)
P0, critical for 1.0 (from existing TODO.md):
## 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
- [ ] Implement referer blacklist
- [ ] Add rate limiting per-IP
- [ ] Add rate limiting per-origin
P1, important for production:
### HTTP Response Handling
- [ ] Implement Last-Modified headers
- [ ] Implement Vary header for content negotiation
- [ ] Implement X-Request-ID propagation
- Blocked networks configuration (extend SSRF protection)
- Rate limit global concurrent fetches (resource exhaustion)
- EXIF/metadata stripping (privacy)
### Additional Endpoints
- [ ] Implement auto-format selection (format=auto based on Accept header)
P2, nice to have:
### Configuration
- [ ] Add all configuration options from README
- [ ] Implement environment variable overrides
- [ ] Implement YAML config file support
### Operational
- [ ] Implement Sentry error reporting (optional)
- [ ] Add comprehensive request logging
- [ ] Add performance metrics (Prometheus)
- [ ] Write integration tests for image proxy flow
- [ ] Write load tests to verify 1-5k req/s target
### Documentation
- [ ] Document configuration options
- [ ] Document API endpoints
- [ ] Document deployment guide
- [ ] Add example nginx/caddy reverse proxy config
- Referer blacklist; per-IP and per-origin rate limiting
- Last-Modified headers; Vary header; X-Request-ID propagation
- Auto-format selection (format=auto based on Accept header)
- All configuration options from README; env var overrides; YAML config
file support
- Sentry error reporting (optional); comprehensive request logging;
Prometheus metrics
- Integration tests for the image proxy flow; load tests for the
1-5k req/s target
- Docs: configuration options, API endpoints, deployment guide, example
nginx/caddy reverse proxy config

View File

@@ -13,7 +13,8 @@ state_dir: ./data
# 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:
- s3.sneak.cloud
- static.sneak.cloud

View File

@@ -6,19 +6,22 @@ import (
)
// 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 {
// hosts contains hosts that must match exactly (e.g., "cdn.example.com")
hosts map[string]struct{}
// exactHosts contains hosts that must match exactly (e.g., "cdn.example.com")
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.
// All patterns are treated as exact matches. Leading dots are stripped
// for backwards compatibility (e.g. ".example.com" matches "example.com" only).
// Patterns starting with "." are treated as suffix matches.
// Examples:
// - "cdn.example.com" - exact match only
// - ".example.com" - matches cdn.example.com, images.example.com, etc.
func NewHostWhitelist(patterns []string) *HostWhitelist {
w := &HostWhitelist{
hosts: make(map[string]struct{}),
exactHosts: make(map[string]struct{}),
suffixHosts: make([]string, 0),
}
for _, pattern := range patterns {
@@ -27,22 +30,17 @@ func NewHostWhitelist(patterns []string) *HostWhitelist {
continue
}
// Strip leading dot — suffix matching is not supported.
// ".example.com" is treated as exact match for "example.com".
pattern = strings.TrimPrefix(pattern, ".")
if pattern == "" {
continue
if strings.HasPrefix(pattern, ".") {
w.suffixHosts = append(w.suffixHosts, pattern)
} else {
w.exactHosts[pattern] = struct{}{}
}
w.hosts[pattern] = struct{}{}
}
return w
}
// 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 {
if u == nil {
return false
@@ -53,17 +51,32 @@ func (w *HostWhitelist) IsWhitelisted(u *url.URL) bool {
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.
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.
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,
},
{
name: "dot prefix does not enable suffix matching",
name: "suffix match",
patterns: []string{".example.com"},
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"},
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"},
testURL: "https://example.com/image.jpg",
want: true,
},
{
name: "dot prefix does not match unrelated domain",
name: "suffix match not found",
patterns: []string{".example.com"},
testURL: "https://notexample.com/image.jpg",
want: false,
},
{
name: "dot prefix does not match partial domain",
name: "suffix match partial not allowed",
patterns: []string{".example.com"},
testURL: "https://fakeexample.com/image.jpg",
want: false,
},
{
name: "multiple patterns exact only",
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",
name: "multiple patterns",
patterns: []string{"cdn.example.com", ".images.org", "static.test.net"},
testURL: "https://photos.images.org/image.jpg",
want: false,
want: true,
},
{
name: "empty whitelist",
@@ -96,12 +90,6 @@ func TestHostWhitelist_IsWhitelisted(t *testing.T) {
testURL: "https://cdn.example.com/image.jpg",
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 {
@@ -151,11 +139,6 @@ func TestHostWhitelist_IsEmpty(t *testing.T) {
patterns: []string{"example.com"},
want: false,
},
{
name: "dot prefix entry still counts",
patterns: []string{".example.com"},
want: false,
},
}
for _, tt := range tests {
@@ -185,7 +168,7 @@ func TestHostWhitelist_Count(t *testing.T) {
want: 3,
},
{
name: "dot prefix hosts treated as exact",
name: "suffix hosts only",
patterns: []string{".a.com", ".b.com"},
want: 2,
},
@@ -194,11 +177,6 @@ func TestHostWhitelist_Count(t *testing.T) {
patterns: []string{"exact.com", ".suffix.com"},
want: 2,
},
{
name: "dot prefix deduplicates with exact",
patterns: []string{"example.com", ".example.com"},
want: 1,
},
}
for _, tt := range tests {

View File

@@ -48,7 +48,7 @@ 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")
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
pass "Wrong password shows error message"
else
@@ -57,7 +57,7 @@ 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
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")
if echo "$GENERATOR_PAGE" | grep -qi "generate\|url\|source\|logout"; then
pass "Correct password shows generator page"
@@ -68,12 +68,12 @@ 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 "source_url=$TEST_IMAGE_URL" \
-d "width=800" \
-d "height=600" \
-d "format=jpeg" \
-d "quality=85" \
-d "fit=cover" \
-d "fit_mode=cover" \
-d "ttl=3600")
if echo "$GEN_RESULT" | grep -q "/v1/e/"; then
pass "Encrypted URL generated"
@@ -121,10 +121,10 @@ 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
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
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 "height=100" \
-d "format=jpeg" \