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
|
||||
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.
|
||||
|
||||
## Login, Sessions & Encrypted URLs Feature
|
||||
|
||||
### Phase 1: Crypto Foundation
|
||||
- [ ] Create `internal/crypto/crypto.go` with:
|
||||
- [ ] `DeriveKey(masterKey []byte, salt string) ([32]byte, error)` - HKDF-SHA256 key derivation
|
||||
- [ ] `Encrypt(key [32]byte, plaintext []byte) (string, error)` - NaCl secretbox encrypt, returns base64url
|
||||
- [ ] `Decrypt(key [32]byte, ciphertext string) ([]byte, error)` - NaCl secretbox decrypt from base64url
|
||||
- [ ] Create `internal/crypto/crypto_test.go` with tests for:
|
||||
- [ ] Key derivation produces consistent keys for same input
|
||||
- [ ] Encrypt/decrypt round-trip
|
||||
- [ ] Decryption fails with wrong key
|
||||
- [ ] Decryption fails with tampered ciphertext
|
||||
|
||||
### Phase 2: Session Management
|
||||
- [ ] Add `github.com/gorilla/securecookie` dependency
|
||||
- [ ] Create `internal/session/session.go` with:
|
||||
- [ ] `SessionData` struct: `Authenticated bool`, `CreatedAt time.Time`, `ExpiresAt time.Time`
|
||||
- [ ] `Manager` struct using gorilla/securecookie with keys derived via HKDF
|
||||
- [ ] `NewManager(signingKey string, secure bool) (*Manager, error)`
|
||||
- [ ] `CreateSession(w http.ResponseWriter) error` - creates 30-day encrypted cookie
|
||||
- [ ] `ValidateSession(r *http.Request) (*SessionData, error)` - decrypts and validates cookie
|
||||
- [ ] `ClearSession(w http.ResponseWriter)` - clears cookie (logout)
|
||||
- [ ] `IsAuthenticated(r *http.Request) bool` - convenience wrapper
|
||||
- [ ] Cookie settings: `HttpOnly`, `Secure` (prod), `SameSite=Strict`, name `pixa_session`
|
||||
- [ ] Create `internal/session/session_test.go` with tests for:
|
||||
- [ ] Session creation and validation round-trip
|
||||
- [ ] Expired session rejection
|
||||
- [ ] Tampered cookie rejection
|
||||
- [ ] Missing cookie returns unauthenticated
|
||||
|
||||
### Phase 3: Encrypted URL Generation
|
||||
- [ ] Add `github.com/fxamacker/cbor/v2` dependency for compact binary encoding
|
||||
- [ ] Create `internal/encurl/encurl.go` with:
|
||||
- [ ] `EncryptedPayload` struct (short CBOR field names, omitempty for fields with sane defaults):
|
||||
- `SourceHost string` (`cbor:"h"`) - required
|
||||
- `SourcePath string` (`cbor:"p"`) - required
|
||||
- `SourceQuery string` (`cbor:"q,omitempty"`) - optional
|
||||
- `Width int` (`cbor:"w,omitempty"`) - 0 = original
|
||||
- `Height int` (`cbor:"ht,omitempty"`) - 0 = original
|
||||
- `Format ImageFormat` (`cbor:"f,omitempty"`) - default: orig
|
||||
- `Quality int` (`cbor:"ql,omitempty"`) - default: 85
|
||||
- `FitMode FitMode` (`cbor:"fm,omitempty"`) - default: cover
|
||||
- `ExpiresAt int64` (`cbor:"e"`) - required, Unix timestamp
|
||||
- [ ] `Generator` struct with URL key derived via HKDF (salt: `"pixa-urlenc-v1"`)
|
||||
- [ ] `NewGenerator(signingKey string) (*Generator, error)`
|
||||
- [ ] `Generate(p *EncryptedPayload) (string, error)` - CBOR encode, encrypt, base64url
|
||||
- [ ] `Parse(token string) (*EncryptedPayload, error)` - base64url decode, decrypt, CBOR decode, validate expiration
|
||||
- [ ] `(p *EncryptedPayload) ToImageRequest() *imgcache.ImageRequest`
|
||||
- [ ] Create `internal/encurl/encurl_test.go` with tests for:
|
||||
- [ ] Generate/parse round-trip preserves all fields
|
||||
- [ ] Expired URL returns `ErrExpired`
|
||||
- [ ] Malformed token returns error
|
||||
- [ ] Tampered token fails decryption
|
||||
|
||||
### Phase 4: HTML Templates
|
||||
- [ ] Create `templates/templates.go` with:
|
||||
- [ ] `//go:embed *.html` for embedded templates
|
||||
- [ ] `GetParsed() *template.Template` function
|
||||
- [ ] Create `templates/login.html`:
|
||||
- [ ] Simple form with password input for signing key
|
||||
- [ ] POST to `/`
|
||||
- [ ] Error message display area
|
||||
- [ ] Minimal inline CSS
|
||||
- [ ] Create `templates/generator.html`:
|
||||
- [ ] Logout link at top
|
||||
- [ ] Form with fields: Source URL, Width, Height, Format (dropdown), Quality, Fit Mode (dropdown), Expiration TTL (dropdown)
|
||||
- [ ] POST to `/generate`
|
||||
- [ ] Result display area showing generated URL and expiration
|
||||
- [ ] Click-to-copy input field for generated URL
|
||||
|
||||
### Phase 5: Auth Handlers
|
||||
- [ ] Create `internal/handlers/auth.go` with:
|
||||
- [ ] `HandleRoot() http.HandlerFunc` - serves login form (GET) or authenticates (POST) if not logged in; serves generator form if logged in
|
||||
- [ ] `handleLoginGet(w, r)` - renders login.html template
|
||||
- [ ] `handleLoginPost(w, r)` - validates key with constant-time comparison, creates session on success
|
||||
- [ ] `HandleLogout() http.HandlerFunc` - clears session, redirects to `/`
|
||||
- [ ] `HandleGenerateURL() http.HandlerFunc` - parses form, generates encrypted URL, renders generator.html with result
|
||||
|
||||
### Phase 6: Encrypted Image Handler
|
||||
- [ ] Create `internal/handlers/imageenc.go` with:
|
||||
- [ ] `HandleImageEnc() http.HandlerFunc` - handles `/v1/e/{token}`
|
||||
- [ ] Extract token from chi URL param
|
||||
- [ ] Decrypt and validate via `encGen.Parse(token)`
|
||||
- [ ] Convert to `ImageRequest` via `payload.ToImageRequest()`
|
||||
- [ ] Serve via `imgSvc.Get()` (bypass signature validation - encrypted URL is trusted)
|
||||
- [ ] Set same response headers as regular image handler (Cache-Control, Content-Type, etc.)
|
||||
- [ ] Handle errors: expired → 410 Gone, decrypt fail → 400 Bad Request
|
||||
|
||||
### Phase 7: Handler Integration
|
||||
- [ ] Modify `internal/handlers/handlers.go`:
|
||||
- [ ] Add `sessMgr *session.Manager` field to `Handlers` struct
|
||||
- [ ] Add `encGen *encurl.Generator` field to `Handlers` struct
|
||||
- [ ] Add `templates *template.Template` field to `Handlers` struct
|
||||
- [ ] Initialize session manager and URL generator in `initImageService()` or new init method
|
||||
- [ ] Add import for `internal/session`, `internal/encurl`, `templates`
|
||||
|
||||
### Phase 8: Route Registration
|
||||
- [ ] Modify `internal/server/routes.go`:
|
||||
- [ ] Add `s.router.Get("/", s.h.HandleRoot())` - login or generator page
|
||||
- [ ] Add `s.router.Post("/", s.h.HandleRoot())` - login form submission
|
||||
- [ ] Add `s.router.Get("/logout", s.h.HandleLogout())` - logout
|
||||
- [ ] Add `s.router.Post("/generate", s.h.HandleGenerateURL())` - URL generation
|
||||
- [ ] Add `s.router.Get("/v1/e/{token}", s.h.HandleImageEnc())` - encrypted image serving
|
||||
|
||||
### Phase 9: Testing & Verification
|
||||
- [ ] Run `make check` to verify lint and tests pass
|
||||
- [ ] Manual test: visit `/`, see login form
|
||||
- [ ] Manual test: enter wrong key, see error
|
||||
- [ ] Manual test: enter correct signing key, see generator form
|
||||
- [ ] Manual test: generate encrypted URL, verify it works
|
||||
- [ ] Manual test: wait for expiration or use short TTL, verify expired URL returns 410
|
||||
- [ ] Manual test: logout, verify redirected to login
|
||||
|
||||
## Project Setup
|
||||
- [x] Create Makefile with check, lint, test, fmt targets
|
||||
- [x] Create project structure (cmd/pixad, internal/*)
|
||||
|
||||
5
go.mod
5
go.mod
@@ -13,6 +13,7 @@ require (
|
||||
github.com/slok/go-http-metrics v0.13.0
|
||||
github.com/spf13/cobra v1.10.2
|
||||
go.uber.org/fx v1.24.0
|
||||
golang.org/x/crypto v0.41.0
|
||||
golang.org/x/image v0.34.0
|
||||
modernc.org/sqlite v1.42.2
|
||||
)
|
||||
@@ -54,7 +55,7 @@ require (
|
||||
github.com/emicklei/go-restful/v3 v3.12.1 // indirect
|
||||
github.com/fatih/color v1.16.0 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.0.5 // indirect
|
||||
github.com/go-logr/logr v1.4.2 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
@@ -70,6 +71,7 @@ require (
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.14.2 // indirect
|
||||
github.com/gorilla/securecookie v1.1.2 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect
|
||||
github.com/hashicorp/consul/api v1.32.1 // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
@@ -124,7 +126,6 @@ require (
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.uber.org/zap v1.27.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||
golang.org/x/crypto v0.41.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
||||
golang.org/x/net v0.43.0 // indirect
|
||||
golang.org/x/oauth2 v0.30.0 // indirect
|
||||
|
||||
4
go.sum
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/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
|
||||
github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/getsentry/sentry-go v0.40.0 h1:VTJMN9zbTvqDqPwheRVLcp0qcUcM+8eFivvGocAaSbo=
|
||||
github.com/getsentry/sentry-go v0.40.0/go.mod h1:eRXCoh3uvmjQLY6qu63BjUZnaBu5L5WhMV1RwYO8W5s=
|
||||
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
|
||||
@@ -172,6 +174,8 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
|
||||
github.com/googleapis/gax-go/v2 v2.14.2 h1:eBLnkZ9635krYIPD+ag1USrOAI0Nr0QYF3+/3GqO0k0=
|
||||
github.com/googleapis/gax-go/v2 v2.14.2/go.mod h1:ON64QhlJkhVtSqp4v1uaK92VyZ2gmvDQsweuyLV+8+w=
|
||||
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
|
||||
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI=
|
||||
github.com/hashicorp/consul/api v1.32.1 h1:0+osr/3t/aZNAdJX558crU3PEjVrG4x6715aZHRgceE=
|
||||
|
||||
@@ -166,3 +166,77 @@ func (s *Database) runMigrations(ctx context.Context) error {
|
||||
func (s *Database) DB() *sql.DB {
|
||||
return s.db
|
||||
}
|
||||
|
||||
// ApplyMigrations applies all migrations to the given database.
|
||||
// This is useful for testing where you want to use the real schema
|
||||
// without the full fx lifecycle.
|
||||
func ApplyMigrations(db *sql.DB) error {
|
||||
ctx := context.Background()
|
||||
|
||||
// Create migrations tracking table
|
||||
_, err := db.ExecContext(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version TEXT PRIMARY KEY,
|
||||
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create migrations table: %w", err)
|
||||
}
|
||||
|
||||
// Get list of migration files
|
||||
entries, err := schemaFS.ReadDir("schema")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read schema directory: %w", err)
|
||||
}
|
||||
|
||||
// Sort migration files by name (001.sql, 002.sql, etc.)
|
||||
var migrations []string
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".sql") {
|
||||
migrations = append(migrations, entry.Name())
|
||||
}
|
||||
}
|
||||
sort.Strings(migrations)
|
||||
|
||||
// Apply each migration that hasn't been applied yet
|
||||
for _, migration := range migrations {
|
||||
version := strings.TrimSuffix(migration, filepath.Ext(migration))
|
||||
|
||||
// Check if already applied
|
||||
var count int
|
||||
err := db.QueryRowContext(ctx,
|
||||
"SELECT COUNT(*) FROM schema_migrations WHERE version = ?",
|
||||
version,
|
||||
).Scan(&count)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check migration status: %w", err)
|
||||
}
|
||||
|
||||
if count > 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Read and apply migration
|
||||
content, err := schemaFS.ReadFile(filepath.Join("schema", migration))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read migration %s: %w", migration, err)
|
||||
}
|
||||
|
||||
_, err = db.ExecContext(ctx, string(content))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to apply migration %s: %w", migration, err)
|
||||
}
|
||||
|
||||
// Record migration as applied
|
||||
_, err = db.ExecContext(ctx,
|
||||
"INSERT INTO schema_migrations (version) VALUES (?)",
|
||||
version,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to record migration %s: %w", migration, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
158
internal/encurl/encurl.go
Normal file
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"
|
||||
"sneak.berlin/go/pixa/internal/config"
|
||||
"sneak.berlin/go/pixa/internal/database"
|
||||
"sneak.berlin/go/pixa/internal/encurl"
|
||||
"sneak.berlin/go/pixa/internal/healthcheck"
|
||||
"sneak.berlin/go/pixa/internal/imgcache"
|
||||
"sneak.berlin/go/pixa/internal/logger"
|
||||
"sneak.berlin/go/pixa/internal/session"
|
||||
)
|
||||
|
||||
// Params defines dependencies for Handlers.
|
||||
@@ -33,6 +35,8 @@ type Handlers struct {
|
||||
config *config.Config
|
||||
imgSvc *imgcache.Service
|
||||
imgCache *imgcache.Cache
|
||||
sessMgr *session.Manager
|
||||
encGen *encurl.Generator
|
||||
}
|
||||
|
||||
// New creates a new Handlers instance.
|
||||
@@ -91,6 +95,23 @@ func (s *Handlers) initImageService() error {
|
||||
s.imgSvc = svc
|
||||
s.log.Info("image service initialized")
|
||||
|
||||
// Initialize session manager and URL generator if signing key is configured
|
||||
if s.config.SigningKey != "" {
|
||||
sessMgr, err := session.NewManager(s.config.SigningKey, !s.config.Debug)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.sessMgr = sessMgr
|
||||
|
||||
encGen, err := encurl.NewGenerator(s.config.SigningKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.encGen = encGen
|
||||
|
||||
s.log.Info("session manager and URL generator initialized")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
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.
|
||||
type Service struct {
|
||||
cache *Cache
|
||||
fetcher *HTTPFetcher
|
||||
fetcher Fetcher
|
||||
processor Processor
|
||||
signer *Signer
|
||||
whitelist *HostWhitelist
|
||||
@@ -25,8 +25,10 @@ type Service struct {
|
||||
type ServiceConfig struct {
|
||||
// Cache is the cache instance
|
||||
Cache *Cache
|
||||
// FetcherConfig configures the upstream fetcher
|
||||
// FetcherConfig configures the upstream fetcher (ignored if Fetcher is set)
|
||||
FetcherConfig *FetcherConfig
|
||||
// Fetcher is an optional custom fetcher (for testing)
|
||||
Fetcher Fetcher
|
||||
// SigningKey is the HMAC signing key (empty disables signing)
|
||||
SigningKey string
|
||||
// Whitelist is the list of hosts that don't require signatures
|
||||
@@ -41,9 +43,16 @@ func NewService(cfg *ServiceConfig) (*Service, error) {
|
||||
return nil, errors.New("cache is required")
|
||||
}
|
||||
|
||||
fetcherCfg := cfg.FetcherConfig
|
||||
if fetcherCfg == nil {
|
||||
fetcherCfg = DefaultFetcherConfig()
|
||||
// Use custom fetcher if provided, otherwise create HTTP fetcher
|
||||
var fetcher Fetcher
|
||||
if cfg.Fetcher != nil {
|
||||
fetcher = cfg.Fetcher
|
||||
} else {
|
||||
fetcherCfg := cfg.FetcherConfig
|
||||
if fetcherCfg == nil {
|
||||
fetcherCfg = DefaultFetcherConfig()
|
||||
}
|
||||
fetcher = NewHTTPFetcher(fetcherCfg)
|
||||
}
|
||||
|
||||
var signer *Signer
|
||||
@@ -58,7 +67,7 @@ func NewService(cfg *ServiceConfig) (*Service, error) {
|
||||
|
||||
return &Service{
|
||||
cache: cfg.Cache,
|
||||
fetcher: NewHTTPFetcher(fetcherCfg),
|
||||
fetcher: fetcher,
|
||||
processor: NewImageProcessor(),
|
||||
signer: signer,
|
||||
whitelist: NewHostWhitelist(cfg.Whitelist),
|
||||
|
||||
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/middleware"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
|
||||
"sneak.berlin/go/pixa/internal/static"
|
||||
)
|
||||
|
||||
// SetupRoutes configures all HTTP routes.
|
||||
@@ -38,10 +40,22 @@ func (s *Server) SetupRoutes() {
|
||||
// Robots.txt
|
||||
s.router.Get("/robots.txt", s.h.HandleRobotsTxt())
|
||||
|
||||
// Static files (Tailwind CSS, etc.)
|
||||
s.router.Handle("/static/*", http.StripPrefix("/static/", static.Handler()))
|
||||
|
||||
// Login/generator UI
|
||||
s.router.Get("/", s.h.HandleRoot())
|
||||
s.router.Post("/", s.h.HandleRoot())
|
||||
s.router.Get("/logout", s.h.HandleLogout())
|
||||
s.router.Post("/generate", s.h.HandleGenerateURL())
|
||||
|
||||
// Main image proxy route
|
||||
// /v1/image/<host>/<path>/<width>x<height>.<format>
|
||||
s.router.Get("/v1/image/*", s.h.HandleImage())
|
||||
|
||||
// Encrypted image URL route
|
||||
s.router.Get("/v1/e/{token}", s.h.HandleImageEnc())
|
||||
|
||||
// Metrics endpoint with auth
|
||||
if s.config.MetricsUsername != "" {
|
||||
s.router.Group(func(r chi.Router) {
|
||||
|
||||
145
internal/session/session.go
Normal file
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