Merge feature/auth-and-encrypted-urls: Add login, sessions, and encrypted URLs

This commit is contained in:
2026-01-08 07:41:28 -08:00
24 changed files with 2771 additions and 8 deletions

View File

@@ -57,3 +57,12 @@ These rules MUST be followed at all times, it is very important.
* As this is production code, no stubbing of implementations unless
specifically instructed. We need working implementations.
* Avoid vendoring deps unless specifically instructed to. NEVER commit
the vendor directory, NEVER commit compiled binaries. If these
directories or files exist, add them to .gitignore (and commit the
.gitignore) if they are not already in there. Keep the entire git
repository (with history) small - under 20MiB, unless you specifically
must commit larger files (e.g. test fixture example media files). Only
OUR source code and immediately supporting files (such as test examples)
goes into the repo/history.

113
TODO.md
View File

@@ -2,6 +2,119 @@
A single linear checklist of tasks to implement the complete pixa caching image reverse proxy server.
## Login, Sessions & Encrypted URLs Feature
### Phase 1: Crypto Foundation
- [ ] Create `internal/crypto/crypto.go` with:
- [ ] `DeriveKey(masterKey []byte, salt string) ([32]byte, error)` - HKDF-SHA256 key derivation
- [ ] `Encrypt(key [32]byte, plaintext []byte) (string, error)` - NaCl secretbox encrypt, returns base64url
- [ ] `Decrypt(key [32]byte, ciphertext string) ([]byte, error)` - NaCl secretbox decrypt from base64url
- [ ] Create `internal/crypto/crypto_test.go` with tests for:
- [ ] Key derivation produces consistent keys for same input
- [ ] Encrypt/decrypt round-trip
- [ ] Decryption fails with wrong key
- [ ] Decryption fails with tampered ciphertext
### Phase 2: Session Management
- [ ] Add `github.com/gorilla/securecookie` dependency
- [ ] Create `internal/session/session.go` with:
- [ ] `SessionData` struct: `Authenticated bool`, `CreatedAt time.Time`, `ExpiresAt time.Time`
- [ ] `Manager` struct using gorilla/securecookie with keys derived via HKDF
- [ ] `NewManager(signingKey string, secure bool) (*Manager, error)`
- [ ] `CreateSession(w http.ResponseWriter) error` - creates 30-day encrypted cookie
- [ ] `ValidateSession(r *http.Request) (*SessionData, error)` - decrypts and validates cookie
- [ ] `ClearSession(w http.ResponseWriter)` - clears cookie (logout)
- [ ] `IsAuthenticated(r *http.Request) bool` - convenience wrapper
- [ ] Cookie settings: `HttpOnly`, `Secure` (prod), `SameSite=Strict`, name `pixa_session`
- [ ] Create `internal/session/session_test.go` with tests for:
- [ ] Session creation and validation round-trip
- [ ] Expired session rejection
- [ ] Tampered cookie rejection
- [ ] Missing cookie returns unauthenticated
### Phase 3: Encrypted URL Generation
- [ ] Add `github.com/fxamacker/cbor/v2` dependency for compact binary encoding
- [ ] Create `internal/encurl/encurl.go` with:
- [ ] `EncryptedPayload` struct (short CBOR field names, omitempty for fields with sane defaults):
- `SourceHost string` (`cbor:"h"`) - required
- `SourcePath string` (`cbor:"p"`) - required
- `SourceQuery string` (`cbor:"q,omitempty"`) - optional
- `Width int` (`cbor:"w,omitempty"`) - 0 = original
- `Height int` (`cbor:"ht,omitempty"`) - 0 = original
- `Format ImageFormat` (`cbor:"f,omitempty"`) - default: orig
- `Quality int` (`cbor:"ql,omitempty"`) - default: 85
- `FitMode FitMode` (`cbor:"fm,omitempty"`) - default: cover
- `ExpiresAt int64` (`cbor:"e"`) - required, Unix timestamp
- [ ] `Generator` struct with URL key derived via HKDF (salt: `"pixa-urlenc-v1"`)
- [ ] `NewGenerator(signingKey string) (*Generator, error)`
- [ ] `Generate(p *EncryptedPayload) (string, error)` - CBOR encode, encrypt, base64url
- [ ] `Parse(token string) (*EncryptedPayload, error)` - base64url decode, decrypt, CBOR decode, validate expiration
- [ ] `(p *EncryptedPayload) ToImageRequest() *imgcache.ImageRequest`
- [ ] Create `internal/encurl/encurl_test.go` with tests for:
- [ ] Generate/parse round-trip preserves all fields
- [ ] Expired URL returns `ErrExpired`
- [ ] Malformed token returns error
- [ ] Tampered token fails decryption
### Phase 4: HTML Templates
- [ ] Create `templates/templates.go` with:
- [ ] `//go:embed *.html` for embedded templates
- [ ] `GetParsed() *template.Template` function
- [ ] Create `templates/login.html`:
- [ ] Simple form with password input for signing key
- [ ] POST to `/`
- [ ] Error message display area
- [ ] Minimal inline CSS
- [ ] Create `templates/generator.html`:
- [ ] Logout link at top
- [ ] Form with fields: Source URL, Width, Height, Format (dropdown), Quality, Fit Mode (dropdown), Expiration TTL (dropdown)
- [ ] POST to `/generate`
- [ ] Result display area showing generated URL and expiration
- [ ] Click-to-copy input field for generated URL
### Phase 5: Auth Handlers
- [ ] Create `internal/handlers/auth.go` with:
- [ ] `HandleRoot() http.HandlerFunc` - serves login form (GET) or authenticates (POST) if not logged in; serves generator form if logged in
- [ ] `handleLoginGet(w, r)` - renders login.html template
- [ ] `handleLoginPost(w, r)` - validates key with constant-time comparison, creates session on success
- [ ] `HandleLogout() http.HandlerFunc` - clears session, redirects to `/`
- [ ] `HandleGenerateURL() http.HandlerFunc` - parses form, generates encrypted URL, renders generator.html with result
### Phase 6: Encrypted Image Handler
- [ ] Create `internal/handlers/imageenc.go` with:
- [ ] `HandleImageEnc() http.HandlerFunc` - handles `/v1/e/{token}`
- [ ] Extract token from chi URL param
- [ ] Decrypt and validate via `encGen.Parse(token)`
- [ ] Convert to `ImageRequest` via `payload.ToImageRequest()`
- [ ] Serve via `imgSvc.Get()` (bypass signature validation - encrypted URL is trusted)
- [ ] Set same response headers as regular image handler (Cache-Control, Content-Type, etc.)
- [ ] Handle errors: expired → 410 Gone, decrypt fail → 400 Bad Request
### Phase 7: Handler Integration
- [ ] Modify `internal/handlers/handlers.go`:
- [ ] Add `sessMgr *session.Manager` field to `Handlers` struct
- [ ] Add `encGen *encurl.Generator` field to `Handlers` struct
- [ ] Add `templates *template.Template` field to `Handlers` struct
- [ ] Initialize session manager and URL generator in `initImageService()` or new init method
- [ ] Add import for `internal/session`, `internal/encurl`, `templates`
### Phase 8: Route Registration
- [ ] Modify `internal/server/routes.go`:
- [ ] Add `s.router.Get("/", s.h.HandleRoot())` - login or generator page
- [ ] Add `s.router.Post("/", s.h.HandleRoot())` - login form submission
- [ ] Add `s.router.Get("/logout", s.h.HandleLogout())` - logout
- [ ] Add `s.router.Post("/generate", s.h.HandleGenerateURL())` - URL generation
- [ ] Add `s.router.Get("/v1/e/{token}", s.h.HandleImageEnc())` - encrypted image serving
### Phase 9: Testing & Verification
- [ ] Run `make check` to verify lint and tests pass
- [ ] 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
## Project Setup
- [x] Create Makefile with check, lint, test, fmt targets
- [x] Create project structure (cmd/pixad, internal/*)

5
go.mod
View File

@@ -13,6 +13,7 @@ require (
github.com/slok/go-http-metrics v0.13.0
github.com/spf13/cobra v1.10.2
go.uber.org/fx v1.24.0
golang.org/x/crypto v0.41.0
golang.org/x/image v0.34.0
modernc.org/sqlite v1.42.2
)
@@ -54,7 +55,7 @@ require (
github.com/emicklei/go-restful/v3 v3.12.1 // indirect
github.com/fatih/color v1.16.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/go-jose/go-jose/v4 v4.0.5 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
@@ -70,6 +71,7 @@ require (
github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect
github.com/googleapis/gax-go/v2 v2.14.2 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect
github.com/hashicorp/consul/api v1.32.1 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
@@ -124,7 +126,6 @@ require (
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/crypto v0.41.0 // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/net v0.43.0 // indirect
golang.org/x/oauth2 v0.30.0 // indirect

4
go.sum
View File

@@ -108,6 +108,8 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/getsentry/sentry-go v0.40.0 h1:VTJMN9zbTvqDqPwheRVLcp0qcUcM+8eFivvGocAaSbo=
github.com/getsentry/sentry-go v0.40.0/go.mod h1:eRXCoh3uvmjQLY6qu63BjUZnaBu5L5WhMV1RwYO8W5s=
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
@@ -172,6 +174,8 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU
github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
github.com/googleapis/gax-go/v2 v2.14.2 h1:eBLnkZ9635krYIPD+ag1USrOAI0Nr0QYF3+/3GqO0k0=
github.com/googleapis/gax-go/v2 v2.14.2/go.mod h1:ON64QhlJkhVtSqp4v1uaK92VyZ2gmvDQsweuyLV+8+w=
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI=
github.com/hashicorp/consul/api v1.32.1 h1:0+osr/3t/aZNAdJX558crU3PEjVrG4x6715aZHRgceE=

View File

@@ -166,3 +166,77 @@ func (s *Database) runMigrations(ctx context.Context) error {
func (s *Database) DB() *sql.DB {
return s.db
}
// ApplyMigrations applies all migrations to the given database.
// This is useful for testing where you want to use the real schema
// without the full fx lifecycle.
func ApplyMigrations(db *sql.DB) error {
ctx := context.Background()
// Create migrations tracking table
_, err := db.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS schema_migrations (
version TEXT PRIMARY KEY,
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`)
if err != nil {
return fmt.Errorf("failed to create migrations table: %w", err)
}
// Get list of migration files
entries, err := schemaFS.ReadDir("schema")
if err != nil {
return fmt.Errorf("failed to read schema directory: %w", err)
}
// Sort migration files by name (001.sql, 002.sql, etc.)
var migrations []string
for _, entry := range entries {
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".sql") {
migrations = append(migrations, entry.Name())
}
}
sort.Strings(migrations)
// Apply each migration that hasn't been applied yet
for _, migration := range migrations {
version := strings.TrimSuffix(migration, filepath.Ext(migration))
// Check if already applied
var count int
err := db.QueryRowContext(ctx,
"SELECT COUNT(*) FROM schema_migrations WHERE version = ?",
version,
).Scan(&count)
if err != nil {
return fmt.Errorf("failed to check migration status: %w", err)
}
if count > 0 {
continue
}
// Read and apply migration
content, err := schemaFS.ReadFile(filepath.Join("schema", migration))
if err != nil {
return fmt.Errorf("failed to read migration %s: %w", migration, err)
}
_, err = db.ExecContext(ctx, string(content))
if err != nil {
return fmt.Errorf("failed to apply migration %s: %w", migration, err)
}
// Record migration as applied
_, err = db.ExecContext(ctx,
"INSERT INTO schema_migrations (version) VALUES (?)",
version,
)
if err != nil {
return fmt.Errorf("failed to record migration %s: %w", migration, err)
}
}
return nil
}

158
internal/encurl/encurl.go Normal file
View File

@@ -0,0 +1,158 @@
// Package encurl provides encrypted URL generation and parsing for pixa.
package encurl
import (
"errors"
"time"
"github.com/fxamacker/cbor/v2"
"sneak.berlin/go/pixa/internal/imgcache"
"sneak.berlin/go/pixa/internal/seal"
)
// Default values for optional fields.
const (
DefaultQuality = 85
DefaultFormat = imgcache.FormatOriginal
DefaultFitMode = imgcache.FitCover
// HKDF salt for URL encryption key derivation
urlKeySalt = "pixa-urlenc-v1"
)
// Errors returned by encurl operations.
var (
ErrExpired = errors.New("encrypted URL has expired")
ErrInvalidFormat = errors.New("invalid encrypted URL format")
ErrDecryptFailed = errors.New("failed to decrypt URL")
)
// Payload contains all image request parameters in a compact format.
// Field names are short to minimize encoded size.
// Fields with omitempty use sane defaults when absent.
type Payload struct {
SourceHost string `cbor:"h"` // required
SourcePath string `cbor:"p"` // required
SourceQuery string `cbor:"q,omitempty"` // optional
Width int `cbor:"w,omitempty"` // 0 = original
Height int `cbor:"ht,omitempty"` // 0 = original
Format imgcache.ImageFormat `cbor:"f,omitempty"` // default: orig
Quality int `cbor:"ql,omitempty"` // default: 85
FitMode imgcache.FitMode `cbor:"fm,omitempty"` // default: cover
ExpiresAt int64 `cbor:"e"` // required, Unix timestamp
}
// Generator creates and parses encrypted URL tokens.
type Generator struct {
key [seal.KeySize]byte
}
// NewGenerator creates an encrypted URL generator with a key derived from the signing key.
func NewGenerator(signingKey string) (*Generator, error) {
key, err := seal.DeriveKey([]byte(signingKey), urlKeySalt)
if err != nil {
return nil, err
}
return &Generator{key: key}, nil
}
// Generate creates an encrypted URL token from the payload.
// The token is CBOR-encoded, encrypted with NaCl secretbox, and base64url-encoded.
func (g *Generator) Generate(p *Payload) (string, error) {
// CBOR encode the payload
data, err := cbor.Marshal(p)
if err != nil {
return "", err
}
// Encrypt and base64url encode
return seal.Encrypt(g.key, data)
}
// Parse decrypts and validates an encrypted URL token.
// Returns ErrExpired if the token has expired, or other errors for invalid tokens.
func (g *Generator) Parse(token string) (*Payload, error) {
// Decrypt
data, err := seal.Decrypt(g.key, token)
if err != nil {
if errors.Is(err, seal.ErrDecryptionFailed) || errors.Is(err, seal.ErrInvalidPayload) {
return nil, ErrDecryptFailed
}
return nil, err
}
// CBOR decode
var p Payload
if err := cbor.Unmarshal(data, &p); err != nil {
return nil, ErrInvalidFormat
}
// Check expiration
if time.Now().Unix() > p.ExpiresAt {
return nil, ErrExpired
}
return &p, nil
}
// ToImageRequest converts the payload to an ImageRequest.
// Applies default values for omitted optional fields.
func (p *Payload) ToImageRequest() *imgcache.ImageRequest {
format := p.Format
if format == "" {
format = DefaultFormat
}
quality := p.Quality
if quality == 0 {
quality = DefaultQuality
}
fitMode := p.FitMode
if fitMode == "" {
fitMode = DefaultFitMode
}
return &imgcache.ImageRequest{
SourceHost: p.SourceHost,
SourcePath: p.SourcePath,
SourceQuery: p.SourceQuery,
Size: imgcache.Size{
Width: p.Width,
Height: p.Height,
},
Format: format,
Quality: quality,
FitMode: fitMode,
}
}
// FromImageRequest creates a Payload from an ImageRequest with the given expiration.
func FromImageRequest(req *imgcache.ImageRequest, expiresAt time.Time) *Payload {
p := &Payload{
SourceHost: req.SourceHost,
SourcePath: req.SourcePath,
SourceQuery: req.SourceQuery,
Width: req.Size.Width,
Height: req.Size.Height,
ExpiresAt: expiresAt.Unix(),
}
// Only set non-default values to benefit from omitempty
if req.Format != "" && req.Format != DefaultFormat {
p.Format = req.Format
}
if req.Quality != 0 && req.Quality != DefaultQuality {
p.Quality = req.Quality
}
if req.FitMode != "" && req.FitMode != DefaultFitMode {
p.FitMode = req.FitMode
}
return p
}

View File

@@ -0,0 +1,281 @@
package encurl
import (
"testing"
"time"
"sneak.berlin/go/pixa/internal/imgcache"
)
func TestGenerator_GenerateAndParse(t *testing.T) {
gen, err := NewGenerator("test-signing-key-12345")
if err != nil {
t.Fatalf("NewGenerator() error = %v", err)
}
payload := &Payload{
SourceHost: "cdn.example.com",
SourcePath: "/images/photo.jpg",
SourceQuery: "v=2",
Width: 800,
Height: 600,
Format: imgcache.FormatWebP,
Quality: 90,
FitMode: imgcache.FitContain,
ExpiresAt: time.Now().Add(time.Hour).Unix(),
}
token, err := gen.Generate(payload)
if err != nil {
t.Fatalf("Generate() error = %v", err)
}
if token == "" {
t.Fatal("Generate() returned empty token")
}
parsed, err := gen.Parse(token)
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
// Verify all fields
if parsed.SourceHost != payload.SourceHost {
t.Errorf("SourceHost = %q, want %q", parsed.SourceHost, payload.SourceHost)
}
if parsed.SourcePath != payload.SourcePath {
t.Errorf("SourcePath = %q, want %q", parsed.SourcePath, payload.SourcePath)
}
if parsed.SourceQuery != payload.SourceQuery {
t.Errorf("SourceQuery = %q, want %q", parsed.SourceQuery, payload.SourceQuery)
}
if parsed.Width != payload.Width {
t.Errorf("Width = %d, want %d", parsed.Width, payload.Width)
}
if parsed.Height != payload.Height {
t.Errorf("Height = %d, want %d", parsed.Height, payload.Height)
}
if parsed.Format != payload.Format {
t.Errorf("Format = %q, want %q", parsed.Format, payload.Format)
}
if parsed.Quality != payload.Quality {
t.Errorf("Quality = %d, want %d", parsed.Quality, payload.Quality)
}
if parsed.FitMode != payload.FitMode {
t.Errorf("FitMode = %q, want %q", parsed.FitMode, payload.FitMode)
}
if parsed.ExpiresAt != payload.ExpiresAt {
t.Errorf("ExpiresAt = %d, want %d", parsed.ExpiresAt, payload.ExpiresAt)
}
}
func TestGenerator_Parse_Expired(t *testing.T) {
gen, _ := NewGenerator("test-signing-key-12345")
payload := &Payload{
SourceHost: "cdn.example.com",
SourcePath: "/images/photo.jpg",
ExpiresAt: time.Now().Add(-time.Hour).Unix(), // Already expired
}
token, err := gen.Generate(payload)
if err != nil {
t.Fatalf("Generate() error = %v", err)
}
_, err = gen.Parse(token)
if err == nil {
t.Error("Parse() should fail for expired token")
}
if err != ErrExpired {
t.Errorf("Parse() error = %v, want %v", err, ErrExpired)
}
}
func TestGenerator_Parse_InvalidToken(t *testing.T) {
gen, _ := NewGenerator("test-signing-key-12345")
_, err := gen.Parse("not-a-valid-token")
if err == nil {
t.Error("Parse() should fail for invalid token")
}
}
func TestGenerator_Parse_TamperedToken(t *testing.T) {
gen, _ := NewGenerator("test-signing-key-12345")
payload := &Payload{
SourceHost: "cdn.example.com",
SourcePath: "/images/photo.jpg",
ExpiresAt: time.Now().Add(time.Hour).Unix(),
}
token, _ := gen.Generate(payload)
// Tamper with the token
tampered := []byte(token)
if len(tampered) > 10 {
tampered[10] ^= 0x01
}
_, err := gen.Parse(string(tampered))
if err == nil {
t.Error("Parse() should fail for tampered token")
}
}
func TestGenerator_Parse_WrongKey(t *testing.T) {
gen1, _ := NewGenerator("signing-key-1")
gen2, _ := NewGenerator("signing-key-2")
payload := &Payload{
SourceHost: "cdn.example.com",
SourcePath: "/images/photo.jpg",
ExpiresAt: time.Now().Add(time.Hour).Unix(),
}
token, _ := gen1.Generate(payload)
_, err := gen2.Parse(token)
if err == nil {
t.Error("Parse() should fail with different signing key")
}
}
func TestPayload_ToImageRequest(t *testing.T) {
payload := &Payload{
SourceHost: "cdn.example.com",
SourcePath: "/images/photo.jpg",
SourceQuery: "v=2",
Width: 800,
Height: 600,
Format: imgcache.FormatWebP,
Quality: 90,
FitMode: imgcache.FitContain,
ExpiresAt: time.Now().Add(time.Hour).Unix(),
}
req := payload.ToImageRequest()
if req.SourceHost != payload.SourceHost {
t.Errorf("SourceHost = %q, want %q", req.SourceHost, payload.SourceHost)
}
if req.SourcePath != payload.SourcePath {
t.Errorf("SourcePath = %q, want %q", req.SourcePath, payload.SourcePath)
}
if req.SourceQuery != payload.SourceQuery {
t.Errorf("SourceQuery = %q, want %q", req.SourceQuery, payload.SourceQuery)
}
if req.Size.Width != payload.Width {
t.Errorf("Width = %d, want %d", req.Size.Width, payload.Width)
}
if req.Size.Height != payload.Height {
t.Errorf("Height = %d, want %d", req.Size.Height, payload.Height)
}
if req.Format != payload.Format {
t.Errorf("Format = %q, want %q", req.Format, payload.Format)
}
if req.Quality != payload.Quality {
t.Errorf("Quality = %d, want %d", req.Quality, payload.Quality)
}
if req.FitMode != payload.FitMode {
t.Errorf("FitMode = %q, want %q", req.FitMode, payload.FitMode)
}
}
func TestPayload_ToImageRequest_Defaults(t *testing.T) {
// Payload with only required fields - should get defaults
payload := &Payload{
SourceHost: "cdn.example.com",
SourcePath: "/images/photo.jpg",
ExpiresAt: time.Now().Add(time.Hour).Unix(),
}
req := payload.ToImageRequest()
if req.Format != DefaultFormat {
t.Errorf("Format = %q, want default %q", req.Format, DefaultFormat)
}
if req.Quality != DefaultQuality {
t.Errorf("Quality = %d, want default %d", req.Quality, DefaultQuality)
}
if req.FitMode != DefaultFitMode {
t.Errorf("FitMode = %q, want default %q", req.FitMode, DefaultFitMode)
}
}
func TestFromImageRequest(t *testing.T) {
req := &imgcache.ImageRequest{
SourceHost: "cdn.example.com",
SourcePath: "/images/photo.jpg",
SourceQuery: "v=2",
Size: imgcache.Size{Width: 800, Height: 600},
Format: imgcache.FormatWebP,
Quality: 90,
FitMode: imgcache.FitContain,
}
expiresAt := time.Now().Add(time.Hour)
payload := FromImageRequest(req, expiresAt)
if payload.SourceHost != req.SourceHost {
t.Errorf("SourceHost = %q, want %q", payload.SourceHost, req.SourceHost)
}
if payload.SourcePath != req.SourcePath {
t.Errorf("SourcePath = %q, want %q", payload.SourcePath, req.SourcePath)
}
if payload.Width != req.Size.Width {
t.Errorf("Width = %d, want %d", payload.Width, req.Size.Width)
}
if payload.ExpiresAt != expiresAt.Unix() {
t.Errorf("ExpiresAt = %d, want %d", payload.ExpiresAt, expiresAt.Unix())
}
}
func TestFromImageRequest_OmitsDefaults(t *testing.T) {
// Request with default values - payload should omit them for smaller encoding
req := &imgcache.ImageRequest{
SourceHost: "cdn.example.com",
SourcePath: "/images/photo.jpg",
Format: DefaultFormat,
Quality: DefaultQuality,
FitMode: DefaultFitMode,
}
payload := FromImageRequest(req, time.Now().Add(time.Hour))
// These should be zero/empty because they match defaults
if payload.Format != "" {
t.Errorf("Format should be empty for default, got %q", payload.Format)
}
if payload.Quality != 0 {
t.Errorf("Quality should be 0 for default, got %d", payload.Quality)
}
if payload.FitMode != "" {
t.Errorf("FitMode should be empty for default, got %q", payload.FitMode)
}
}
func TestGenerator_TokenIsURLSafe(t *testing.T) {
gen, _ := NewGenerator("test-signing-key-12345")
payload := &Payload{
SourceHost: "cdn.example.com",
SourcePath: "/images/photo.jpg",
ExpiresAt: time.Now().Add(time.Hour).Unix(),
}
token, _ := gen.Generate(payload)
// Check that token only contains URL-safe characters
for _, c := range token {
isURLSafe := (c >= 'a' && c <= 'z') ||
(c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9') ||
c == '-' || c == '_'
if !isURLSafe {
t.Errorf("Token contains non-URL-safe character: %q", c)
}
}
}

232
internal/handlers/auth.go Normal file
View File

@@ -0,0 +1,232 @@
package handlers
import (
"crypto/subtle"
"net/http"
"net/url"
"strconv"
"time"
"sneak.berlin/go/pixa/internal/encurl"
"sneak.berlin/go/pixa/internal/imgcache"
"sneak.berlin/go/pixa/internal/templates"
)
// HandleRoot serves the login page or generator page based on authentication state.
func (s *Handlers) HandleRoot() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Check if signing key is configured
if s.sessMgr == nil {
s.respondError(w, "signing key not configured", http.StatusServiceUnavailable)
return
}
if r.Method == http.MethodPost {
s.handleLoginPost(w, r)
return
}
// Check if authenticated
if s.sessMgr.IsAuthenticated(r) {
s.renderGenerator(w, nil)
return
}
// Show login page
s.renderLogin(w, "")
}
}
// handleLoginPost handles login form submission.
func (s *Handlers) handleLoginPost(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
s.renderLogin(w, "Invalid form data")
return
}
submittedKey := r.FormValue("key")
// Constant-time comparison to prevent timing attacks
if subtle.ConstantTimeCompare([]byte(submittedKey), []byte(s.config.SigningKey)) != 1 {
s.log.Warn("failed login attempt", "remote_addr", r.RemoteAddr)
s.renderLogin(w, "Invalid signing key")
return
}
// Create session
if err := s.sessMgr.CreateSession(w); err != nil {
s.log.Error("failed to create session", "error", err)
s.renderLogin(w, "Failed to create session")
return
}
s.log.Info("successful login", "remote_addr", r.RemoteAddr)
// Redirect to generator page
http.Redirect(w, r, "/", http.StatusSeeOther)
}
// HandleLogout clears the session and redirects to login.
func (s *Handlers) HandleLogout() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if s.sessMgr != nil {
s.sessMgr.ClearSession(w)
}
http.Redirect(w, r, "/", http.StatusSeeOther)
}
}
// HandleGenerateURL handles the URL generation form submission.
func (s *Handlers) HandleGenerateURL() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Check authentication
if s.sessMgr == nil || !s.sessMgr.IsAuthenticated(r) {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
if err := r.ParseForm(); err != nil {
s.renderGenerator(w, &generatorData{Error: "Invalid form data"})
return
}
// Parse form values
sourceURL := r.FormValue("url")
widthStr := r.FormValue("width")
heightStr := r.FormValue("height")
format := r.FormValue("format")
qualityStr := r.FormValue("quality")
fit := r.FormValue("fit")
ttlStr := r.FormValue("ttl")
// Validate source URL
parsed, err := url.Parse(sourceURL)
if err != nil || parsed.Host == "" {
s.renderGeneratorWithForm(w, "Invalid source URL", r.Form)
return
}
// Parse dimensions
width, _ := strconv.Atoi(widthStr)
height, _ := strconv.Atoi(heightStr)
quality, _ := strconv.Atoi(qualityStr)
ttl, _ := strconv.Atoi(ttlStr)
if quality <= 0 {
quality = 85
}
if ttl <= 0 {
ttl = 2592000 // 30 days default
}
// Create payload
expiresAt := time.Now().Add(time.Duration(ttl) * time.Second)
payload := &encurl.Payload{
SourceHost: parsed.Host,
SourcePath: parsed.Path,
SourceQuery: parsed.RawQuery,
Width: width,
Height: height,
Format: imgcache.ImageFormat(format),
Quality: quality,
FitMode: imgcache.FitMode(fit),
ExpiresAt: expiresAt.Unix(),
}
// Generate encrypted token
token, err := s.encGen.Generate(payload)
if err != nil {
s.log.Error("failed to generate encrypted URL", "error", err)
s.renderGeneratorWithForm(w, "Failed to generate URL", r.Form)
return
}
// Build full URL
scheme := "https"
if s.config.Debug {
scheme = "http"
}
host := r.Host
generatedURL := scheme + "://" + host + "/v1/e/" + token
s.renderGenerator(w, &generatorData{
GeneratedURL: generatedURL,
ExpiresAt: expiresAt.Format(time.RFC3339),
FormURL: sourceURL,
FormWidth: widthStr,
FormHeight: heightStr,
FormFormat: format,
FormQuality: qualityStr,
FormFit: fit,
FormTTL: ttlStr,
})
}
}
// generatorData holds template data for the generator page.
type generatorData struct {
GeneratedURL string
ExpiresAt string
Error string
FormURL string
FormWidth string
FormHeight string
FormFormat string
FormQuality string
FormFit string
FormTTL string
}
func (s *Handlers) renderLogin(w http.ResponseWriter, errorMsg string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
data := struct {
Error string
}{
Error: errorMsg,
}
if err := templates.Render(w, "login.html", data); err != nil {
s.log.Error("failed to render login template", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
func (s *Handlers) renderGenerator(w http.ResponseWriter, data *generatorData) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if data == nil {
data = &generatorData{}
}
if err := templates.Render(w, "generator.html", data); err != nil {
s.log.Error("failed to render generator template", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
func (s *Handlers) renderGeneratorWithForm(w http.ResponseWriter, errorMsg string, form url.Values) {
s.renderGenerator(w, &generatorData{
Error: errorMsg,
FormURL: form.Get("url"),
FormWidth: form.Get("width"),
FormHeight: form.Get("height"),
FormFormat: form.Get("format"),
FormQuality: form.Get("quality"),
FormFit: form.Get("fit"),
FormTTL: form.Get("ttl"),
})
}

View File

@@ -11,9 +11,11 @@ import (
"go.uber.org/fx"
"sneak.berlin/go/pixa/internal/config"
"sneak.berlin/go/pixa/internal/database"
"sneak.berlin/go/pixa/internal/encurl"
"sneak.berlin/go/pixa/internal/healthcheck"
"sneak.berlin/go/pixa/internal/imgcache"
"sneak.berlin/go/pixa/internal/logger"
"sneak.berlin/go/pixa/internal/session"
)
// Params defines dependencies for Handlers.
@@ -33,6 +35,8 @@ type Handlers struct {
config *config.Config
imgSvc *imgcache.Service
imgCache *imgcache.Cache
sessMgr *session.Manager
encGen *encurl.Generator
}
// New creates a new Handlers instance.
@@ -91,6 +95,23 @@ func (s *Handlers) initImageService() error {
s.imgSvc = svc
s.log.Info("image service initialized")
// Initialize session manager and URL generator if signing key is configured
if s.config.SigningKey != "" {
sessMgr, err := session.NewManager(s.config.SigningKey, !s.config.Debug)
if err != nil {
return err
}
s.sessMgr = sessMgr
encGen, err := encurl.NewGenerator(s.config.SigningKey)
if err != nil {
return err
}
s.encGen = encGen
s.log.Info("session manager and URL generator initialized")
}
return nil
}

View File

@@ -0,0 +1,117 @@
package handlers
import (
"errors"
"io"
"net/http"
"strconv"
"time"
"github.com/go-chi/chi/v5"
"sneak.berlin/go/pixa/internal/encurl"
"sneak.berlin/go/pixa/internal/imgcache"
)
// HandleImageEnc handles requests to /v1/e/{token} for encrypted image URLs.
func (s *Handlers) HandleImageEnc() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
start := time.Now()
// Check if encryption is configured
if s.encGen == nil {
s.respondError(w, "encrypted URLs not configured", http.StatusServiceUnavailable)
return
}
// Extract token from URL
token := chi.URLParam(r, "token")
if token == "" {
s.respondError(w, "missing token", http.StatusBadRequest)
return
}
// Decrypt and validate the payload
payload, err := s.encGen.Parse(token)
if err != nil {
if errors.Is(err, encurl.ErrExpired) {
s.log.Debug("encrypted URL expired", "error", err)
s.respondError(w, "URL has expired", http.StatusGone)
return
}
s.log.Debug("failed to decrypt URL", "error", err)
s.respondError(w, "invalid encrypted URL", http.StatusBadRequest)
return
}
// Convert payload to ImageRequest
req := payload.ToImageRequest()
// Log the request
s.log.Debug("encrypted image request",
"host", req.SourceHost,
"path", req.SourcePath,
"size", req.Size,
"format", req.Format,
)
// Fetch and process the image (no signature validation needed - encrypted URL is trusted)
resp, err := s.imgSvc.Get(ctx, req)
if err != nil {
s.handleImageError(w, err)
return
}
defer func() { _ = resp.Content.Close() }()
// Set response headers
w.Header().Set("Content-Type", resp.ContentType)
if resp.ContentLength > 0 {
w.Header().Set("Content-Length", strconv.FormatInt(resp.ContentLength, 10))
}
// Cache headers - encrypted URLs can be cached since they're immutable
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
w.Header().Set("X-Pixa-Cache", string(resp.CacheStatus))
// Stream the response
written, err := io.Copy(w, resp.Content)
if err != nil {
s.log.Error("failed to write response", "error", err)
return
}
// Log completion
duration := time.Since(start)
s.log.Info("served encrypted image",
"host", req.SourceHost,
"path", req.SourcePath,
"format", req.Format,
"cache_status", resp.CacheStatus,
"served_bytes", written,
"duration_ms", duration.Milliseconds(),
)
}
}
// handleImageError converts image service errors to HTTP responses.
func (s *Handlers) handleImageError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, imgcache.ErrSSRFBlocked):
s.respondError(w, "forbidden", http.StatusForbidden)
case errors.Is(err, imgcache.ErrUpstreamError):
s.respondError(w, "upstream error", http.StatusBadGateway)
case errors.Is(err, imgcache.ErrUpstreamTimeout):
s.respondError(w, "upstream timeout", http.StatusGatewayTimeout)
default:
s.log.Error("image request failed", "error", err)
s.respondError(w, "internal error", http.StatusInternalServerError)
}
}

View File

@@ -0,0 +1,115 @@
package imgcache
import (
"context"
"errors"
"fmt"
"io"
"io/fs"
"net/http"
"strings"
)
// MockFetcher implements the Fetcher interface using an embedded filesystem.
// Files are organized as: hostname/path/to/file.ext
// URLs like https://example.com/images/photo.jpg map to example.com/images/photo.jpg
type MockFetcher struct {
fs fs.FS
}
// NewMockFetcher creates a new mock fetcher backed by the given filesystem.
func NewMockFetcher(fsys fs.FS) *MockFetcher {
return &MockFetcher{fs: fsys}
}
// Fetch retrieves content from the mock filesystem.
func (m *MockFetcher) Fetch(ctx context.Context, url string) (*FetchResult, error) {
// Check context cancellation
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
// Parse URL to get filesystem path
path, err := urlToFSPath(url)
if err != nil {
return nil, err
}
// Open the file
f, err := m.fs.Open(path)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return nil, fmt.Errorf("%w: status 404", ErrUpstreamError)
}
return nil, fmt.Errorf("failed to open mock file: %w", err)
}
// Get file info for content length
stat, err := f.Stat()
if err != nil {
_ = f.Close()
return nil, fmt.Errorf("failed to stat mock file: %w", err)
}
// Detect content type from extension
contentType := detectContentTypeFromPath(path)
return &FetchResult{
Content: f.(io.ReadCloser),
ContentLength: stat.Size(),
ContentType: contentType,
Headers: make(http.Header),
}, nil
}
// urlToFSPath converts a URL to a filesystem path.
// https://example.com/images/photo.jpg -> example.com/images/photo.jpg
func urlToFSPath(rawURL string) (string, error) {
// Strip scheme
url := rawURL
if idx := strings.Index(url, "://"); idx != -1 {
url = url[idx+3:]
}
// Remove query string
if idx := strings.Index(url, "?"); idx != -1 {
url = url[:idx]
}
// Remove fragment
if idx := strings.Index(url, "#"); idx != -1 {
url = url[:idx]
}
if url == "" {
return "", errors.New("empty URL path")
}
return url, nil
}
// detectContentTypeFromPath returns the MIME type based on file extension.
func detectContentTypeFromPath(path string) string {
path = strings.ToLower(path)
switch {
case strings.HasSuffix(path, ".jpg"), strings.HasSuffix(path, ".jpeg"):
return "image/jpeg"
case strings.HasSuffix(path, ".png"):
return "image/png"
case strings.HasSuffix(path, ".gif"):
return "image/gif"
case strings.HasSuffix(path, ".webp"):
return "image/webp"
case strings.HasSuffix(path, ".avif"):
return "image/avif"
case strings.HasSuffix(path, ".svg"):
return "image/svg+xml"
default:
return "application/octet-stream"
}
}

View File

@@ -14,7 +14,7 @@ import (
// Service implements the ImageCache interface, orchestrating cache, fetcher, and processor.
type Service struct {
cache *Cache
fetcher *HTTPFetcher
fetcher Fetcher
processor Processor
signer *Signer
whitelist *HostWhitelist
@@ -25,8 +25,10 @@ type Service struct {
type ServiceConfig struct {
// Cache is the cache instance
Cache *Cache
// FetcherConfig configures the upstream fetcher
// FetcherConfig configures the upstream fetcher (ignored if Fetcher is set)
FetcherConfig *FetcherConfig
// Fetcher is an optional custom fetcher (for testing)
Fetcher Fetcher
// SigningKey is the HMAC signing key (empty disables signing)
SigningKey string
// Whitelist is the list of hosts that don't require signatures
@@ -41,9 +43,16 @@ func NewService(cfg *ServiceConfig) (*Service, error) {
return nil, errors.New("cache is required")
}
fetcherCfg := cfg.FetcherConfig
if fetcherCfg == nil {
fetcherCfg = DefaultFetcherConfig()
// Use custom fetcher if provided, otherwise create HTTP fetcher
var fetcher Fetcher
if cfg.Fetcher != nil {
fetcher = cfg.Fetcher
} else {
fetcherCfg := cfg.FetcherConfig
if fetcherCfg == nil {
fetcherCfg = DefaultFetcherConfig()
}
fetcher = NewHTTPFetcher(fetcherCfg)
}
var signer *Signer
@@ -58,7 +67,7 @@ func NewService(cfg *ServiceConfig) (*Service, error) {
return &Service{
cache: cfg.Cache,
fetcher: NewHTTPFetcher(fetcherCfg),
fetcher: fetcher,
processor: NewImageProcessor(),
signer: signer,
whitelist: NewHostWhitelist(cfg.Whitelist),

View File

@@ -0,0 +1,407 @@
package imgcache
import (
"context"
"io"
"testing"
"time"
)
func TestService_Get_WhitelistedHost(t *testing.T) {
svc, fixtures := SetupTestService(t)
ctx := context.Background()
req := &ImageRequest{
SourceHost: fixtures.GoodHost,
SourcePath: "/images/photo.jpg",
Size: Size{Width: 50, Height: 50},
Format: FormatJPEG,
Quality: 85,
FitMode: FitCover,
}
resp, err := svc.Get(ctx, req)
if err != nil {
t.Fatalf("Get() error = %v", err)
}
defer resp.Content.Close()
// Verify we got content
data, err := io.ReadAll(resp.Content)
if err != nil {
t.Fatalf("failed to read response: %v", err)
}
if len(data) == 0 {
t.Error("expected non-empty response")
}
if resp.ContentType != "image/jpeg" {
t.Errorf("ContentType = %q, want %q", resp.ContentType, "image/jpeg")
}
}
func TestService_Get_NonWhitelistedHost_NoSignature(t *testing.T) {
svc, fixtures := SetupTestService(t, WithSigningKey("test-key"))
req := &ImageRequest{
SourceHost: fixtures.OtherHost,
SourcePath: "/uploads/image.jpg",
Size: Size{Width: 50, Height: 50},
Format: FormatJPEG,
Quality: 85,
FitMode: FitCover,
}
// Should fail validation - not whitelisted and no signature
err := svc.ValidateRequest(req)
if err == nil {
t.Error("ValidateRequest() expected error for non-whitelisted host without signature")
}
}
func TestService_Get_NonWhitelistedHost_ValidSignature(t *testing.T) {
signingKey := "test-signing-key-12345"
svc, fixtures := SetupTestService(t, WithSigningKey(signingKey))
ctx := context.Background()
req := &ImageRequest{
SourceHost: fixtures.OtherHost,
SourcePath: "/uploads/image.jpg",
Size: Size{Width: 50, Height: 50},
Format: FormatJPEG,
Quality: 85,
FitMode: FitCover,
}
// Generate a valid signature
signer := NewSigner(signingKey)
req.Expires = time.Now().Add(time.Hour)
req.Signature = signer.Sign(req)
// Should pass validation
err := svc.ValidateRequest(req)
if err != nil {
t.Errorf("ValidateRequest() error = %v", err)
}
// Should fetch successfully
resp, err := svc.Get(ctx, req)
if err != nil {
t.Fatalf("Get() error = %v", err)
}
defer resp.Content.Close()
data, err := io.ReadAll(resp.Content)
if err != nil {
t.Fatalf("failed to read response: %v", err)
}
if len(data) == 0 {
t.Error("expected non-empty response")
}
}
func TestService_Get_NonWhitelistedHost_ExpiredSignature(t *testing.T) {
signingKey := "test-signing-key-12345"
svc, fixtures := SetupTestService(t, WithSigningKey(signingKey))
req := &ImageRequest{
SourceHost: fixtures.OtherHost,
SourcePath: "/uploads/image.jpg",
Size: Size{Width: 50, Height: 50},
Format: FormatJPEG,
Quality: 85,
FitMode: FitCover,
}
// Generate an expired signature
signer := NewSigner(signingKey)
req.Expires = time.Now().Add(-time.Hour) // Already expired
req.Signature = signer.Sign(req)
// Should fail validation
err := svc.ValidateRequest(req)
if err == nil {
t.Error("ValidateRequest() expected error for expired signature")
}
}
func TestService_Get_NonWhitelistedHost_InvalidSignature(t *testing.T) {
signingKey := "test-signing-key-12345"
svc, fixtures := SetupTestService(t, WithSigningKey(signingKey))
req := &ImageRequest{
SourceHost: fixtures.OtherHost,
SourcePath: "/uploads/image.jpg",
Size: Size{Width: 50, Height: 50},
Format: FormatJPEG,
Quality: 85,
FitMode: FitCover,
}
// Set an invalid signature
req.Signature = "invalid-signature"
req.Expires = time.Now().Add(time.Hour)
// Should fail validation
err := svc.ValidateRequest(req)
if err == nil {
t.Error("ValidateRequest() expected error for invalid signature")
}
}
func TestService_Get_InvalidFile(t *testing.T) {
svc, fixtures := SetupTestService(t)
ctx := context.Background()
req := &ImageRequest{
SourceHost: fixtures.GoodHost,
SourcePath: "/images/fake.jpg",
Size: Size{Width: 50, Height: 50},
Format: FormatJPEG,
Quality: 85,
FitMode: FitCover,
}
// Should fail because magic bytes don't match
_, err := svc.Get(ctx, req)
if err == nil {
t.Error("Get() expected error for invalid file")
}
}
func TestService_Get_NotFound(t *testing.T) {
svc, fixtures := SetupTestService(t)
ctx := context.Background()
req := &ImageRequest{
SourceHost: fixtures.GoodHost,
SourcePath: "/images/nonexistent.jpg",
Size: Size{Width: 50, Height: 50},
Format: FormatJPEG,
Quality: 85,
FitMode: FitCover,
}
_, err := svc.Get(ctx, req)
if err == nil {
t.Error("Get() expected error for nonexistent file")
}
}
func TestService_Get_FormatConversion(t *testing.T) {
svc, fixtures := SetupTestService(t)
ctx := context.Background()
tests := []struct {
name string
sourcePath string
outFormat ImageFormat
wantMIME string
}{
{
name: "JPEG to PNG",
sourcePath: "/images/photo.jpg",
outFormat: FormatPNG,
wantMIME: "image/png",
},
{
name: "PNG to JPEG",
sourcePath: "/images/logo.png",
outFormat: FormatJPEG,
wantMIME: "image/jpeg",
},
{
name: "GIF to PNG",
sourcePath: "/images/animation.gif",
outFormat: FormatPNG,
wantMIME: "image/png",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := &ImageRequest{
SourceHost: fixtures.GoodHost,
SourcePath: tt.sourcePath,
Size: Size{Width: 50, Height: 50},
Format: tt.outFormat,
Quality: 85,
FitMode: FitCover,
}
resp, err := svc.Get(ctx, req)
if err != nil {
t.Fatalf("Get() error = %v", err)
}
defer resp.Content.Close()
if resp.ContentType != tt.wantMIME {
t.Errorf("ContentType = %q, want %q", resp.ContentType, tt.wantMIME)
}
// Verify magic bytes match the expected format
data, err := io.ReadAll(resp.Content)
if err != nil {
t.Fatalf("failed to read response: %v", err)
}
detectedMIME, err := DetectFormat(data)
if err != nil {
t.Fatalf("failed to detect format: %v", err)
}
expectedFormat, ok := MIMEToImageFormat(tt.wantMIME)
if !ok {
t.Fatalf("unknown format for MIME type: %s", tt.wantMIME)
}
detectedFormat, ok := MIMEToImageFormat(string(detectedMIME))
if !ok {
t.Fatalf("unknown format for detected MIME type: %s", detectedMIME)
}
if detectedFormat != expectedFormat {
t.Errorf("detected format = %q, want %q", detectedFormat, expectedFormat)
}
})
}
}
func TestService_Get_Caching(t *testing.T) {
svc, fixtures := SetupTestService(t)
ctx := context.Background()
req := &ImageRequest{
SourceHost: fixtures.GoodHost,
SourcePath: "/images/photo.jpg",
Size: Size{Width: 50, Height: 50},
Format: FormatJPEG,
Quality: 85,
FitMode: FitCover,
}
// First request - should be a cache miss
resp1, err := svc.Get(ctx, req)
if err != nil {
t.Fatalf("Get() first request error = %v", err)
}
if resp1.CacheStatus != CacheMiss {
t.Errorf("first request CacheStatus = %q, want %q", resp1.CacheStatus, CacheMiss)
}
data1, err := io.ReadAll(resp1.Content)
if err != nil {
t.Fatalf("failed to read first response: %v", err)
}
resp1.Content.Close()
// Second request - should be a cache hit
resp2, err := svc.Get(ctx, req)
if err != nil {
t.Fatalf("Get() second request error = %v", err)
}
if resp2.CacheStatus != CacheHit {
t.Errorf("second request CacheStatus = %q, want %q", resp2.CacheStatus, CacheHit)
}
data2, err := io.ReadAll(resp2.Content)
if err != nil {
t.Fatalf("failed to read second response: %v", err)
}
resp2.Content.Close()
// Content should be identical
if len(data1) != len(data2) {
t.Errorf("response sizes differ: %d vs %d", len(data1), len(data2))
}
}
func TestService_Get_DifferentSizes(t *testing.T) {
svc, fixtures := SetupTestService(t)
ctx := context.Background()
// Request same image at different sizes
sizes := []Size{
{Width: 25, Height: 25},
{Width: 50, Height: 50},
{Width: 75, Height: 75},
}
var responses [][]byte
for _, size := range sizes {
req := &ImageRequest{
SourceHost: fixtures.GoodHost,
SourcePath: "/images/photo.jpg",
Size: size,
Format: FormatJPEG,
Quality: 85,
FitMode: FitCover,
}
resp, err := svc.Get(ctx, req)
if err != nil {
t.Fatalf("Get() error for size %v = %v", size, err)
}
data, err := io.ReadAll(resp.Content)
if err != nil {
t.Fatalf("failed to read response: %v", err)
}
resp.Content.Close()
responses = append(responses, data)
}
// All responses should be different sizes (different cache entries)
for i := 0; i < len(responses)-1; i++ {
if len(responses[i]) == len(responses[i+1]) {
// Not necessarily an error, but worth noting
t.Logf("responses %d and %d have same size: %d bytes", i, i+1, len(responses[i]))
}
}
}
func TestService_ValidateRequest_NoSigningKey(t *testing.T) {
// Service with no signing key - all non-whitelisted requests should fail
svc, fixtures := SetupTestService(t, WithNoWhitelist())
req := &ImageRequest{
SourceHost: fixtures.OtherHost,
SourcePath: "/uploads/image.jpg",
Size: Size{Width: 50, Height: 50},
Format: FormatJPEG,
Quality: 85,
FitMode: FitCover,
}
err := svc.ValidateRequest(req)
if err == nil {
t.Error("ValidateRequest() expected error when no signing key and host not whitelisted")
}
}
func TestService_Get_ContextCancellation(t *testing.T) {
svc, fixtures := SetupTestService(t)
ctx, cancel := context.WithCancel(context.Background())
cancel() // Cancel immediately
req := &ImageRequest{
SourceHost: fixtures.GoodHost,
SourcePath: "/images/photo.jpg",
Size: Size{Width: 50, Height: 50},
Format: FormatJPEG,
Quality: 85,
FitMode: FitCover,
}
_, err := svc.Get(ctx, req)
if err == nil {
t.Error("Get() expected error for cancelled context")
}
}

View File

@@ -0,0 +1,232 @@
package imgcache
import (
"bytes"
"database/sql"
"image"
"image/color"
"image/gif"
"image/jpeg"
"image/png"
"io/fs"
"testing"
"testing/fstest"
"time"
"sneak.berlin/go/pixa/internal/database"
)
// TestFixtures contains paths to test files in the mock filesystem.
type TestFixtures struct {
// Valid image files
GoodHostJPEG string // whitelisted host, valid JPEG
GoodHostPNG string // whitelisted host, valid PNG
GoodHostGIF string // whitelisted host, valid GIF
OtherHostJPEG string // non-whitelisted host, valid JPEG
OtherHostPNG string // non-whitelisted host, valid PNG
// Invalid/edge case files
InvalidFile string // file with wrong magic bytes
EmptyFile string // zero-byte file
TextFile string // text file masquerading as image
// Hostnames
GoodHost string // whitelisted hostname
OtherHost string // non-whitelisted hostname
}
// DefaultFixtures returns the standard test fixture paths.
func DefaultFixtures() *TestFixtures {
return &TestFixtures{
GoodHostJPEG: "goodhost.example.com/images/photo.jpg",
GoodHostPNG: "goodhost.example.com/images/logo.png",
GoodHostGIF: "goodhost.example.com/images/animation.gif",
OtherHostJPEG: "otherhost.example.com/uploads/image.jpg",
OtherHostPNG: "otherhost.example.com/uploads/icon.png",
InvalidFile: "goodhost.example.com/images/fake.jpg",
EmptyFile: "goodhost.example.com/images/empty.jpg",
TextFile: "goodhost.example.com/images/text.png",
GoodHost: "goodhost.example.com",
OtherHost: "otherhost.example.com",
}
}
// NewTestFS creates a mock filesystem with test images.
func NewTestFS(t *testing.T) (fs.FS, *TestFixtures) {
t.Helper()
fixtures := DefaultFixtures()
// Generate test images
jpegData := generateTestJPEG(t, 100, 100, color.RGBA{255, 0, 0, 255})
pngData := generateTestPNG(t, 100, 100, color.RGBA{0, 255, 0, 255})
gifData := generateTestGIF(t, 100, 100, color.RGBA{0, 0, 255, 255})
// Create the mock filesystem
mockFS := fstest.MapFS{
// Good host files
fixtures.GoodHostJPEG: &fstest.MapFile{Data: jpegData},
fixtures.GoodHostPNG: &fstest.MapFile{Data: pngData},
fixtures.GoodHostGIF: &fstest.MapFile{Data: gifData},
// Other host files
fixtures.OtherHostJPEG: &fstest.MapFile{Data: jpegData},
fixtures.OtherHostPNG: &fstest.MapFile{Data: pngData},
// Invalid files
fixtures.InvalidFile: &fstest.MapFile{Data: []byte("not a real image file content")},
fixtures.EmptyFile: &fstest.MapFile{Data: []byte{}},
fixtures.TextFile: &fstest.MapFile{Data: []byte("This is just text, not a PNG")},
}
return mockFS, fixtures
}
// generateTestJPEG creates a minimal valid JPEG image.
func generateTestJPEG(t *testing.T, width, height int, c color.Color) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, width, height))
for y := 0; y < height; y++ {
for x := 0; x < width; x++ {
img.Set(x, y, c)
}
}
var buf bytes.Buffer
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 85}); err != nil {
t.Fatalf("failed to encode test JPEG: %v", err)
}
return buf.Bytes()
}
// generateTestPNG creates a minimal valid PNG image.
func generateTestPNG(t *testing.T, width, height int, c color.Color) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, width, height))
for y := 0; y < height; y++ {
for x := 0; x < width; x++ {
img.Set(x, y, c)
}
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
t.Fatalf("failed to encode test PNG: %v", err)
}
return buf.Bytes()
}
// generateTestGIF creates a minimal valid GIF image.
func generateTestGIF(t *testing.T, width, height int, c color.Color) []byte {
t.Helper()
img := image.NewPaletted(image.Rect(0, 0, width, height), []color.Color{c, color.White})
for y := 0; y < height; y++ {
for x := 0; x < width; x++ {
img.SetColorIndex(x, y, 0)
}
}
var buf bytes.Buffer
if err := gif.Encode(&buf, img, nil); err != nil {
t.Fatalf("failed to encode test GIF: %v", err)
}
return buf.Bytes()
}
// SetupTestService creates a Service with mock fetcher for testing.
func SetupTestService(t *testing.T, opts ...TestServiceOption) (*Service, *TestFixtures) {
t.Helper()
mockFS, fixtures := NewTestFS(t)
cfg := &testServiceConfig{
whitelist: []string{fixtures.GoodHost},
signingKey: "",
}
for _, opt := range opts {
opt(cfg)
}
// Create temp directory for cache
tmpDir := t.TempDir()
// Create in-memory database
db := setupServiceTestDB(t)
cache, err := NewCache(db, CacheConfig{
StateDir: tmpDir,
CacheTTL: time.Hour,
NegativeTTL: 5 * time.Minute,
HotCacheSize: 100,
HotCacheEnabled: true,
})
if err != nil {
t.Fatalf("failed to create cache: %v", err)
}
svc, err := NewService(&ServiceConfig{
Cache: cache,
Fetcher: NewMockFetcher(mockFS),
SigningKey: cfg.signingKey,
Whitelist: cfg.whitelist,
})
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
return svc, fixtures
}
// setupServiceTestDB creates an in-memory SQLite database for testing
// using the production schema.
func setupServiceTestDB(t *testing.T) *sql.DB {
t.Helper()
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatalf("failed to open test db: %v", err)
}
// Use the real production schema via migrations
if err := database.ApplyMigrations(db); err != nil {
t.Fatalf("failed to apply migrations: %v", err)
}
return db
}
type testServiceConfig struct {
whitelist []string
signingKey string
}
// TestServiceOption configures the test service.
type TestServiceOption func(*testServiceConfig)
// WithWhitelist sets the whitelist for the test service.
func WithWhitelist(hosts ...string) TestServiceOption {
return func(c *testServiceConfig) {
c.whitelist = hosts
}
}
// WithSigningKey sets the signing key for the test service.
func WithSigningKey(key string) TestServiceOption {
return func(c *testServiceConfig) {
c.signingKey = key
}
}
// WithNoWhitelist removes all whitelisted hosts.
func WithNoWhitelist() TestServiceOption {
return func(c *testServiceConfig) {
c.whitelist = nil
}
}

84
internal/seal/crypto.go Normal file
View File

@@ -0,0 +1,84 @@
// Package seal provides authenticated encryption utilities for pixa.
// It uses NaCl secretbox (XSalsa20-Poly1305) for sealing data
// and HKDF-SHA256 for key derivation.
package seal
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"errors"
"io"
"golang.org/x/crypto/hkdf"
"golang.org/x/crypto/nacl/secretbox"
)
// Key sizes for NaCl secretbox.
const (
KeySize = 32 // NaCl secretbox key size
NonceSize = 24 // NaCl secretbox nonce size
)
// Errors returned by crypto operations.
var (
ErrDecryptionFailed = errors.New("decryption failed: invalid ciphertext or key")
ErrInvalidPayload = errors.New("invalid encrypted payload")
ErrKeyDerivation = errors.New("key derivation failed")
)
// DeriveKey uses HKDF-SHA256 to derive a key from master key material.
// The salt parameter provides domain separation between different key usages.
func DeriveKey(masterKey []byte, salt string) ([KeySize]byte, error) {
var key [KeySize]byte
hkdfReader := hkdf.New(sha256.New, masterKey, []byte(salt), nil)
if _, err := io.ReadFull(hkdfReader, key[:]); err != nil {
return key, ErrKeyDerivation
}
return key, nil
}
// Encrypt encrypts plaintext using NaCl secretbox (XSalsa20-Poly1305).
// Returns base64url-encoded ciphertext with the nonce prepended.
func Encrypt(key [KeySize]byte, plaintext []byte) (string, error) {
// Generate random nonce
var nonce [NonceSize]byte
if _, err := rand.Read(nonce[:]); err != nil {
return "", err
}
// Encrypt: nonce + secretbox.Seal(plaintext)
encrypted := secretbox.Seal(nonce[:], plaintext, &nonce, &key)
return base64.RawURLEncoding.EncodeToString(encrypted), nil
}
// Decrypt decrypts base64url-encoded ciphertext using NaCl secretbox.
// Expects the nonce to be prepended to the ciphertext.
func Decrypt(key [KeySize]byte, ciphertext string) ([]byte, error) {
// Decode base64url
data, err := base64.RawURLEncoding.DecodeString(ciphertext)
if err != nil {
return nil, ErrInvalidPayload
}
// Check minimum length (nonce + at least some ciphertext + auth tag)
if len(data) < NonceSize+secretbox.Overhead {
return nil, ErrInvalidPayload
}
// Extract nonce
var nonce [NonceSize]byte
copy(nonce[:], data[:NonceSize])
// Decrypt
plaintext, ok := secretbox.Open(nil, data[NonceSize:], &nonce, &key)
if !ok {
return nil, ErrDecryptionFailed
}
return plaintext, nil
}

View File

@@ -0,0 +1,184 @@
package seal
import (
"bytes"
"testing"
)
func TestDeriveKey_Consistent(t *testing.T) {
masterKey := []byte("test-master-key-12345")
salt := "test-salt-v1"
key1, err := DeriveKey(masterKey, salt)
if err != nil {
t.Fatalf("DeriveKey() error = %v", err)
}
key2, err := DeriveKey(masterKey, salt)
if err != nil {
t.Fatalf("DeriveKey() error = %v", err)
}
if key1 != key2 {
t.Error("DeriveKey() should produce consistent keys for same input")
}
}
func TestDeriveKey_DifferentSalts(t *testing.T) {
masterKey := []byte("test-master-key-12345")
key1, err := DeriveKey(masterKey, "salt-1")
if err != nil {
t.Fatalf("DeriveKey() error = %v", err)
}
key2, err := DeriveKey(masterKey, "salt-2")
if err != nil {
t.Fatalf("DeriveKey() error = %v", err)
}
if key1 == key2 {
t.Error("DeriveKey() should produce different keys for different salts")
}
}
func TestDeriveKey_DifferentMasterKeys(t *testing.T) {
salt := "test-salt"
key1, err := DeriveKey([]byte("master-key-1"), salt)
if err != nil {
t.Fatalf("DeriveKey() error = %v", err)
}
key2, err := DeriveKey([]byte("master-key-2"), salt)
if err != nil {
t.Fatalf("DeriveKey() error = %v", err)
}
if key1 == key2 {
t.Error("DeriveKey() should produce different keys for different master keys")
}
}
func TestEncryptDecrypt_RoundTrip(t *testing.T) {
key, err := DeriveKey([]byte("test-key"), "test-salt")
if err != nil {
t.Fatalf("DeriveKey() error = %v", err)
}
plaintext := []byte("hello, world! this is a test message.")
ciphertext, err := Encrypt(key, plaintext)
if err != nil {
t.Fatalf("Encrypt() error = %v", err)
}
decrypted, err := Decrypt(key, ciphertext)
if err != nil {
t.Fatalf("Decrypt() error = %v", err)
}
if !bytes.Equal(plaintext, decrypted) {
t.Errorf("Decrypt() = %q, want %q", decrypted, plaintext)
}
}
func TestEncryptDecrypt_EmptyPlaintext(t *testing.T) {
key, _ := DeriveKey([]byte("test-key"), "test-salt")
plaintext := []byte{}
ciphertext, err := Encrypt(key, plaintext)
if err != nil {
t.Fatalf("Encrypt() error = %v", err)
}
decrypted, err := Decrypt(key, ciphertext)
if err != nil {
t.Fatalf("Decrypt() error = %v", err)
}
if !bytes.Equal(plaintext, decrypted) {
t.Errorf("Decrypt() = %q, want %q", decrypted, plaintext)
}
}
func TestDecrypt_WrongKey(t *testing.T) {
key1, _ := DeriveKey([]byte("key-1"), "salt")
key2, _ := DeriveKey([]byte("key-2"), "salt")
plaintext := []byte("secret message")
ciphertext, err := Encrypt(key1, plaintext)
if err != nil {
t.Fatalf("Encrypt() error = %v", err)
}
_, err = Decrypt(key2, ciphertext)
if err == nil {
t.Error("Decrypt() should fail with wrong key")
}
if err != ErrDecryptionFailed {
t.Errorf("Decrypt() error = %v, want %v", err, ErrDecryptionFailed)
}
}
func TestDecrypt_TamperedCiphertext(t *testing.T) {
key, _ := DeriveKey([]byte("test-key"), "test-salt")
plaintext := []byte("secret message")
ciphertext, err := Encrypt(key, plaintext)
if err != nil {
t.Fatalf("Encrypt() error = %v", err)
}
// Tamper with the ciphertext (flip a bit in the middle)
tampered := []byte(ciphertext)
if len(tampered) > 10 {
tampered[10] ^= 0x01
}
_, err = Decrypt(key, string(tampered))
if err == nil {
t.Error("Decrypt() should fail with tampered ciphertext")
}
}
func TestDecrypt_InvalidBase64(t *testing.T) {
key, _ := DeriveKey([]byte("test-key"), "test-salt")
_, err := Decrypt(key, "not-valid-base64!!!")
if err == nil {
t.Error("Decrypt() should fail with invalid base64")
}
if err != ErrInvalidPayload {
t.Errorf("Decrypt() error = %v, want %v", err, ErrInvalidPayload)
}
}
func TestDecrypt_TooShort(t *testing.T) {
key, _ := DeriveKey([]byte("test-key"), "test-salt")
// Create a base64 string that's too short to contain nonce + auth tag
_, err := Decrypt(key, "dG9vLXNob3J0")
if err == nil {
t.Error("Decrypt() should fail with too-short ciphertext")
}
if err != ErrInvalidPayload {
t.Errorf("Decrypt() error = %v, want %v", err, ErrInvalidPayload)
}
}
func TestEncrypt_ProducesDifferentCiphertexts(t *testing.T) {
key, _ := DeriveKey([]byte("test-key"), "test-salt")
plaintext := []byte("same message")
ciphertext1, _ := Encrypt(key, plaintext)
ciphertext2, _ := Encrypt(key, plaintext)
if ciphertext1 == ciphertext2 {
t.Error("Encrypt() should produce different ciphertexts due to random nonce")
}
}

View File

@@ -7,6 +7,8 @@ import (
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/prometheus/client_golang/prometheus/promhttp"
"sneak.berlin/go/pixa/internal/static"
)
// SetupRoutes configures all HTTP routes.
@@ -38,10 +40,22 @@ func (s *Server) SetupRoutes() {
// Robots.txt
s.router.Get("/robots.txt", s.h.HandleRobotsTxt())
// Static files (Tailwind CSS, etc.)
s.router.Handle("/static/*", http.StripPrefix("/static/", static.Handler()))
// Login/generator UI
s.router.Get("/", s.h.HandleRoot())
s.router.Post("/", s.h.HandleRoot())
s.router.Get("/logout", s.h.HandleLogout())
s.router.Post("/generate", s.h.HandleGenerateURL())
// Main image proxy route
// /v1/image/<host>/<path>/<width>x<height>.<format>
s.router.Get("/v1/image/*", s.h.HandleImage())
// Encrypted image URL route
s.router.Get("/v1/e/{token}", s.h.HandleImageEnc())
// Metrics endpoint with auth
if s.config.MetricsUsername != "" {
s.router.Group(func(r chi.Router) {

145
internal/session/session.go Normal file
View File

@@ -0,0 +1,145 @@
// Package session provides encrypted session cookie management.
package session
import (
"errors"
"net/http"
"time"
"github.com/gorilla/securecookie"
"sneak.berlin/go/pixa/internal/seal"
)
// Session configuration constants.
const (
CookieName = "pixa_session"
SessionTTL = 30 * 24 * time.Hour // 30 days
// HKDF salts for key derivation
hashKeySalt = "pixa-session-hash-v1"
blockKeySalt = "pixa-session-block-v1"
)
// Errors returned by session operations.
var (
ErrInvalidSession = errors.New("invalid or expired session")
ErrNoSession = errors.New("no session cookie present")
)
// Data contains the session payload stored in the encrypted cookie.
type Data struct {
Authenticated bool `json:"auth"`
CreatedAt time.Time `json:"created"`
ExpiresAt time.Time `json:"expires"`
}
// Manager handles session creation and validation using encrypted cookies.
type Manager struct {
sc *securecookie.SecureCookie
secure bool // Set Secure flag on cookies (should be true in production)
sameSite http.SameSite
}
// NewManager creates a session manager with keys derived from the signing key.
// Set secure=true in production to require HTTPS for cookies.
func NewManager(signingKey string, secure bool) (*Manager, error) {
masterKey := []byte(signingKey)
// Derive separate keys for HMAC (hash) and encryption (block)
hashKey, err := seal.DeriveKey(masterKey, hashKeySalt)
if err != nil {
return nil, err
}
blockKey, err := seal.DeriveKey(masterKey, blockKeySalt)
if err != nil {
return nil, err
}
sc := securecookie.New(hashKey[:], blockKey[:])
sc.MaxAge(int(SessionTTL.Seconds()))
return &Manager{
sc: sc,
secure: secure,
sameSite: http.SameSiteStrictMode,
}, nil
}
// CreateSession creates a new authenticated session and sets the cookie.
func (m *Manager) CreateSession(w http.ResponseWriter) error {
now := time.Now()
data := &Data{
Authenticated: true,
CreatedAt: now,
ExpiresAt: now.Add(SessionTTL),
}
encoded, err := m.sc.Encode(CookieName, data)
if err != nil {
return err
}
http.SetCookie(w, &http.Cookie{
Name: CookieName,
Value: encoded,
Path: "/",
MaxAge: int(SessionTTL.Seconds()),
HttpOnly: true,
Secure: m.secure,
SameSite: m.sameSite,
})
return nil
}
// ValidateSession checks if the request has a valid session cookie.
// Returns the session data if valid, or an error if invalid/missing.
func (m *Manager) ValidateSession(r *http.Request) (*Data, error) {
cookie, err := r.Cookie(CookieName)
if err != nil {
if errors.Is(err, http.ErrNoCookie) {
return nil, ErrNoSession
}
return nil, err
}
var data Data
if err := m.sc.Decode(CookieName, cookie.Value, &data); err != nil {
return nil, ErrInvalidSession
}
// Check if session has expired (defense in depth - cookie MaxAge should handle this)
if time.Now().After(data.ExpiresAt) {
return nil, ErrInvalidSession
}
if !data.Authenticated {
return nil, ErrInvalidSession
}
return &data, nil
}
// ClearSession removes the session cookie.
func (m *Manager) ClearSession(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: CookieName,
Value: "",
Path: "/",
MaxAge: -1, // Delete immediately
HttpOnly: true,
Secure: m.secure,
SameSite: m.sameSite,
})
}
// IsAuthenticated is a convenience method that returns true if the request
// has a valid authenticated session.
func (m *Manager) IsAuthenticated(r *http.Request) bool {
data, err := m.ValidateSession(r)
return err == nil && data != nil && data.Authenticated
}

View File

@@ -0,0 +1,204 @@
package session
import (
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestManager_CreateAndValidate(t *testing.T) {
mgr, err := NewManager("test-signing-key-12345", false)
if err != nil {
t.Fatalf("NewManager() error = %v", err)
}
// Create a session
w := httptest.NewRecorder()
if err := mgr.CreateSession(w); err != nil {
t.Fatalf("CreateSession() error = %v", err)
}
// Extract the cookie from response
resp := w.Result()
cookies := resp.Cookies()
if len(cookies) == 0 {
t.Fatal("CreateSession() did not set a cookie")
}
var sessionCookie *http.Cookie
for _, c := range cookies {
if c.Name == CookieName {
sessionCookie = c
break
}
}
if sessionCookie == nil {
t.Fatalf("CreateSession() did not set cookie named %q", CookieName)
}
// Validate the session
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(sessionCookie)
data, err := mgr.ValidateSession(req)
if err != nil {
t.Fatalf("ValidateSession() error = %v", err)
}
if !data.Authenticated {
t.Error("ValidateSession() returned unauthenticated session")
}
if data.ExpiresAt.Before(time.Now()) {
t.Error("ValidateSession() returned already-expired session")
}
}
func TestManager_ValidateSession_NoCookie(t *testing.T) {
mgr, _ := NewManager("test-signing-key-12345", false)
req := httptest.NewRequest(http.MethodGet, "/", nil)
_, err := mgr.ValidateSession(req)
if err == nil {
t.Error("ValidateSession() should fail with no cookie")
}
if err != ErrNoSession {
t.Errorf("ValidateSession() error = %v, want %v", err, ErrNoSession)
}
}
func TestManager_ValidateSession_TamperedCookie(t *testing.T) {
mgr, _ := NewManager("test-signing-key-12345", false)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(&http.Cookie{
Name: CookieName,
Value: "tampered-invalid-cookie-value",
})
_, err := mgr.ValidateSession(req)
if err == nil {
t.Error("ValidateSession() should fail with tampered cookie")
}
if err != ErrInvalidSession {
t.Errorf("ValidateSession() error = %v, want %v", err, ErrInvalidSession)
}
}
func TestManager_ValidateSession_WrongKey(t *testing.T) {
mgr1, _ := NewManager("signing-key-1", false)
mgr2, _ := NewManager("signing-key-2", false)
// Create session with mgr1
w := httptest.NewRecorder()
_ = mgr1.CreateSession(w)
resp := w.Result()
var sessionCookie *http.Cookie
for _, c := range resp.Cookies() {
if c.Name == CookieName {
sessionCookie = c
break
}
}
// Try to validate with mgr2 (different key)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(sessionCookie)
_, err := mgr2.ValidateSession(req)
if err == nil {
t.Error("ValidateSession() should fail with different signing key")
}
}
func TestManager_ClearSession(t *testing.T) {
mgr, _ := NewManager("test-signing-key-12345", false)
w := httptest.NewRecorder()
mgr.ClearSession(w)
resp := w.Result()
cookies := resp.Cookies()
var sessionCookie *http.Cookie
for _, c := range cookies {
if c.Name == CookieName {
sessionCookie = c
break
}
}
if sessionCookie == nil {
t.Fatal("ClearSession() did not set a cookie")
}
if sessionCookie.MaxAge != -1 {
t.Errorf("ClearSession() cookie MaxAge = %d, want -1", sessionCookie.MaxAge)
}
}
func TestManager_IsAuthenticated(t *testing.T) {
mgr, _ := NewManager("test-signing-key-12345", false)
// No session - should return false
req := httptest.NewRequest(http.MethodGet, "/", nil)
if mgr.IsAuthenticated(req) {
t.Error("IsAuthenticated() should return false with no session")
}
// Create session
w := httptest.NewRecorder()
_ = mgr.CreateSession(w)
resp := w.Result()
var sessionCookie *http.Cookie
for _, c := range resp.Cookies() {
if c.Name == CookieName {
sessionCookie = c
break
}
}
// With valid session - should return true
req = httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(sessionCookie)
if !mgr.IsAuthenticated(req) {
t.Error("IsAuthenticated() should return true with valid session")
}
}
func TestManager_CookieAttributes(t *testing.T) {
// Test with secure=true
mgr, _ := NewManager("test-key", true)
w := httptest.NewRecorder()
_ = mgr.CreateSession(w)
resp := w.Result()
var sessionCookie *http.Cookie
for _, c := range resp.Cookies() {
if c.Name == CookieName {
sessionCookie = c
break
}
}
if !sessionCookie.HttpOnly {
t.Error("Cookie should have HttpOnly flag")
}
if !sessionCookie.Secure {
t.Error("Cookie should have Secure flag when manager created with secure=true")
}
if sessionCookie.SameSite != http.SameSiteStrictMode {
t.Errorf("Cookie SameSite = %v, want %v", sessionCookie.SameSite, http.SameSiteStrictMode)
}
}

21
internal/static/static.go Normal file
View File

@@ -0,0 +1,21 @@
// Package static provides embedded static files for the web UI.
package static
import (
"embed"
"io/fs"
"net/http"
)
//go:embed *.js
var files embed.FS
// FS returns the embedded filesystem containing static files.
func FS() fs.FS {
return files
}
// Handler returns an http.Handler that serves static files.
func Handler() http.Handler {
return http.FileServer(http.FS(files))
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,179 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pixa - URL Generator</title>
<script src="/static/tailwind.js"></script>
</head>
<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>
</div>
{{if .GeneratedURL}}
<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"
>
Copy
</button>
</div>
<p class="text-xs text-green-600 mt-2">
Expires: {{.ExpiresAt}}
</p>
</div>
{{end}}
{{if .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="bg-white rounded-lg shadow-md p-6 space-y-4">
<div>
<label for="url" class="block text-sm font-medium text-gray-700 mb-1">
Source URL
</label>
<input
type="url"
id="url"
name="url"
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="grid grid-cols-2 gap-4">
<div>
<label for="width" class="block text-sm font-medium text-gray-700 mb-1">
Width
</label>
<input
type="number"
id="width"
name="width"
min="0"
max="10000"
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" class="block text-sm font-medium text-gray-700 mb-1">
Height
</label>
<input
type="number"
id="height"
name="height"
min="0"
max="10000"
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="grid grid-cols-2 gap-4">
<div>
<label for="format" class="block text-sm font-medium text-gray-700 mb-1">
Format
</label>
<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="webp" {{if eq .FormFormat "webp"}}selected{{end}}>WebP</option>
<option value="jpeg" {{if eq .FormFormat "jpeg"}}selected{{end}}>JPEG</option>
<option value="png" {{if eq .FormFormat "png"}}selected{{end}}>PNG</option>
<option value="avif" {{if eq .FormFormat "avif"}}selected{{end}}>AVIF</option>
</select>
</div>
<div>
<label for="quality" class="block text-sm font-medium text-gray-700 mb-1">
Quality
</label>
<input
type="number"
id="quality"
name="quality"
min="1"
max="100"
value="{{if .FormQuality}}{{.FormQuality}}{{else}}85{{end}}"
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="grid grid-cols-2 gap-4">
<div>
<label for="fit" class="block text-sm font-medium text-gray-700 mb-1">
Fit Mode
</label>
<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>
<option value="inside" {{if eq .FormFit "inside"}}selected{{end}}>Inside</option>
<option value="outside" {{if eq .FormFit "outside"}}selected{{end}}>Outside</option>
</select>
</div>
<div>
<label for="ttl" class="block text-sm font-medium text-gray-700 mb-1">
Expires In
</label>
<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="3600" {{if eq .FormTTL "3600"}}selected{{end}}>1 hour</option>
<option value="86400" {{if eq .FormTTL "86400"}}selected{{end}}>1 day</option>
<option value="604800" {{if eq .FormTTL "604800"}}selected{{end}}>1 week</option>
<option value="2592000" {{if or (eq .FormTTL "2592000") (eq .FormTTL "")}}selected{{end}}>30 days</option>
<option value="31536000" {{if eq .FormTTL "31536000"}}selected{{end}}>1 year</option>
</select>
</div>
</div>
<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="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>
</body>
</html>

View File

@@ -0,0 +1,44 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pixa - Login</title>
<script src="/static/tailwind.js"></script>
</head>
<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="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
{{.Error}}
</div>
{{end}}
<form method="POST" action="/" class="space-y-4">
<div>
<label for="key" class="block text-sm font-medium text-gray-700 mb-1">
Signing Key
</label>
<input
type="password"
id="key"
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"
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>
</div>
</body>
</html>

View File

@@ -0,0 +1,32 @@
// Package templates provides embedded HTML templates for the web UI.
package templates
import (
"embed"
"html/template"
"io"
"sync"
)
//go:embed *.html
var files embed.FS
//nolint:gochecknoglobals // intentional lazy initialization cache
var (
parsed *template.Template
parseOne sync.Once
)
// Get returns the parsed templates, parsing them on first call.
func Get() *template.Template {
parseOne.Do(func() {
parsed = template.Must(template.ParseFS(files, "*.html"))
})
return parsed
}
// Render renders a template by name to the given writer.
func Render(w io.Writer, name string, data any) error {
return Get().ExecuteTemplate(w, name, data)
}