Merge feature/auth-and-encrypted-urls: Add login, sessions, and encrypted URLs
This commit is contained in:
@@ -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
|
* As this is production code, no stubbing of implementations unless
|
||||||
specifically instructed. We need working implementations.
|
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
113
TODO.md
@@ -2,6 +2,119 @@
|
|||||||
|
|
||||||
A single linear checklist of tasks to implement the complete pixa caching image reverse proxy server.
|
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
|
## Project Setup
|
||||||
- [x] Create Makefile with check, lint, test, fmt targets
|
- [x] Create Makefile with check, lint, test, fmt targets
|
||||||
- [x] Create project structure (cmd/pixad, internal/*)
|
- [x] Create project structure (cmd/pixad, internal/*)
|
||||||
|
|||||||
5
go.mod
5
go.mod
@@ -13,6 +13,7 @@ require (
|
|||||||
github.com/slok/go-http-metrics v0.13.0
|
github.com/slok/go-http-metrics v0.13.0
|
||||||
github.com/spf13/cobra v1.10.2
|
github.com/spf13/cobra v1.10.2
|
||||||
go.uber.org/fx v1.24.0
|
go.uber.org/fx v1.24.0
|
||||||
|
golang.org/x/crypto v0.41.0
|
||||||
golang.org/x/image v0.34.0
|
golang.org/x/image v0.34.0
|
||||||
modernc.org/sqlite v1.42.2
|
modernc.org/sqlite v1.42.2
|
||||||
)
|
)
|
||||||
@@ -54,7 +55,7 @@ require (
|
|||||||
github.com/emicklei/go-restful/v3 v3.12.1 // indirect
|
github.com/emicklei/go-restful/v3 v3.12.1 // indirect
|
||||||
github.com/fatih/color v1.16.0 // indirect
|
github.com/fatih/color v1.16.0 // indirect
|
||||||
github.com/felixge/httpsnoop v1.0.4 // 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-jose/go-jose/v4 v4.0.5 // indirect
|
||||||
github.com/go-logr/logr v1.4.2 // indirect
|
github.com/go-logr/logr v1.4.2 // indirect
|
||||||
github.com/go-logr/stdr v1.2.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/google/uuid v1.6.0 // indirect
|
||||||
github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect
|
github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect
|
||||||
github.com/googleapis/gax-go/v2 v2.14.2 // 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/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect
|
||||||
github.com/hashicorp/consul/api v1.32.1 // indirect
|
github.com/hashicorp/consul/api v1.32.1 // indirect
|
||||||
github.com/hashicorp/errwrap v1.1.0 // 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/multierr v1.11.0 // indirect
|
||||||
go.uber.org/zap v1.27.0 // indirect
|
go.uber.org/zap v1.27.0 // indirect
|
||||||
go.yaml.in/yaml/v2 v2.4.2 // 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/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
||||||
golang.org/x/net v0.43.0 // indirect
|
golang.org/x/net v0.43.0 // indirect
|
||||||
golang.org/x/oauth2 v0.30.0 // indirect
|
golang.org/x/oauth2 v0.30.0 // indirect
|
||||||
|
|||||||
4
go.sum
4
go.sum
@@ -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/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 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
|
||||||
github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
|
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 h1:VTJMN9zbTvqDqPwheRVLcp0qcUcM+8eFivvGocAaSbo=
|
||||||
github.com/getsentry/sentry-go v0.40.0/go.mod h1:eRXCoh3uvmjQLY6qu63BjUZnaBu5L5WhMV1RwYO8W5s=
|
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=
|
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/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 h1:eBLnkZ9635krYIPD+ag1USrOAI0Nr0QYF3+/3GqO0k0=
|
||||||
github.com/googleapis/gax-go/v2 v2.14.2/go.mod h1:ON64QhlJkhVtSqp4v1uaK92VyZ2gmvDQsweuyLV+8+w=
|
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 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo=
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI=
|
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=
|
github.com/hashicorp/consul/api v1.32.1 h1:0+osr/3t/aZNAdJX558crU3PEjVrG4x6715aZHRgceE=
|
||||||
|
|||||||
@@ -166,3 +166,77 @@ func (s *Database) runMigrations(ctx context.Context) error {
|
|||||||
func (s *Database) DB() *sql.DB {
|
func (s *Database) DB() *sql.DB {
|
||||||
return s.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
158
internal/encurl/encurl.go
Normal 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
|
||||||
|
}
|
||||||
281
internal/encurl/encurl_test.go
Normal file
281
internal/encurl/encurl_test.go
Normal 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
232
internal/handlers/auth.go
Normal 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"),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -11,9 +11,11 @@ import (
|
|||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
"sneak.berlin/go/pixa/internal/config"
|
"sneak.berlin/go/pixa/internal/config"
|
||||||
"sneak.berlin/go/pixa/internal/database"
|
"sneak.berlin/go/pixa/internal/database"
|
||||||
|
"sneak.berlin/go/pixa/internal/encurl"
|
||||||
"sneak.berlin/go/pixa/internal/healthcheck"
|
"sneak.berlin/go/pixa/internal/healthcheck"
|
||||||
"sneak.berlin/go/pixa/internal/imgcache"
|
"sneak.berlin/go/pixa/internal/imgcache"
|
||||||
"sneak.berlin/go/pixa/internal/logger"
|
"sneak.berlin/go/pixa/internal/logger"
|
||||||
|
"sneak.berlin/go/pixa/internal/session"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Params defines dependencies for Handlers.
|
// Params defines dependencies for Handlers.
|
||||||
@@ -33,6 +35,8 @@ type Handlers struct {
|
|||||||
config *config.Config
|
config *config.Config
|
||||||
imgSvc *imgcache.Service
|
imgSvc *imgcache.Service
|
||||||
imgCache *imgcache.Cache
|
imgCache *imgcache.Cache
|
||||||
|
sessMgr *session.Manager
|
||||||
|
encGen *encurl.Generator
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new Handlers instance.
|
// New creates a new Handlers instance.
|
||||||
@@ -91,6 +95,23 @@ func (s *Handlers) initImageService() error {
|
|||||||
s.imgSvc = svc
|
s.imgSvc = svc
|
||||||
s.log.Info("image service initialized")
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
117
internal/handlers/imageenc.go
Normal file
117
internal/handlers/imageenc.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
115
internal/imgcache/mock_fetcher.go
Normal file
115
internal/imgcache/mock_fetcher.go
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ import (
|
|||||||
// Service implements the ImageCache interface, orchestrating cache, fetcher, and processor.
|
// Service implements the ImageCache interface, orchestrating cache, fetcher, and processor.
|
||||||
type Service struct {
|
type Service struct {
|
||||||
cache *Cache
|
cache *Cache
|
||||||
fetcher *HTTPFetcher
|
fetcher Fetcher
|
||||||
processor Processor
|
processor Processor
|
||||||
signer *Signer
|
signer *Signer
|
||||||
whitelist *HostWhitelist
|
whitelist *HostWhitelist
|
||||||
@@ -25,8 +25,10 @@ type Service struct {
|
|||||||
type ServiceConfig struct {
|
type ServiceConfig struct {
|
||||||
// Cache is the cache instance
|
// Cache is the cache instance
|
||||||
Cache *Cache
|
Cache *Cache
|
||||||
// FetcherConfig configures the upstream fetcher
|
// FetcherConfig configures the upstream fetcher (ignored if Fetcher is set)
|
||||||
FetcherConfig *FetcherConfig
|
FetcherConfig *FetcherConfig
|
||||||
|
// Fetcher is an optional custom fetcher (for testing)
|
||||||
|
Fetcher Fetcher
|
||||||
// SigningKey is the HMAC signing key (empty disables signing)
|
// SigningKey is the HMAC signing key (empty disables signing)
|
||||||
SigningKey string
|
SigningKey string
|
||||||
// Whitelist is the list of hosts that don't require signatures
|
// 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")
|
return nil, errors.New("cache is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
fetcherCfg := cfg.FetcherConfig
|
// Use custom fetcher if provided, otherwise create HTTP fetcher
|
||||||
if fetcherCfg == nil {
|
var fetcher Fetcher
|
||||||
fetcherCfg = DefaultFetcherConfig()
|
if cfg.Fetcher != nil {
|
||||||
|
fetcher = cfg.Fetcher
|
||||||
|
} else {
|
||||||
|
fetcherCfg := cfg.FetcherConfig
|
||||||
|
if fetcherCfg == nil {
|
||||||
|
fetcherCfg = DefaultFetcherConfig()
|
||||||
|
}
|
||||||
|
fetcher = NewHTTPFetcher(fetcherCfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
var signer *Signer
|
var signer *Signer
|
||||||
@@ -58,7 +67,7 @@ func NewService(cfg *ServiceConfig) (*Service, error) {
|
|||||||
|
|
||||||
return &Service{
|
return &Service{
|
||||||
cache: cfg.Cache,
|
cache: cfg.Cache,
|
||||||
fetcher: NewHTTPFetcher(fetcherCfg),
|
fetcher: fetcher,
|
||||||
processor: NewImageProcessor(),
|
processor: NewImageProcessor(),
|
||||||
signer: signer,
|
signer: signer,
|
||||||
whitelist: NewHostWhitelist(cfg.Whitelist),
|
whitelist: NewHostWhitelist(cfg.Whitelist),
|
||||||
|
|||||||
407
internal/imgcache/service_test.go
Normal file
407
internal/imgcache/service_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
232
internal/imgcache/testutil_test.go
Normal file
232
internal/imgcache/testutil_test.go
Normal 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
84
internal/seal/crypto.go
Normal 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
|
||||||
|
}
|
||||||
184
internal/seal/crypto_test.go
Normal file
184
internal/seal/crypto_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@ import (
|
|||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/static"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SetupRoutes configures all HTTP routes.
|
// SetupRoutes configures all HTTP routes.
|
||||||
@@ -38,10 +40,22 @@ func (s *Server) SetupRoutes() {
|
|||||||
// Robots.txt
|
// Robots.txt
|
||||||
s.router.Get("/robots.txt", s.h.HandleRobotsTxt())
|
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
|
// Main image proxy route
|
||||||
// /v1/image/<host>/<path>/<width>x<height>.<format>
|
// /v1/image/<host>/<path>/<width>x<height>.<format>
|
||||||
s.router.Get("/v1/image/*", s.h.HandleImage())
|
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
|
// Metrics endpoint with auth
|
||||||
if s.config.MetricsUsername != "" {
|
if s.config.MetricsUsername != "" {
|
||||||
s.router.Group(func(r chi.Router) {
|
s.router.Group(func(r chi.Router) {
|
||||||
|
|||||||
145
internal/session/session.go
Normal file
145
internal/session/session.go
Normal 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
|
||||||
|
}
|
||||||
204
internal/session/session_test.go
Normal file
204
internal/session/session_test.go
Normal 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
21
internal/static/static.go
Normal 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))
|
||||||
|
}
|
||||||
83
internal/static/tailwind.js
Normal file
83
internal/static/tailwind.js
Normal file
File diff suppressed because one or more lines are too long
179
internal/templates/generator.html
Normal file
179
internal/templates/generator.html
Normal 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>
|
||||||
44
internal/templates/login.html
Normal file
44
internal/templates/login.html
Normal 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>
|
||||||
32
internal/templates/templates.go
Normal file
32
internal/templates/templates.go
Normal 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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user