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

This commit is contained in:
sneak committed 2026-01-08 07:41:28 -08:00
commit 06c99b6f4e
24 files changed
+2768 -5

No files matched your search

+9
View File
@@ -57,3 +57,12 @@ These rules MUST be followed at all times, it is very important.
* As this is production code, no stubbing of implementations unless
specifically instructed. We need working implementations.
* Avoid vendoring deps unless specifically instructed to. NEVER commit
the vendor directory, NEVER commit compiled binaries. If these
directories or files exist, add them to .gitignore (and commit the
.gitignore) if they are not already in there. Keep the entire git
repository (with history) small - under 20MiB, unless you specifically
must commit larger files (e.g. test fixture example media files). Only
OUR source code and immediately supporting files (such as test examples)
goes into the repo/history.
+113
View File
@@ -2,6 +2,119 @@
A single linear checklist of tasks to implement the complete pixa caching image reverse proxy server.
## Login, Sessions & Encrypted URLs Feature
### Phase 1: Crypto Foundation
- [ ] Create `internal/crypto/crypto.go` with:
- [ ] `DeriveKey(masterKey []byte, salt string) ([32]byte, error)` - HKDF-SHA256 key derivation
- [ ] `Encrypt(key [32]byte, plaintext []byte) (string, error)` - NaCl secretbox encrypt, returns base64url
- [ ] `Decrypt(key [32]byte, ciphertext string) ([]byte, error)` - NaCl secretbox decrypt from base64url
- [ ] Create `internal/crypto/crypto_test.go` with tests for:
- [ ] Key derivation produces consistent keys for same input
- [ ] Encrypt/decrypt round-trip
- [ ] Decryption fails with wrong key
- [ ] Decryption fails with tampered ciphertext
### Phase 2: Session Management
- [ ] Add `github.com/gorilla/securecookie` dependency
- [ ] Create `internal/session/session.go` with:
- [ ] `SessionData` struct: `Authenticated bool`, `CreatedAt time.Time`, `ExpiresAt time.Time`
- [ ] `Manager` struct using gorilla/securecookie with keys derived via HKDF
- [ ] `NewManager(signingKey string, secure bool) (*Manager, error)`
- [ ] `CreateSession(w http.ResponseWriter) error` - creates 30-day encrypted cookie
- [ ] `ValidateSession(r *http.Request) (*SessionData, error)` - decrypts and validates cookie
- [ ] `ClearSession(w http.ResponseWriter)` - clears cookie (logout)
- [ ] `IsAuthenticated(r *http.Request) bool` - convenience wrapper
- [ ] Cookie settings: `HttpOnly`, `Secure` (prod), `SameSite=Strict`, name `pixa_session`
- [ ] Create `internal/session/session_test.go` with tests for:
- [ ] Session creation and validation round-trip
- [ ] Expired session rejection
- [ ] Tampered cookie rejection
- [ ] Missing cookie returns unauthenticated
### Phase 3: Encrypted URL Generation
- [ ] Add `github.com/fxamacker/cbor/v2` dependency for compact binary encoding
- [ ] Create `internal/encurl/encurl.go` with:
- [ ] `EncryptedPayload` struct (short CBOR field names, omitempty for fields with sane defaults):
- `SourceHost string` (`cbor:"h"`) - required
- `SourcePath string` (`cbor:"p"`) - required
- `SourceQuery string` (`cbor:"q,omitempty"`) - optional
- `Width int` (`cbor:"w,omitempty"`) - 0 = original
- `Height int` (`cbor:"ht,omitempty"`) - 0 = original
- `Format ImageFormat` (`cbor:"f,omitempty"`) - default: orig
- `Quality int` (`cbor:"ql,omitempty"`) - default: 85
- `FitMode FitMode` (`cbor:"fm,omitempty"`) - default: cover
- `ExpiresAt int64` (`cbor:"e"`) - required, Unix timestamp
- [ ] `Generator` struct with URL key derived via HKDF (salt: `"pixa-urlenc-v1"`)
- [ ] `NewGenerator(signingKey string) (*Generator, error)`
- [ ] `Generate(p *EncryptedPayload) (string, error)` - CBOR encode, encrypt, base64url
- [ ] `Parse(token string) (*EncryptedPayload, error)` - base64url decode, decrypt, CBOR decode, validate expiration
- [ ] `(p *EncryptedPayload) ToImageRequest() *imgcache.ImageRequest`
- [ ] Create `internal/encurl/encurl_test.go` with tests for:
- [ ] Generate/parse round-trip preserves all fields
- [ ] Expired URL returns `ErrExpired`
- [ ] Malformed token returns error
- [ ] Tampered token fails decryption
### Phase 4: HTML Templates
- [ ] Create `templates/templates.go` with:
- [ ] `//go:embed *.html` for embedded templates
- [ ] `GetParsed() *template.Template` function
- [ ] Create `templates/login.html`:
- [ ] Simple form with password input for signing key
- [ ] POST to `/`
- [ ] Error message display area
- [ ] Minimal inline CSS
- [ ] Create `templates/generator.html`:
- [ ] Logout link at top
- [ ] Form with fields: Source URL, Width, Height, Format (dropdown), Quality, Fit Mode (dropdown), Expiration TTL (dropdown)
- [ ] POST to `/generate`
- [ ] Result display area showing generated URL and expiration
- [ ] Click-to-copy input field for generated URL
### Phase 5: Auth Handlers
- [ ] Create `internal/handlers/auth.go` with:
- [ ] `HandleRoot() http.HandlerFunc` - serves login form (GET) or authenticates (POST) if not logged in; serves generator form if logged in
- [ ] `handleLoginGet(w, r)` - renders login.html template
- [ ] `handleLoginPost(w, r)` - validates key with constant-time comparison, creates session on success
- [ ] `HandleLogout() http.HandlerFunc` - clears session, redirects to `/`
- [ ] `HandleGenerateURL() http.HandlerFunc` - parses form, generates encrypted URL, renders generator.html with result
### Phase 6: Encrypted Image Handler
- [ ] Create `internal/handlers/imageenc.go` with:
- [ ] `HandleImageEnc() http.HandlerFunc` - handles `/v1/e/{token}`
- [ ] Extract token from chi URL param
- [ ] Decrypt and validate via `encGen.Parse(token)`
- [ ] Convert to `ImageRequest` via `payload.ToImageRequest()`
- [ ] Serve via `imgSvc.Get()` (bypass signature validation - encrypted URL is trusted)
- [ ] Set same response headers as regular image handler (Cache-Control, Content-Type, etc.)
- [ ] Handle errors: expired → 410 Gone, decrypt fail → 400 Bad Request
### Phase 7: Handler Integration
- [ ] Modify `internal/handlers/handlers.go`:
- [ ] Add `sessMgr *session.Manager` field to `Handlers` struct
- [ ] Add `encGen *encurl.Generator` field to `Handlers` struct
- [ ] Add `templates *template.Template` field to `Handlers` struct
- [ ] Initialize session manager and URL generator in `initImageService()` or new init method
- [ ] Add import for `internal/session`, `internal/encurl`, `templates`
### Phase 8: Route Registration
- [ ] Modify `internal/server/routes.go`:
- [ ] Add `s.router.Get("/", s.h.HandleRoot())` - login or generator page
- [ ] Add `s.router.Post("/", s.h.HandleRoot())` - login form submission
- [ ] Add `s.router.Get("/logout", s.h.HandleLogout())` - logout
- [ ] Add `s.router.Post("/generate", s.h.HandleGenerateURL())` - URL generation
- [ ] Add `s.router.Get("/v1/e/{token}", s.h.HandleImageEnc())` - encrypted image serving
### Phase 9: Testing & Verification
- [ ] Run `make check` to verify lint and tests pass
- [ ] Manual test: visit `/`, see login form
- [ ] Manual test: enter wrong key, see error
- [ ] Manual test: enter correct signing key, see generator form
- [ ] Manual test: generate encrypted URL, verify it works
- [ ] Manual test: wait for expiration or use short TTL, verify expired URL returns 410
- [ ] Manual test: logout, verify redirected to login
## Project Setup
- [x] Create Makefile with check, lint, test, fmt targets
- [x] Create project structure (cmd/pixad, internal/*)
+3 -2
View File
@@ -13,6 +13,7 @@ require (
github.com/slok/go-http-metrics v0.13.0
github.com/spf13/cobra v1.10.2
go.uber.org/fx v1.24.0
golang.org/x/crypto v0.41.0
golang.org/x/image v0.34.0
modernc.org/sqlite v1.42.2
)
@@ -54,7 +55,7 @@ require (
github.com/emicklei/go-restful/v3 v3.12.1 // indirect
github.com/fatih/color v1.16.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/go-jose/go-jose/v4 v4.0.5 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
@@ -70,6 +71,7 @@ require (
github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect
github.com/googleapis/gax-go/v2 v2.14.2 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect
github.com/hashicorp/consul/api v1.32.1 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
@@ -124,7 +126,6 @@ require (
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/crypto v0.41.0 // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/net v0.43.0 // indirect
golang.org/x/oauth2 v0.30.0 // indirect
+4
View File
@@ -108,6 +108,8 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/getsentry/sentry-go v0.40.0 h1:VTJMN9zbTvqDqPwheRVLcp0qcUcM+8eFivvGocAaSbo=
github.com/getsentry/sentry-go v0.40.0/go.mod h1:eRXCoh3uvmjQLY6qu63BjUZnaBu5L5WhMV1RwYO8W5s=
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
@@ -172,6 +174,8 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU
github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
github.com/googleapis/gax-go/v2 v2.14.2 h1:eBLnkZ9635krYIPD+ag1USrOAI0Nr0QYF3+/3GqO0k0=
github.com/googleapis/gax-go/v2 v2.14.2/go.mod h1:ON64QhlJkhVtSqp4v1uaK92VyZ2gmvDQsweuyLV+8+w=
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI=
github.com/hashicorp/consul/api v1.32.1 h1:0+osr/3t/aZNAdJX558crU3PEjVrG4x6715aZHRgceE=
+74
View File
@@ -166,3 +166,77 @@ func (s *Database) runMigrations(ctx context.Context) error {
func (s *Database) DB() *sql.DB {
return s.db
}
// ApplyMigrations applies all migrations to the given database.
// This is useful for testing where you want to use the real schema
// without the full fx lifecycle.
func ApplyMigrations(db *sql.DB) error {
ctx := context.Background()
// Create migrations tracking table
_, err := db.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS schema_migrations (
version TEXT PRIMARY KEY,
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`)
if err != nil {
return fmt.Errorf("failed to create migrations table: %w", err)
}
// Get list of migration files
entries, err := schemaFS.ReadDir("schema")
if err != nil {
return fmt.Errorf("failed to read schema directory: %w", err)
}
// Sort migration files by name (001.sql, 002.sql, etc.)
var migrations []string
for _, entry := range entries {
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".sql") {
migrations = append(migrations, entry.Name())
}
}
sort.Strings(migrations)
// Apply each migration that hasn't been applied yet
for _, migration := range migrations {
version := strings.TrimSuffix(migration, filepath.Ext(migration))
// Check if already applied
var count int
err := db.QueryRowContext(ctx,
"SELECT COUNT(*) FROM schema_migrations WHERE version = ?",
version,
).Scan(&count)
if err != nil {
return fmt.Errorf("failed to check migration status: %w", err)
}
if count > 0 {
continue
}
// Read and apply migration
content, err := schemaFS.ReadFile(filepath.Join("schema", migration))
if err != nil {
return fmt.Errorf("failed to read migration %s: %w", migration, err)
}
_, err = db.ExecContext(ctx, string(content))
if err != nil {
return fmt.Errorf("failed to apply migration %s: %w", migration, err)
}
// Record migration as applied
_, err = db.ExecContext(ctx,
"INSERT INTO schema_migrations (version) VALUES (?)",
version,
)
if err != nil {
return fmt.Errorf("failed to record migration %s: %w", migration, err)
}
}
return nil
}
+158
View File
@@ -0,0 +1,158 @@
// Package encurl provides encrypted URL generation and parsing for pixa.
package encurl
import (
"errors"
"time"
"github.com/fxamacker/cbor/v2"
"sneak.berlin/go/pixa/internal/imgcache"
"sneak.berlin/go/pixa/internal/seal"
)
// Default values for optional fields.
const (
DefaultQuality = 85
DefaultFormat = imgcache.FormatOriginal
DefaultFitMode = imgcache.FitCover
// HKDF salt for URL encryption key derivation
urlKeySalt = "pixa-urlenc-v1"
)
// Errors returned by encurl operations.
var (
ErrExpired = errors.New("encrypted URL has expired")
ErrInvalidFormat = errors.New("invalid encrypted URL format")
ErrDecryptFailed = errors.New("failed to decrypt URL")
)
// Payload contains all image request parameters in a compact format.
// Field names are short to minimize encoded size.
// Fields with omitempty use sane defaults when absent.
type Payload struct {
SourceHost string `cbor:"h"` // required
SourcePath string `cbor:"p"` // required
SourceQuery string `cbor:"q,omitempty"` // optional
Width int `cbor:"w,omitempty"` // 0 = original
Height int `cbor:"ht,omitempty"` // 0 = original
Format imgcache.ImageFormat `cbor:"f,omitempty"` // default: orig
Quality int `cbor:"ql,omitempty"` // default: 85
FitMode imgcache.FitMode `cbor:"fm,omitempty"` // default: cover
ExpiresAt int64 `cbor:"e"` // required, Unix timestamp
}
// Generator creates and parses encrypted URL tokens.
type Generator struct {
key [seal.KeySize]byte
}
// NewGenerator creates an encrypted URL generator with a key derived from the signing key.
func NewGenerator(signingKey string) (*Generator, error) {
key, err := seal.DeriveKey([]byte(signingKey), urlKeySalt)
if err != nil {
return nil, err
}
return &Generator{key: key}, nil
}
// Generate creates an encrypted URL token from the payload.
// The token is CBOR-encoded, encrypted with NaCl secretbox, and base64url-encoded.
func (g *Generator) Generate(p *Payload) (string, error) {
// CBOR encode the payload
data, err := cbor.Marshal(p)
if err != nil {
return "", err
}
// Encrypt and base64url encode
return seal.Encrypt(g.key, data)
}
// Parse decrypts and validates an encrypted URL token.
// Returns ErrExpired if the token has expired, or other errors for invalid tokens.
func (g *Generator) Parse(token string) (*Payload, error) {
// Decrypt
data, err := seal.Decrypt(g.key, token)
if err != nil {
if errors.Is(err, seal.ErrDecryptionFailed) || errors.Is(err, seal.ErrInvalidPayload) {
return nil, ErrDecryptFailed
}
return nil, err
}
// CBOR decode
var p Payload
if err := cbor.Unmarshal(data, &p); err != nil {
return nil, ErrInvalidFormat
}
// Check expiration
if time.Now().Unix() > p.ExpiresAt {
return nil, ErrExpired
}
return &p, nil
}
// ToImageRequest converts the payload to an ImageRequest.
// Applies default values for omitted optional fields.
func (p *Payload) ToImageRequest() *imgcache.ImageRequest {
format := p.Format
if format == "" {
format = DefaultFormat
}
quality := p.Quality
if quality == 0 {
quality = DefaultQuality
}
fitMode := p.FitMode
if fitMode == "" {
fitMode = DefaultFitMode
}
return &imgcache.ImageRequest{
SourceHost: p.SourceHost,
SourcePath: p.SourcePath,
SourceQuery: p.SourceQuery,
Size: imgcache.Size{
Width: p.Width,
Height: p.Height,
},
Format: format,
Quality: quality,
FitMode: fitMode,
}
}
// FromImageRequest creates a Payload from an ImageRequest with the given expiration.
func FromImageRequest(req *imgcache.ImageRequest, expiresAt time.Time) *Payload {
p := &Payload{
SourceHost: req.SourceHost,
SourcePath: req.SourcePath,
SourceQuery: req.SourceQuery,
Width: req.Size.Width,
Height: req.Size.Height,
ExpiresAt: expiresAt.Unix(),
}
// Only set non-default values to benefit from omitempty
if req.Format != "" && req.Format != DefaultFormat {
p.Format = req.Format
}
if req.Quality != 0 && req.Quality != DefaultQuality {
p.Quality = req.Quality
}
if req.FitMode != "" && req.FitMode != DefaultFitMode {
p.FitMode = req.FitMode
}
return p
}
+281
View File
@@ -0,0 +1,281 @@
package encurl
import (
"testing"
"time"
"sneak.berlin/go/pixa/internal/imgcache"
)
func TestGenerator_GenerateAndParse(t *testing.T) {
gen, err := NewGenerator("test-signing-key-12345")
if err != nil {
t.Fatalf("NewGenerator() error = %v", err)
}
payload := &Payload{
SourceHost: "cdn.example.com",
SourcePath: "/images/photo.jpg",
SourceQuery: "v=2",
Width: 800,
Height: 600,
Format: imgcache.FormatWebP,
Quality: 90,
FitMode: imgcache.FitContain,
ExpiresAt: time.Now().Add(time.Hour).Unix(),
}
token, err := gen.Generate(payload)
if err != nil {
t.Fatalf("Generate() error = %v", err)
}
if token == "" {
t.Fatal("Generate() returned empty token")
}
parsed, err := gen.Parse(token)
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
// Verify all fields
if parsed.SourceHost != payload.SourceHost {
t.Errorf("SourceHost = %q, want %q", parsed.SourceHost, payload.SourceHost)
}
if parsed.SourcePath != payload.SourcePath {
t.Errorf("SourcePath = %q, want %q", parsed.SourcePath, payload.SourcePath)
}
if parsed.SourceQuery != payload.SourceQuery {
t.Errorf("SourceQuery = %q, want %q", parsed.SourceQuery, payload.SourceQuery)
}
if parsed.Width != payload.Width {
t.Errorf("Width = %d, want %d", parsed.Width, payload.Width)
}
if parsed.Height != payload.Height {
t.Errorf("Height = %d, want %d", parsed.Height, payload.Height)
}
if parsed.Format != payload.Format {
t.Errorf("Format = %q, want %q", parsed.Format, payload.Format)
}
if parsed.Quality != payload.Quality {
t.Errorf("Quality = %d, want %d", parsed.Quality, payload.Quality)
}
if parsed.FitMode != payload.FitMode {
t.Errorf("FitMode = %q, want %q", parsed.FitMode, payload.FitMode)
}
if parsed.ExpiresAt != payload.ExpiresAt {
t.Errorf("ExpiresAt = %d, want %d", parsed.ExpiresAt, payload.ExpiresAt)
}
}
func TestGenerator_Parse_Expired(t *testing.T) {
gen, _ := NewGenerator("test-signing-key-12345")
payload := &Payload{
SourceHost: "cdn.example.com",
SourcePath: "/images/photo.jpg",
ExpiresAt: time.Now().Add(-time.Hour).Unix(), // Already expired
}
token, err := gen.Generate(payload)
if err != nil {
t.Fatalf("Generate() error = %v", err)
}
_, err = gen.Parse(token)
if err == nil {
t.Error("Parse() should fail for expired token")
}
if err != ErrExpired {
t.Errorf("Parse() error = %v, want %v", err, ErrExpired)
}
}
func TestGenerator_Parse_InvalidToken(t *testing.T) {
gen, _ := NewGenerator("test-signing-key-12345")
_, err := gen.Parse("not-a-valid-token")
if err == nil {
t.Error("Parse() should fail for invalid token")
}
}
func TestGenerator_Parse_TamperedToken(t *testing.T) {
gen, _ := NewGenerator("test-signing-key-12345")
payload := &Payload{
SourceHost: "cdn.example.com",
SourcePath: "/images/photo.jpg",
ExpiresAt: time.Now().Add(time.Hour).Unix(),
}
token, _ := gen.Generate(payload)
// Tamper with the token
tampered := []byte(token)
if len(tampered) > 10 {
tampered[10] ^= 0x01
}
_, err := gen.Parse(string(tampered))
if err == nil {
t.Error("Parse() should fail for tampered token")
}
}
func TestGenerator_Parse_WrongKey(t *testing.T) {
gen1, _ := NewGenerator("signing-key-1")
gen2, _ := NewGenerator("signing-key-2")
payload := &Payload{
SourceHost: "cdn.example.com",
SourcePath: "/images/photo.jpg",
ExpiresAt: time.Now().Add(time.Hour).Unix(),
}
token, _ := gen1.Generate(payload)
_, err := gen2.Parse(token)
if err == nil {
t.Error("Parse() should fail with different signing key")
}
}
func TestPayload_ToImageRequest(t *testing.T) {
payload := &Payload{
SourceHost: "cdn.example.com",
SourcePath: "/images/photo.jpg",
SourceQuery: "v=2",
Width: 800,
Height: 600,
Format: imgcache.FormatWebP,
Quality: 90,
FitMode: imgcache.FitContain,
ExpiresAt: time.Now().Add(time.Hour).Unix(),
}
req := payload.ToImageRequest()
if req.SourceHost != payload.SourceHost {
t.Errorf("SourceHost = %q, want %q", req.SourceHost, payload.SourceHost)
}
if req.SourcePath != payload.SourcePath {
t.Errorf("SourcePath = %q, want %q", req.SourcePath, payload.SourcePath)
}
if req.SourceQuery != payload.SourceQuery {
t.Errorf("SourceQuery = %q, want %q", req.SourceQuery, payload.SourceQuery)
}
if req.Size.Width != payload.Width {
t.Errorf("Width = %d, want %d", req.Size.Width, payload.Width)
}
if req.Size.Height != payload.Height {
t.Errorf("Height = %d, want %d", req.Size.Height, payload.Height)
}
if req.Format != payload.Format {
t.Errorf("Format = %q, want %q", req.Format, payload.Format)
}
if req.Quality != payload.Quality {
t.Errorf("Quality = %d, want %d", req.Quality, payload.Quality)
}
if req.FitMode != payload.FitMode {
t.Errorf("FitMode = %q, want %q", req.FitMode, payload.FitMode)
}
}
func TestPayload_ToImageRequest_Defaults(t *testing.T) {
// Payload with only required fields - should get defaults
payload := &Payload{
SourceHost: "cdn.example.com",
SourcePath: "/images/photo.jpg",
ExpiresAt: time.Now().Add(time.Hour).Unix(),
}
req := payload.ToImageRequest()
if req.Format != DefaultFormat {
t.Errorf("Format = %q, want default %q", req.Format, DefaultFormat)
}
if req.Quality != DefaultQuality {
t.Errorf("Quality = %d, want default %d", req.Quality, DefaultQuality)
}
if req.FitMode != DefaultFitMode {
t.Errorf("FitMode = %q, want default %q", req.FitMode, DefaultFitMode)
}
}
func TestFromImageRequest(t *testing.T) {
req := &imgcache.ImageRequest{
SourceHost: "cdn.example.com",
SourcePath: "/images/photo.jpg",
SourceQuery: "v=2",
Size: imgcache.Size{Width: 800, Height: 600},
Format: imgcache.FormatWebP,
Quality: 90,
FitMode: imgcache.FitContain,
}
expiresAt := time.Now().Add(time.Hour)
payload := FromImageRequest(req, expiresAt)
if payload.SourceHost != req.SourceHost {
t.Errorf("SourceHost = %q, want %q", payload.SourceHost, req.SourceHost)
}
if payload.SourcePath != req.SourcePath {
t.Errorf("SourcePath = %q, want %q", payload.SourcePath, req.SourcePath)
}
if payload.Width != req.Size.Width {
t.Errorf("Width = %d, want %d", payload.Width, req.Size.Width)
}
if payload.ExpiresAt != expiresAt.Unix() {
t.Errorf("ExpiresAt = %d, want %d", payload.ExpiresAt, expiresAt.Unix())
}
}
func TestFromImageRequest_OmitsDefaults(t *testing.T) {
// Request with default values - payload should omit them for smaller encoding
req := &imgcache.ImageRequest{
SourceHost: "cdn.example.com",
SourcePath: "/images/photo.jpg",
Format: DefaultFormat,
Quality: DefaultQuality,
FitMode: DefaultFitMode,
}
payload := FromImageRequest(req, time.Now().Add(time.Hour))
// These should be zero/empty because they match defaults
if payload.Format != "" {
t.Errorf("Format should be empty for default, got %q", payload.Format)
}
if payload.Quality != 0 {
t.Errorf("Quality should be 0 for default, got %d", payload.Quality)
}
if payload.FitMode != "" {
t.Errorf("FitMode should be empty for default, got %q", payload.FitMode)
}
}
func TestGenerator_TokenIsURLSafe(t *testing.T) {
gen, _ := NewGenerator("test-signing-key-12345")
payload := &Payload{
SourceHost: "cdn.example.com",
SourcePath: "/images/photo.jpg",
ExpiresAt: time.Now().Add(time.Hour).Unix(),
}
token, _ := gen.Generate(payload)
// Check that token only contains URL-safe characters
for _, c := range token {
isURLSafe := (c >= 'a' && c <= 'z') ||
(c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9') ||
c == '-' || c == '_'
if !isURLSafe {
t.Errorf("Token contains non-URL-safe character: %q", c)
}
}
}
+232
View File
@@ -0,0 +1,232 @@
package handlers
import (
"crypto/subtle"
"net/http"
"net/url"
"strconv"
"time"
"sneak.berlin/go/pixa/internal/encurl"
"sneak.berlin/go/pixa/internal/imgcache"
"sneak.berlin/go/pixa/internal/templates"
)
// HandleRoot serves the login page or generator page based on authentication state.
func (s *Handlers) HandleRoot() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Check if signing key is configured
if s.sessMgr == nil {
s.respondError(w, "signing key not configured", http.StatusServiceUnavailable)
return
}
if r.Method == http.MethodPost {
s.handleLoginPost(w, r)
return
}
// Check if authenticated
if s.sessMgr.IsAuthenticated(r) {
s.renderGenerator(w, nil)
return
}
// Show login page
s.renderLogin(w, "")
}
}
// handleLoginPost handles login form submission.
func (s *Handlers) handleLoginPost(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
s.renderLogin(w, "Invalid form data")
return
}
submittedKey := r.FormValue("key")
// Constant-time comparison to prevent timing attacks
if subtle.ConstantTimeCompare([]byte(submittedKey), []byte(s.config.SigningKey)) != 1 {
s.log.Warn("failed login attempt", "remote_addr", r.RemoteAddr)
s.renderLogin(w, "Invalid signing key")
return
}
// Create session
if err := s.sessMgr.CreateSession(w); err != nil {
s.log.Error("failed to create session", "error", err)
s.renderLogin(w, "Failed to create session")
return
}
s.log.Info("successful login", "remote_addr", r.RemoteAddr)
// Redirect to generator page
http.Redirect(w, r, "/", http.StatusSeeOther)
}
// HandleLogout clears the session and redirects to login.
func (s *Handlers) HandleLogout() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if s.sessMgr != nil {
s.sessMgr.ClearSession(w)
}
http.Redirect(w, r, "/", http.StatusSeeOther)
}
}
// HandleGenerateURL handles the URL generation form submission.
func (s *Handlers) HandleGenerateURL() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Check authentication
if s.sessMgr == nil || !s.sessMgr.IsAuthenticated(r) {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
if err := r.ParseForm(); err != nil {
s.renderGenerator(w, &generatorData{Error: "Invalid form data"})
return
}
// Parse form values
sourceURL := r.FormValue("url")
widthStr := r.FormValue("width")
heightStr := r.FormValue("height")
format := r.FormValue("format")
qualityStr := r.FormValue("quality")
fit := r.FormValue("fit")
ttlStr := r.FormValue("ttl")
// Validate source URL
parsed, err := url.Parse(sourceURL)
if err != nil || parsed.Host == "" {
s.renderGeneratorWithForm(w, "Invalid source URL", r.Form)
return
}
// Parse dimensions
width, _ := strconv.Atoi(widthStr)
height, _ := strconv.Atoi(heightStr)
quality, _ := strconv.Atoi(qualityStr)
ttl, _ := strconv.Atoi(ttlStr)
if quality <= 0 {
quality = 85
}
if ttl <= 0 {
ttl = 2592000 // 30 days default
}
// Create payload
expiresAt := time.Now().Add(time.Duration(ttl) * time.Second)
payload := &encurl.Payload{
SourceHost: parsed.Host,
SourcePath: parsed.Path,
SourceQuery: parsed.RawQuery,
Width: width,
Height: height,
Format: imgcache.ImageFormat(format),
Quality: quality,
FitMode: imgcache.FitMode(fit),
ExpiresAt: expiresAt.Unix(),
}
// Generate encrypted token
token, err := s.encGen.Generate(payload)
if err != nil {
s.log.Error("failed to generate encrypted URL", "error", err)
s.renderGeneratorWithForm(w, "Failed to generate URL", r.Form)
return
}
// Build full URL
scheme := "https"
if s.config.Debug {
scheme = "http"
}
host := r.Host
generatedURL := scheme + "://" + host + "/v1/e/" + token
s.renderGenerator(w, &generatorData{
GeneratedURL: generatedURL,
ExpiresAt: expiresAt.Format(time.RFC3339),
FormURL: sourceURL,
FormWidth: widthStr,
FormHeight: heightStr,
FormFormat: format,
FormQuality: qualityStr,
FormFit: fit,
FormTTL: ttlStr,
})
}
}
// generatorData holds template data for the generator page.
type generatorData struct {
GeneratedURL string
ExpiresAt string
Error string
FormURL string
FormWidth string
FormHeight string
FormFormat string
FormQuality string
FormFit string
FormTTL string
}
func (s *Handlers) renderLogin(w http.ResponseWriter, errorMsg string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
data := struct {
Error string
}{
Error: errorMsg,
}
if err := templates.Render(w, "login.html", data); err != nil {
s.log.Error("failed to render login template", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
func (s *Handlers) renderGenerator(w http.ResponseWriter, data *generatorData) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if data == nil {
data = &generatorData{}
}
if err := templates.Render(w, "generator.html", data); err != nil {
s.log.Error("failed to render generator template", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
func (s *Handlers) renderGeneratorWithForm(w http.ResponseWriter, errorMsg string, form url.Values) {
s.renderGenerator(w, &generatorData{
Error: errorMsg,
FormURL: form.Get("url"),
FormWidth: form.Get("width"),
FormHeight: form.Get("height"),
FormFormat: form.Get("format"),
FormQuality: form.Get("quality"),
FormFit: form.Get("fit"),
FormTTL: form.Get("ttl"),
})
}
+21
View File
@@ -11,9 +11,11 @@ import (
"go.uber.org/fx"
"sneak.berlin/go/pixa/internal/config"
"sneak.berlin/go/pixa/internal/database"
"sneak.berlin/go/pixa/internal/encurl"
"sneak.berlin/go/pixa/internal/healthcheck"
"sneak.berlin/go/pixa/internal/imgcache"
"sneak.berlin/go/pixa/internal/logger"
"sneak.berlin/go/pixa/internal/session"
)
// Params defines dependencies for Handlers.
@@ -33,6 +35,8 @@ type Handlers struct {
config *config.Config
imgSvc *imgcache.Service
imgCache *imgcache.Cache
sessMgr *session.Manager
encGen *encurl.Generator
}
// New creates a new Handlers instance.
@@ -91,6 +95,23 @@ func (s *Handlers) initImageService() error {
s.imgSvc = svc
s.log.Info("image service initialized")
// Initialize session manager and URL generator if signing key is configured
if s.config.SigningKey != "" {
sessMgr, err := session.NewManager(s.config.SigningKey, !s.config.Debug)
if err != nil {
return err
}
s.sessMgr = sessMgr
encGen, err := encurl.NewGenerator(s.config.SigningKey)
if err != nil {
return err
}
s.encGen = encGen
s.log.Info("session manager and URL generator initialized")
}
return nil
}
+117
View File
@@ -0,0 +1,117 @@
package handlers
import (
"errors"
"io"
"net/http"
"strconv"
"time"
"github.com/go-chi/chi/v5"
"sneak.berlin/go/pixa/internal/encurl"
"sneak.berlin/go/pixa/internal/imgcache"
)
// HandleImageEnc handles requests to /v1/e/{token} for encrypted image URLs.
func (s *Handlers) HandleImageEnc() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
start := time.Now()
// Check if encryption is configured
if s.encGen == nil {
s.respondError(w, "encrypted URLs not configured", http.StatusServiceUnavailable)
return
}
// Extract token from URL
token := chi.URLParam(r, "token")
if token == "" {
s.respondError(w, "missing token", http.StatusBadRequest)
return
}
// Decrypt and validate the payload
payload, err := s.encGen.Parse(token)
if err != nil {
if errors.Is(err, encurl.ErrExpired) {
s.log.Debug("encrypted URL expired", "error", err)
s.respondError(w, "URL has expired", http.StatusGone)
return
}
s.log.Debug("failed to decrypt URL", "error", err)
s.respondError(w, "invalid encrypted URL", http.StatusBadRequest)
return
}
// Convert payload to ImageRequest
req := payload.ToImageRequest()
// Log the request
s.log.Debug("encrypted image request",
"host", req.SourceHost,
"path", req.SourcePath,
"size", req.Size,
"format", req.Format,
)
// Fetch and process the image (no signature validation needed - encrypted URL is trusted)
resp, err := s.imgSvc.Get(ctx, req)
if err != nil {
s.handleImageError(w, err)
return
}
defer func() { _ = resp.Content.Close() }()
// Set response headers
w.Header().Set("Content-Type", resp.ContentType)
if resp.ContentLength > 0 {
w.Header().Set("Content-Length", strconv.FormatInt(resp.ContentLength, 10))
}
// Cache headers - encrypted URLs can be cached since they're immutable
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
w.Header().Set("X-Pixa-Cache", string(resp.CacheStatus))
// Stream the response
written, err := io.Copy(w, resp.Content)
if err != nil {
s.log.Error("failed to write response", "error", err)
return
}
// Log completion
duration := time.Since(start)
s.log.Info("served encrypted image",
"host", req.SourceHost,
"path", req.SourcePath,
"format", req.Format,
"cache_status", resp.CacheStatus,
"served_bytes", written,
"duration_ms", duration.Milliseconds(),
)
}
}
// handleImageError converts image service errors to HTTP responses.
func (s *Handlers) handleImageError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, imgcache.ErrSSRFBlocked):
s.respondError(w, "forbidden", http.StatusForbidden)
case errors.Is(err, imgcache.ErrUpstreamError):
s.respondError(w, "upstream error", http.StatusBadGateway)
case errors.Is(err, imgcache.ErrUpstreamTimeout):
s.respondError(w, "upstream timeout", http.StatusGatewayTimeout)
default:
s.log.Error("image request failed", "error", err)
s.respondError(w, "internal error", http.StatusInternalServerError)
}
}
+115
View File
@@ -0,0 +1,115 @@
package imgcache
import (
"context"
"errors"
"fmt"
"io"
"io/fs"
"net/http"
"strings"
)
// MockFetcher implements the Fetcher interface using an embedded filesystem.
// Files are organized as: hostname/path/to/file.ext
// URLs like https://example.com/images/photo.jpg map to example.com/images/photo.jpg
type MockFetcher struct {
fs fs.FS
}
// NewMockFetcher creates a new mock fetcher backed by the given filesystem.
func NewMockFetcher(fsys fs.FS) *MockFetcher {
return &MockFetcher{fs: fsys}
}
// Fetch retrieves content from the mock filesystem.
func (m *MockFetcher) Fetch(ctx context.Context, url string) (*FetchResult, error) {
// Check context cancellation
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
// Parse URL to get filesystem path
path, err := urlToFSPath(url)
if err != nil {
return nil, err
}
// Open the file
f, err := m.fs.Open(path)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return nil, fmt.Errorf("%w: status 404", ErrUpstreamError)
}
return nil, fmt.Errorf("failed to open mock file: %w", err)
}
// Get file info for content length
stat, err := f.Stat()
if err != nil {
_ = f.Close()
return nil, fmt.Errorf("failed to stat mock file: %w", err)
}
// Detect content type from extension
contentType := detectContentTypeFromPath(path)
return &FetchResult{
Content: f.(io.ReadCloser),
ContentLength: stat.Size(),
ContentType: contentType,
Headers: make(http.Header),
}, nil
}
// urlToFSPath converts a URL to a filesystem path.
// https://example.com/images/photo.jpg -> example.com/images/photo.jpg
func urlToFSPath(rawURL string) (string, error) {
// Strip scheme
url := rawURL
if idx := strings.Index(url, "://"); idx != -1 {
url = url[idx+3:]
}
// Remove query string
if idx := strings.Index(url, "?"); idx != -1 {
url = url[:idx]
}
// Remove fragment
if idx := strings.Index(url, "#"); idx != -1 {
url = url[:idx]
}
if url == "" {
return "", errors.New("empty URL path")
}
return url, nil
}
// detectContentTypeFromPath returns the MIME type based on file extension.
func detectContentTypeFromPath(path string) string {
path = strings.ToLower(path)
switch {
case strings.HasSuffix(path, ".jpg"), strings.HasSuffix(path, ".jpeg"):
return "image/jpeg"
case strings.HasSuffix(path, ".png"):
return "image/png"
case strings.HasSuffix(path, ".gif"):
return "image/gif"
case strings.HasSuffix(path, ".webp"):
return "image/webp"
case strings.HasSuffix(path, ".avif"):
return "image/avif"
case strings.HasSuffix(path, ".svg"):
return "image/svg+xml"
default:
return "application/octet-stream"
}
}
+12 -3
View File
@@ -14,7 +14,7 @@ import (
// Service implements the ImageCache interface, orchestrating cache, fetcher, and processor.
type Service struct {
cache *Cache
fetcher *HTTPFetcher
fetcher Fetcher
processor Processor
signer *Signer
whitelist *HostWhitelist
@@ -25,8 +25,10 @@ type Service struct {
type ServiceConfig struct {
// Cache is the cache instance
Cache *Cache
// FetcherConfig configures the upstream fetcher
// FetcherConfig configures the upstream fetcher (ignored if Fetcher is set)
FetcherConfig *FetcherConfig
// Fetcher is an optional custom fetcher (for testing)
Fetcher Fetcher
// SigningKey is the HMAC signing key (empty disables signing)
SigningKey string
// Whitelist is the list of hosts that don't require signatures
@@ -41,10 +43,17 @@ func NewService(cfg *ServiceConfig) (*Service, error) {
return nil, errors.New("cache is required")
}
// 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
if cfg.SigningKey != "" {
@@ -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
View File
@@ -0,0 +1,407 @@
package imgcache
import (
"context"
"io"
"testing"
"time"
)
func TestService_Get_WhitelistedHost(t *testing.T) {
svc, fixtures := SetupTestService(t)
ctx := context.Background()
req := &ImageRequest{
SourceHost: fixtures.GoodHost,
SourcePath: "/images/photo.jpg",
Size: Size{Width: 50, Height: 50},
Format: FormatJPEG,
Quality: 85,
FitMode: FitCover,
}
resp, err := svc.Get(ctx, req)
if err != nil {
t.Fatalf("Get() error = %v", err)
}
defer resp.Content.Close()
// Verify we got content
data, err := io.ReadAll(resp.Content)
if err != nil {
t.Fatalf("failed to read response: %v", err)
}
if len(data) == 0 {
t.Error("expected non-empty response")
}
if resp.ContentType != "image/jpeg" {
t.Errorf("ContentType = %q, want %q", resp.ContentType, "image/jpeg")
}
}
func TestService_Get_NonWhitelistedHost_NoSignature(t *testing.T) {
svc, fixtures := SetupTestService(t, WithSigningKey("test-key"))
req := &ImageRequest{
SourceHost: fixtures.OtherHost,
SourcePath: "/uploads/image.jpg",
Size: Size{Width: 50, Height: 50},
Format: FormatJPEG,
Quality: 85,
FitMode: FitCover,
}
// Should fail validation - not whitelisted and no signature
err := svc.ValidateRequest(req)
if err == nil {
t.Error("ValidateRequest() expected error for non-whitelisted host without signature")
}
}
func TestService_Get_NonWhitelistedHost_ValidSignature(t *testing.T) {
signingKey := "test-signing-key-12345"
svc, fixtures := SetupTestService(t, WithSigningKey(signingKey))
ctx := context.Background()
req := &ImageRequest{
SourceHost: fixtures.OtherHost,
SourcePath: "/uploads/image.jpg",
Size: Size{Width: 50, Height: 50},
Format: FormatJPEG,
Quality: 85,
FitMode: FitCover,
}
// Generate a valid signature
signer := NewSigner(signingKey)
req.Expires = time.Now().Add(time.Hour)
req.Signature = signer.Sign(req)
// Should pass validation
err := svc.ValidateRequest(req)
if err != nil {
t.Errorf("ValidateRequest() error = %v", err)
}
// Should fetch successfully
resp, err := svc.Get(ctx, req)
if err != nil {
t.Fatalf("Get() error = %v", err)
}
defer resp.Content.Close()
data, err := io.ReadAll(resp.Content)
if err != nil {
t.Fatalf("failed to read response: %v", err)
}
if len(data) == 0 {
t.Error("expected non-empty response")
}
}
func TestService_Get_NonWhitelistedHost_ExpiredSignature(t *testing.T) {
signingKey := "test-signing-key-12345"
svc, fixtures := SetupTestService(t, WithSigningKey(signingKey))
req := &ImageRequest{
SourceHost: fixtures.OtherHost,
SourcePath: "/uploads/image.jpg",
Size: Size{Width: 50, Height: 50},
Format: FormatJPEG,
Quality: 85,
FitMode: FitCover,
}
// Generate an expired signature
signer := NewSigner(signingKey)
req.Expires = time.Now().Add(-time.Hour) // Already expired
req.Signature = signer.Sign(req)
// Should fail validation
err := svc.ValidateRequest(req)
if err == nil {
t.Error("ValidateRequest() expected error for expired signature")
}
}
func TestService_Get_NonWhitelistedHost_InvalidSignature(t *testing.T) {
signingKey := "test-signing-key-12345"
svc, fixtures := SetupTestService(t, WithSigningKey(signingKey))
req := &ImageRequest{
SourceHost: fixtures.OtherHost,
SourcePath: "/uploads/image.jpg",
Size: Size{Width: 50, Height: 50},
Format: FormatJPEG,
Quality: 85,
FitMode: FitCover,
}
// Set an invalid signature
req.Signature = "invalid-signature"
req.Expires = time.Now().Add(time.Hour)
// Should fail validation
err := svc.ValidateRequest(req)
if err == nil {
t.Error("ValidateRequest() expected error for invalid signature")
}
}
func TestService_Get_InvalidFile(t *testing.T) {
svc, fixtures := SetupTestService(t)
ctx := context.Background()
req := &ImageRequest{
SourceHost: fixtures.GoodHost,
SourcePath: "/images/fake.jpg",
Size: Size{Width: 50, Height: 50},
Format: FormatJPEG,
Quality: 85,
FitMode: FitCover,
}
// Should fail because magic bytes don't match
_, err := svc.Get(ctx, req)
if err == nil {
t.Error("Get() expected error for invalid file")
}
}
func TestService_Get_NotFound(t *testing.T) {
svc, fixtures := SetupTestService(t)
ctx := context.Background()
req := &ImageRequest{
SourceHost: fixtures.GoodHost,
SourcePath: "/images/nonexistent.jpg",
Size: Size{Width: 50, Height: 50},
Format: FormatJPEG,
Quality: 85,
FitMode: FitCover,
}
_, err := svc.Get(ctx, req)
if err == nil {
t.Error("Get() expected error for nonexistent file")
}
}
func TestService_Get_FormatConversion(t *testing.T) {
svc, fixtures := SetupTestService(t)
ctx := context.Background()
tests := []struct {
name string
sourcePath string
outFormat ImageFormat
wantMIME string
}{
{
name: "JPEG to PNG",
sourcePath: "/images/photo.jpg",
outFormat: FormatPNG,
wantMIME: "image/png",
},
{
name: "PNG to JPEG",
sourcePath: "/images/logo.png",
outFormat: FormatJPEG,
wantMIME: "image/jpeg",
},
{
name: "GIF to PNG",
sourcePath: "/images/animation.gif",
outFormat: FormatPNG,
wantMIME: "image/png",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := &ImageRequest{
SourceHost: fixtures.GoodHost,
SourcePath: tt.sourcePath,
Size: Size{Width: 50, Height: 50},
Format: tt.outFormat,
Quality: 85,
FitMode: FitCover,
}
resp, err := svc.Get(ctx, req)
if err != nil {
t.Fatalf("Get() error = %v", err)
}
defer resp.Content.Close()
if resp.ContentType != tt.wantMIME {
t.Errorf("ContentType = %q, want %q", resp.ContentType, tt.wantMIME)
}
// Verify magic bytes match the expected format
data, err := io.ReadAll(resp.Content)
if err != nil {
t.Fatalf("failed to read response: %v", err)
}
detectedMIME, err := DetectFormat(data)
if err != nil {
t.Fatalf("failed to detect format: %v", err)
}
expectedFormat, ok := MIMEToImageFormat(tt.wantMIME)
if !ok {
t.Fatalf("unknown format for MIME type: %s", tt.wantMIME)
}
detectedFormat, ok := MIMEToImageFormat(string(detectedMIME))
if !ok {
t.Fatalf("unknown format for detected MIME type: %s", detectedMIME)
}
if detectedFormat != expectedFormat {
t.Errorf("detected format = %q, want %q", detectedFormat, expectedFormat)
}
})
}
}
func TestService_Get_Caching(t *testing.T) {
svc, fixtures := SetupTestService(t)
ctx := context.Background()
req := &ImageRequest{
SourceHost: fixtures.GoodHost,
SourcePath: "/images/photo.jpg",
Size: Size{Width: 50, Height: 50},
Format: FormatJPEG,
Quality: 85,
FitMode: FitCover,
}
// First request - should be a cache miss
resp1, err := svc.Get(ctx, req)
if err != nil {
t.Fatalf("Get() first request error = %v", err)
}
if resp1.CacheStatus != CacheMiss {
t.Errorf("first request CacheStatus = %q, want %q", resp1.CacheStatus, CacheMiss)
}
data1, err := io.ReadAll(resp1.Content)
if err != nil {
t.Fatalf("failed to read first response: %v", err)
}
resp1.Content.Close()
// Second request - should be a cache hit
resp2, err := svc.Get(ctx, req)
if err != nil {
t.Fatalf("Get() second request error = %v", err)
}
if resp2.CacheStatus != CacheHit {
t.Errorf("second request CacheStatus = %q, want %q", resp2.CacheStatus, CacheHit)
}
data2, err := io.ReadAll(resp2.Content)
if err != nil {
t.Fatalf("failed to read second response: %v", err)
}
resp2.Content.Close()
// Content should be identical
if len(data1) != len(data2) {
t.Errorf("response sizes differ: %d vs %d", len(data1), len(data2))
}
}
func TestService_Get_DifferentSizes(t *testing.T) {
svc, fixtures := SetupTestService(t)
ctx := context.Background()
// Request same image at different sizes
sizes := []Size{
{Width: 25, Height: 25},
{Width: 50, Height: 50},
{Width: 75, Height: 75},
}
var responses [][]byte
for _, size := range sizes {
req := &ImageRequest{
SourceHost: fixtures.GoodHost,
SourcePath: "/images/photo.jpg",
Size: size,
Format: FormatJPEG,
Quality: 85,
FitMode: FitCover,
}
resp, err := svc.Get(ctx, req)
if err != nil {
t.Fatalf("Get() error for size %v = %v", size, err)
}
data, err := io.ReadAll(resp.Content)
if err != nil {
t.Fatalf("failed to read response: %v", err)
}
resp.Content.Close()
responses = append(responses, data)
}
// All responses should be different sizes (different cache entries)
for i := 0; i < len(responses)-1; i++ {
if len(responses[i]) == len(responses[i+1]) {
// Not necessarily an error, but worth noting
t.Logf("responses %d and %d have same size: %d bytes", i, i+1, len(responses[i]))
}
}
}
func TestService_ValidateRequest_NoSigningKey(t *testing.T) {
// Service with no signing key - all non-whitelisted requests should fail
svc, fixtures := SetupTestService(t, WithNoWhitelist())
req := &ImageRequest{
SourceHost: fixtures.OtherHost,
SourcePath: "/uploads/image.jpg",
Size: Size{Width: 50, Height: 50},
Format: FormatJPEG,
Quality: 85,
FitMode: FitCover,
}
err := svc.ValidateRequest(req)
if err == nil {
t.Error("ValidateRequest() expected error when no signing key and host not whitelisted")
}
}
func TestService_Get_ContextCancellation(t *testing.T) {
svc, fixtures := SetupTestService(t)
ctx, cancel := context.WithCancel(context.Background())
cancel() // Cancel immediately
req := &ImageRequest{
SourceHost: fixtures.GoodHost,
SourcePath: "/images/photo.jpg",
Size: Size{Width: 50, Height: 50},
Format: FormatJPEG,
Quality: 85,
FitMode: FitCover,
}
_, err := svc.Get(ctx, req)
if err == nil {
t.Error("Get() expected error for cancelled context")
}
}
+232
View File
@@ -0,0 +1,232 @@
package imgcache
import (
"bytes"
"database/sql"
"image"
"image/color"
"image/gif"
"image/jpeg"
"image/png"
"io/fs"
"testing"
"testing/fstest"
"time"
"sneak.berlin/go/pixa/internal/database"
)
// TestFixtures contains paths to test files in the mock filesystem.
type TestFixtures struct {
// Valid image files
GoodHostJPEG string // whitelisted host, valid JPEG
GoodHostPNG string // whitelisted host, valid PNG
GoodHostGIF string // whitelisted host, valid GIF
OtherHostJPEG string // non-whitelisted host, valid JPEG
OtherHostPNG string // non-whitelisted host, valid PNG
// Invalid/edge case files
InvalidFile string // file with wrong magic bytes
EmptyFile string // zero-byte file
TextFile string // text file masquerading as image
// Hostnames
GoodHost string // whitelisted hostname
OtherHost string // non-whitelisted hostname
}
// DefaultFixtures returns the standard test fixture paths.
func DefaultFixtures() *TestFixtures {
return &TestFixtures{
GoodHostJPEG: "goodhost.example.com/images/photo.jpg",
GoodHostPNG: "goodhost.example.com/images/logo.png",
GoodHostGIF: "goodhost.example.com/images/animation.gif",
OtherHostJPEG: "otherhost.example.com/uploads/image.jpg",
OtherHostPNG: "otherhost.example.com/uploads/icon.png",
InvalidFile: "goodhost.example.com/images/fake.jpg",
EmptyFile: "goodhost.example.com/images/empty.jpg",
TextFile: "goodhost.example.com/images/text.png",
GoodHost: "goodhost.example.com",
OtherHost: "otherhost.example.com",
}
}
// NewTestFS creates a mock filesystem with test images.
func NewTestFS(t *testing.T) (fs.FS, *TestFixtures) {
t.Helper()
fixtures := DefaultFixtures()
// Generate test images
jpegData := generateTestJPEG(t, 100, 100, color.RGBA{255, 0, 0, 255})
pngData := generateTestPNG(t, 100, 100, color.RGBA{0, 255, 0, 255})
gifData := generateTestGIF(t, 100, 100, color.RGBA{0, 0, 255, 255})
// Create the mock filesystem
mockFS := fstest.MapFS{
// Good host files
fixtures.GoodHostJPEG: &fstest.MapFile{Data: jpegData},
fixtures.GoodHostPNG: &fstest.MapFile{Data: pngData},
fixtures.GoodHostGIF: &fstest.MapFile{Data: gifData},
// Other host files
fixtures.OtherHostJPEG: &fstest.MapFile{Data: jpegData},
fixtures.OtherHostPNG: &fstest.MapFile{Data: pngData},
// Invalid files
fixtures.InvalidFile: &fstest.MapFile{Data: []byte("not a real image file content")},
fixtures.EmptyFile: &fstest.MapFile{Data: []byte{}},
fixtures.TextFile: &fstest.MapFile{Data: []byte("This is just text, not a PNG")},
}
return mockFS, fixtures
}
// generateTestJPEG creates a minimal valid JPEG image.
func generateTestJPEG(t *testing.T, width, height int, c color.Color) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, width, height))
for y := 0; y < height; y++ {
for x := 0; x < width; x++ {
img.Set(x, y, c)
}
}
var buf bytes.Buffer
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 85}); err != nil {
t.Fatalf("failed to encode test JPEG: %v", err)
}
return buf.Bytes()
}
// generateTestPNG creates a minimal valid PNG image.
func generateTestPNG(t *testing.T, width, height int, c color.Color) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, width, height))
for y := 0; y < height; y++ {
for x := 0; x < width; x++ {
img.Set(x, y, c)
}
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
t.Fatalf("failed to encode test PNG: %v", err)
}
return buf.Bytes()
}
// generateTestGIF creates a minimal valid GIF image.
func generateTestGIF(t *testing.T, width, height int, c color.Color) []byte {
t.Helper()
img := image.NewPaletted(image.Rect(0, 0, width, height), []color.Color{c, color.White})
for y := 0; y < height; y++ {
for x := 0; x < width; x++ {
img.SetColorIndex(x, y, 0)
}
}
var buf bytes.Buffer
if err := gif.Encode(&buf, img, nil); err != nil {
t.Fatalf("failed to encode test GIF: %v", err)
}
return buf.Bytes()
}
// SetupTestService creates a Service with mock fetcher for testing.
func SetupTestService(t *testing.T, opts ...TestServiceOption) (*Service, *TestFixtures) {
t.Helper()
mockFS, fixtures := NewTestFS(t)
cfg := &testServiceConfig{
whitelist: []string{fixtures.GoodHost},
signingKey: "",
}
for _, opt := range opts {
opt(cfg)
}
// Create temp directory for cache
tmpDir := t.TempDir()
// Create in-memory database
db := setupServiceTestDB(t)
cache, err := NewCache(db, CacheConfig{
StateDir: tmpDir,
CacheTTL: time.Hour,
NegativeTTL: 5 * time.Minute,
HotCacheSize: 100,
HotCacheEnabled: true,
})
if err != nil {
t.Fatalf("failed to create cache: %v", err)
}
svc, err := NewService(&ServiceConfig{
Cache: cache,
Fetcher: NewMockFetcher(mockFS),
SigningKey: cfg.signingKey,
Whitelist: cfg.whitelist,
})
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
return svc, fixtures
}
// setupServiceTestDB creates an in-memory SQLite database for testing
// using the production schema.
func setupServiceTestDB(t *testing.T) *sql.DB {
t.Helper()
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatalf("failed to open test db: %v", err)
}
// Use the real production schema via migrations
if err := database.ApplyMigrations(db); err != nil {
t.Fatalf("failed to apply migrations: %v", err)
}
return db
}
type testServiceConfig struct {
whitelist []string
signingKey string
}
// TestServiceOption configures the test service.
type TestServiceOption func(*testServiceConfig)
// WithWhitelist sets the whitelist for the test service.
func WithWhitelist(hosts ...string) TestServiceOption {
return func(c *testServiceConfig) {
c.whitelist = hosts
}
}
// WithSigningKey sets the signing key for the test service.
func WithSigningKey(key string) TestServiceOption {
return func(c *testServiceConfig) {
c.signingKey = key
}
}
// WithNoWhitelist removes all whitelisted hosts.
func WithNoWhitelist() TestServiceOption {
return func(c *testServiceConfig) {
c.whitelist = nil
}
}
+84
View File
@@ -0,0 +1,84 @@
// Package seal provides authenticated encryption utilities for pixa.
// It uses NaCl secretbox (XSalsa20-Poly1305) for sealing data
// and HKDF-SHA256 for key derivation.
package seal
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"errors"
"io"
"golang.org/x/crypto/hkdf"
"golang.org/x/crypto/nacl/secretbox"
)
// Key sizes for NaCl secretbox.
const (
KeySize = 32 // NaCl secretbox key size
NonceSize = 24 // NaCl secretbox nonce size
)
// Errors returned by crypto operations.
var (
ErrDecryptionFailed = errors.New("decryption failed: invalid ciphertext or key")
ErrInvalidPayload = errors.New("invalid encrypted payload")
ErrKeyDerivation = errors.New("key derivation failed")
)
// DeriveKey uses HKDF-SHA256 to derive a key from master key material.
// The salt parameter provides domain separation between different key usages.
func DeriveKey(masterKey []byte, salt string) ([KeySize]byte, error) {
var key [KeySize]byte
hkdfReader := hkdf.New(sha256.New, masterKey, []byte(salt), nil)
if _, err := io.ReadFull(hkdfReader, key[:]); err != nil {
return key, ErrKeyDerivation
}
return key, nil
}
// Encrypt encrypts plaintext using NaCl secretbox (XSalsa20-Poly1305).
// Returns base64url-encoded ciphertext with the nonce prepended.
func Encrypt(key [KeySize]byte, plaintext []byte) (string, error) {
// Generate random nonce
var nonce [NonceSize]byte
if _, err := rand.Read(nonce[:]); err != nil {
return "", err
}
// Encrypt: nonce + secretbox.Seal(plaintext)
encrypted := secretbox.Seal(nonce[:], plaintext, &nonce, &key)
return base64.RawURLEncoding.EncodeToString(encrypted), nil
}
// Decrypt decrypts base64url-encoded ciphertext using NaCl secretbox.
// Expects the nonce to be prepended to the ciphertext.
func Decrypt(key [KeySize]byte, ciphertext string) ([]byte, error) {
// Decode base64url
data, err := base64.RawURLEncoding.DecodeString(ciphertext)
if err != nil {
return nil, ErrInvalidPayload
}
// Check minimum length (nonce + at least some ciphertext + auth tag)
if len(data) < NonceSize+secretbox.Overhead {
return nil, ErrInvalidPayload
}
// Extract nonce
var nonce [NonceSize]byte
copy(nonce[:], data[:NonceSize])
// Decrypt
plaintext, ok := secretbox.Open(nil, data[NonceSize:], &nonce, &key)
if !ok {
return nil, ErrDecryptionFailed
}
return plaintext, nil
}
+184
View File
@@ -0,0 +1,184 @@
package seal
import (
"bytes"
"testing"
)
func TestDeriveKey_Consistent(t *testing.T) {
masterKey := []byte("test-master-key-12345")
salt := "test-salt-v1"
key1, err := DeriveKey(masterKey, salt)
if err != nil {
t.Fatalf("DeriveKey() error = %v", err)
}
key2, err := DeriveKey(masterKey, salt)
if err != nil {
t.Fatalf("DeriveKey() error = %v", err)
}
if key1 != key2 {
t.Error("DeriveKey() should produce consistent keys for same input")
}
}
func TestDeriveKey_DifferentSalts(t *testing.T) {
masterKey := []byte("test-master-key-12345")
key1, err := DeriveKey(masterKey, "salt-1")
if err != nil {
t.Fatalf("DeriveKey() error = %v", err)
}
key2, err := DeriveKey(masterKey, "salt-2")
if err != nil {
t.Fatalf("DeriveKey() error = %v", err)
}
if key1 == key2 {
t.Error("DeriveKey() should produce different keys for different salts")
}
}
func TestDeriveKey_DifferentMasterKeys(t *testing.T) {
salt := "test-salt"
key1, err := DeriveKey([]byte("master-key-1"), salt)
if err != nil {
t.Fatalf("DeriveKey() error = %v", err)
}
key2, err := DeriveKey([]byte("master-key-2"), salt)
if err != nil {
t.Fatalf("DeriveKey() error = %v", err)
}
if key1 == key2 {
t.Error("DeriveKey() should produce different keys for different master keys")
}
}
func TestEncryptDecrypt_RoundTrip(t *testing.T) {
key, err := DeriveKey([]byte("test-key"), "test-salt")
if err != nil {
t.Fatalf("DeriveKey() error = %v", err)
}
plaintext := []byte("hello, world! this is a test message.")
ciphertext, err := Encrypt(key, plaintext)
if err != nil {
t.Fatalf("Encrypt() error = %v", err)
}
decrypted, err := Decrypt(key, ciphertext)
if err != nil {
t.Fatalf("Decrypt() error = %v", err)
}
if !bytes.Equal(plaintext, decrypted) {
t.Errorf("Decrypt() = %q, want %q", decrypted, plaintext)
}
}
func TestEncryptDecrypt_EmptyPlaintext(t *testing.T) {
key, _ := DeriveKey([]byte("test-key"), "test-salt")
plaintext := []byte{}
ciphertext, err := Encrypt(key, plaintext)
if err != nil {
t.Fatalf("Encrypt() error = %v", err)
}
decrypted, err := Decrypt(key, ciphertext)
if err != nil {
t.Fatalf("Decrypt() error = %v", err)
}
if !bytes.Equal(plaintext, decrypted) {
t.Errorf("Decrypt() = %q, want %q", decrypted, plaintext)
}
}
func TestDecrypt_WrongKey(t *testing.T) {
key1, _ := DeriveKey([]byte("key-1"), "salt")
key2, _ := DeriveKey([]byte("key-2"), "salt")
plaintext := []byte("secret message")
ciphertext, err := Encrypt(key1, plaintext)
if err != nil {
t.Fatalf("Encrypt() error = %v", err)
}
_, err = Decrypt(key2, ciphertext)
if err == nil {
t.Error("Decrypt() should fail with wrong key")
}
if err != ErrDecryptionFailed {
t.Errorf("Decrypt() error = %v, want %v", err, ErrDecryptionFailed)
}
}
func TestDecrypt_TamperedCiphertext(t *testing.T) {
key, _ := DeriveKey([]byte("test-key"), "test-salt")
plaintext := []byte("secret message")
ciphertext, err := Encrypt(key, plaintext)
if err != nil {
t.Fatalf("Encrypt() error = %v", err)
}
// Tamper with the ciphertext (flip a bit in the middle)
tampered := []byte(ciphertext)
if len(tampered) > 10 {
tampered[10] ^= 0x01
}
_, err = Decrypt(key, string(tampered))
if err == nil {
t.Error("Decrypt() should fail with tampered ciphertext")
}
}
func TestDecrypt_InvalidBase64(t *testing.T) {
key, _ := DeriveKey([]byte("test-key"), "test-salt")
_, err := Decrypt(key, "not-valid-base64!!!")
if err == nil {
t.Error("Decrypt() should fail with invalid base64")
}
if err != ErrInvalidPayload {
t.Errorf("Decrypt() error = %v, want %v", err, ErrInvalidPayload)
}
}
func TestDecrypt_TooShort(t *testing.T) {
key, _ := DeriveKey([]byte("test-key"), "test-salt")
// Create a base64 string that's too short to contain nonce + auth tag
_, err := Decrypt(key, "dG9vLXNob3J0")
if err == nil {
t.Error("Decrypt() should fail with too-short ciphertext")
}
if err != ErrInvalidPayload {
t.Errorf("Decrypt() error = %v, want %v", err, ErrInvalidPayload)
}
}
func TestEncrypt_ProducesDifferentCiphertexts(t *testing.T) {
key, _ := DeriveKey([]byte("test-key"), "test-salt")
plaintext := []byte("same message")
ciphertext1, _ := Encrypt(key, plaintext)
ciphertext2, _ := Encrypt(key, plaintext)
if ciphertext1 == ciphertext2 {
t.Error("Encrypt() should produce different ciphertexts due to random nonce")
}
}
+14
View File
@@ -7,6 +7,8 @@ import (
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/prometheus/client_golang/prometheus/promhttp"
"sneak.berlin/go/pixa/internal/static"
)
// SetupRoutes configures all HTTP routes.
@@ -38,10 +40,22 @@ func (s *Server) SetupRoutes() {
// Robots.txt
s.router.Get("/robots.txt", s.h.HandleRobotsTxt())
// Static files (Tailwind CSS, etc.)
s.router.Handle("/static/*", http.StripPrefix("/static/", static.Handler()))
// Login/generator UI
s.router.Get("/", s.h.HandleRoot())
s.router.Post("/", s.h.HandleRoot())
s.router.Get("/logout", s.h.HandleLogout())
s.router.Post("/generate", s.h.HandleGenerateURL())
// Main image proxy route
// /v1/image/<host>/<path>/<width>x<height>.<format>
s.router.Get("/v1/image/*", s.h.HandleImage())
// Encrypted image URL route
s.router.Get("/v1/e/{token}", s.h.HandleImageEnc())
// Metrics endpoint with auth
if s.config.MetricsUsername != "" {
s.router.Group(func(r chi.Router) {
+145
View File
@@ -0,0 +1,145 @@
// Package session provides encrypted session cookie management.
package session
import (
"errors"
"net/http"
"time"
"github.com/gorilla/securecookie"
"sneak.berlin/go/pixa/internal/seal"
)
// Session configuration constants.
const (
CookieName = "pixa_session"
SessionTTL = 30 * 24 * time.Hour // 30 days
// HKDF salts for key derivation
hashKeySalt = "pixa-session-hash-v1"
blockKeySalt = "pixa-session-block-v1"
)
// Errors returned by session operations.
var (
ErrInvalidSession = errors.New("invalid or expired session")
ErrNoSession = errors.New("no session cookie present")
)
// Data contains the session payload stored in the encrypted cookie.
type Data struct {
Authenticated bool `json:"auth"`
CreatedAt time.Time `json:"created"`
ExpiresAt time.Time `json:"expires"`
}
// Manager handles session creation and validation using encrypted cookies.
type Manager struct {
sc *securecookie.SecureCookie
secure bool // Set Secure flag on cookies (should be true in production)
sameSite http.SameSite
}
// NewManager creates a session manager with keys derived from the signing key.
// Set secure=true in production to require HTTPS for cookies.
func NewManager(signingKey string, secure bool) (*Manager, error) {
masterKey := []byte(signingKey)
// Derive separate keys for HMAC (hash) and encryption (block)
hashKey, err := seal.DeriveKey(masterKey, hashKeySalt)
if err != nil {
return nil, err
}
blockKey, err := seal.DeriveKey(masterKey, blockKeySalt)
if err != nil {
return nil, err
}
sc := securecookie.New(hashKey[:], blockKey[:])
sc.MaxAge(int(SessionTTL.Seconds()))
return &Manager{
sc: sc,
secure: secure,
sameSite: http.SameSiteStrictMode,
}, nil
}
// CreateSession creates a new authenticated session and sets the cookie.
func (m *Manager) CreateSession(w http.ResponseWriter) error {
now := time.Now()
data := &Data{
Authenticated: true,
CreatedAt: now,
ExpiresAt: now.Add(SessionTTL),
}
encoded, err := m.sc.Encode(CookieName, data)
if err != nil {
return err
}
http.SetCookie(w, &http.Cookie{
Name: CookieName,
Value: encoded,
Path: "/",
MaxAge: int(SessionTTL.Seconds()),
HttpOnly: true,
Secure: m.secure,
SameSite: m.sameSite,
})
return nil
}
// ValidateSession checks if the request has a valid session cookie.
// Returns the session data if valid, or an error if invalid/missing.
func (m *Manager) ValidateSession(r *http.Request) (*Data, error) {
cookie, err := r.Cookie(CookieName)
if err != nil {
if errors.Is(err, http.ErrNoCookie) {
return nil, ErrNoSession
}
return nil, err
}
var data Data
if err := m.sc.Decode(CookieName, cookie.Value, &data); err != nil {
return nil, ErrInvalidSession
}
// Check if session has expired (defense in depth - cookie MaxAge should handle this)
if time.Now().After(data.ExpiresAt) {
return nil, ErrInvalidSession
}
if !data.Authenticated {
return nil, ErrInvalidSession
}
return &data, nil
}
// ClearSession removes the session cookie.
func (m *Manager) ClearSession(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: CookieName,
Value: "",
Path: "/",
MaxAge: -1, // Delete immediately
HttpOnly: true,
Secure: m.secure,
SameSite: m.sameSite,
})
}
// IsAuthenticated is a convenience method that returns true if the request
// has a valid authenticated session.
func (m *Manager) IsAuthenticated(r *http.Request) bool {
data, err := m.ValidateSession(r)
return err == nil && data != nil && data.Authenticated
}
+204
View File
@@ -0,0 +1,204 @@
package session
import (
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestManager_CreateAndValidate(t *testing.T) {
mgr, err := NewManager("test-signing-key-12345", false)
if err != nil {
t.Fatalf("NewManager() error = %v", err)
}
// Create a session
w := httptest.NewRecorder()
if err := mgr.CreateSession(w); err != nil {
t.Fatalf("CreateSession() error = %v", err)
}
// Extract the cookie from response
resp := w.Result()
cookies := resp.Cookies()
if len(cookies) == 0 {
t.Fatal("CreateSession() did not set a cookie")
}
var sessionCookie *http.Cookie
for _, c := range cookies {
if c.Name == CookieName {
sessionCookie = c
break
}
}
if sessionCookie == nil {
t.Fatalf("CreateSession() did not set cookie named %q", CookieName)
}
// Validate the session
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(sessionCookie)
data, err := mgr.ValidateSession(req)
if err != nil {
t.Fatalf("ValidateSession() error = %v", err)
}
if !data.Authenticated {
t.Error("ValidateSession() returned unauthenticated session")
}
if data.ExpiresAt.Before(time.Now()) {
t.Error("ValidateSession() returned already-expired session")
}
}
func TestManager_ValidateSession_NoCookie(t *testing.T) {
mgr, _ := NewManager("test-signing-key-12345", false)
req := httptest.NewRequest(http.MethodGet, "/", nil)
_, err := mgr.ValidateSession(req)
if err == nil {
t.Error("ValidateSession() should fail with no cookie")
}
if err != ErrNoSession {
t.Errorf("ValidateSession() error = %v, want %v", err, ErrNoSession)
}
}
func TestManager_ValidateSession_TamperedCookie(t *testing.T) {
mgr, _ := NewManager("test-signing-key-12345", false)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(&http.Cookie{
Name: CookieName,
Value: "tampered-invalid-cookie-value",
})
_, err := mgr.ValidateSession(req)
if err == nil {
t.Error("ValidateSession() should fail with tampered cookie")
}
if err != ErrInvalidSession {
t.Errorf("ValidateSession() error = %v, want %v", err, ErrInvalidSession)
}
}
func TestManager_ValidateSession_WrongKey(t *testing.T) {
mgr1, _ := NewManager("signing-key-1", false)
mgr2, _ := NewManager("signing-key-2", false)
// Create session with mgr1
w := httptest.NewRecorder()
_ = mgr1.CreateSession(w)
resp := w.Result()
var sessionCookie *http.Cookie
for _, c := range resp.Cookies() {
if c.Name == CookieName {
sessionCookie = c
break
}
}
// Try to validate with mgr2 (different key)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(sessionCookie)
_, err := mgr2.ValidateSession(req)
if err == nil {
t.Error("ValidateSession() should fail with different signing key")
}
}
func TestManager_ClearSession(t *testing.T) {
mgr, _ := NewManager("test-signing-key-12345", false)
w := httptest.NewRecorder()
mgr.ClearSession(w)
resp := w.Result()
cookies := resp.Cookies()
var sessionCookie *http.Cookie
for _, c := range cookies {
if c.Name == CookieName {
sessionCookie = c
break
}
}
if sessionCookie == nil {
t.Fatal("ClearSession() did not set a cookie")
}
if sessionCookie.MaxAge != -1 {
t.Errorf("ClearSession() cookie MaxAge = %d, want -1", sessionCookie.MaxAge)
}
}
func TestManager_IsAuthenticated(t *testing.T) {
mgr, _ := NewManager("test-signing-key-12345", false)
// No session - should return false
req := httptest.NewRequest(http.MethodGet, "/", nil)
if mgr.IsAuthenticated(req) {
t.Error("IsAuthenticated() should return false with no session")
}
// Create session
w := httptest.NewRecorder()
_ = mgr.CreateSession(w)
resp := w.Result()
var sessionCookie *http.Cookie
for _, c := range resp.Cookies() {
if c.Name == CookieName {
sessionCookie = c
break
}
}
// With valid session - should return true
req = httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(sessionCookie)
if !mgr.IsAuthenticated(req) {
t.Error("IsAuthenticated() should return true with valid session")
}
}
func TestManager_CookieAttributes(t *testing.T) {
// Test with secure=true
mgr, _ := NewManager("test-key", true)
w := httptest.NewRecorder()
_ = mgr.CreateSession(w)
resp := w.Result()
var sessionCookie *http.Cookie
for _, c := range resp.Cookies() {
if c.Name == CookieName {
sessionCookie = c
break
}
}
if !sessionCookie.HttpOnly {
t.Error("Cookie should have HttpOnly flag")
}
if !sessionCookie.Secure {
t.Error("Cookie should have Secure flag when manager created with secure=true")
}
if sessionCookie.SameSite != http.SameSiteStrictMode {
t.Errorf("Cookie SameSite = %v, want %v", sessionCookie.SameSite, http.SameSiteStrictMode)
}
}
+21
View File
@@ -0,0 +1,21 @@
// Package static provides embedded static files for the web UI.
package static
import (
"embed"
"io/fs"
"net/http"
)
//go:embed *.js
var files embed.FS
// FS returns the embedded filesystem containing static files.
func FS() fs.FS {
return files
}
// Handler returns an http.Handler that serves static files.
func Handler() http.Handler {
return http.FileServer(http.FS(files))
}
+83
View File
@@ -0,0 +1,83 @@
(()=>{var Kv=Object.create;var Qi=Object.defineProperty;var Xv=Object.getOwnPropertyDescriptor;var Zv=Object.getOwnPropertyNames;var Jv=Object.getPrototypeOf,ex=Object.prototype.hasOwnProperty;var Sf=r=>Qi(r,"__esModule",{value:!0});var Af=r=>{if(typeof require!="undefined")return require(r);throw new Error('Dynamic require of "'+r+'" is not supported')};var P=(r,e)=>()=>(r&&(e=r(r=0)),e);var x=(r,e)=>()=>(e||r((e={exports:{}}).exports,e),e.exports),Ge=(r,e)=>{Sf(r);for(var t in e)Qi(r,t,{get:e[t],enumerable:!0})},tx=(r,e,t)=>{if(e&&typeof e=="object"||typeof e=="function")for(let i of Zv(e))!ex.call(r,i)&&i!=="default"&&Qi(r,i,{get:()=>e[i],enumerable:!(t=Xv(e,i))||t.enumerable});return r},pe=r=>tx(Sf(Qi(r!=null?Kv(Jv(r)):{},"default",r&&r.__esModule&&"default"in r?{get:()=>r.default,enumerable:!0}:{value:r,enumerable:!0})),r);var m,u=P(()=>{m={platform:"",env:{},versions:{node:"14.17.6"}}});var rx,be,ft=P(()=>{u();rx=0,be={readFileSync:r=>self[r]||"",statSync:()=>({mtimeMs:rx++}),promises:{readFile:r=>Promise.resolve(self[r]||"")}}});var Gs=x((PP,_f)=>{u();"use strict";var Cf=class{constructor(e={}){if(!(e.maxSize&&e.maxSize>0))throw new TypeError("`maxSize` must be a number greater than 0");if(typeof e.maxAge=="number"&&e.maxAge===0)throw new TypeError("`maxAge` must be a number greater than 0");this.maxSize=e.maxSize,this.maxAge=e.maxAge||1/0,this.onEviction=e.onEviction,this.cache=new Map,this.oldCache=new Map,this._size=0}_emitEvictions(e){if(typeof this.onEviction=="function")for(let[t,i]of e)this.onEviction(t,i.value)}_deleteIfExpired(e,t){return typeof t.expiry=="number"&&t.expiry<=Date.now()?(typeof this.onEviction=="function"&&this.onEviction(e,t.value),this.delete(e)):!1}_getOrDeleteIfExpired(e,t){if(this._deleteIfExpired(e,t)===!1)return t.value}_getItemValue(e,t){return t.expiry?this._getOrDeleteIfExpired(e,t):t.value}_peek(e,t){let i=t.get(e);return this._getItemValue(e,i)}_set(e,t){this.cache.set(e,t),this._size++,this._size>=this.maxSize&&(this._size=0,this._emitEvictions(this.oldCache),this.oldCache=this.cache,this.cache=new Map)}_moveToRecent(e,t){this.oldCache.delete(e),this._set(e,t)}*_entriesAscending(){for(let e of this.oldCache){let[t,i]=e;this.cache.has(t)||this._deleteIfExpired(t,i)===!1&&(yield e)}for(let e of this.cache){let[t,i]=e;this._deleteIfExpired(t,i)===!1&&(yield e)}}get(e){if(this.cache.has(e)){let t=this.cache.get(e);return this._getItemValue(e,t)}if(this.oldCache.has(e)){let t=this.oldCache.get(e);if(this._deleteIfExpired(e,t)===!1)return this._moveToRecent(e,t),t.value}}set(e,t,{maxAge:i=this.maxAge===1/0?void 0:Date.now()+this.maxAge}={}){this.cache.has(e)?this.cache.set(e,{value:t,maxAge:i}):this._set(e,{value:t,expiry:i})}has(e){return this.cache.has(e)?!this._deleteIfExpired(e,this.cache.get(e)):this.oldCache.has(e)?!this._deleteIfExpired(e,this.oldCache.get(e)):!1}peek(e){if(this.cache.has(e))return this._peek(e,this.cache);if(this.oldCache.has(e))return this._peek(e,this.oldCache)}delete(e){let t=this.cache.delete(e);return t&&this._size--,this.oldCache.delete(e)||t}clear(){this.cache.clear(),this.oldCache.clear(),this._size=0}resize(e){if(!(e&&e>0))throw new TypeError("`maxSize` must be a number greater than 0");let t=[...this._entriesAscending()],i=t.length-e;i<0?(this.cache=new Map(t),this.oldCache=new Map,this._size=t.length):(i>0&&this._emitEvictions(t.slice(0,i)),this.oldCache=new Map(t.slice(i)),this.cache=new Map,this._size=0),this.maxSize=e}*keys(){for(let[e]of this)yield e}*values(){for(let[,e]of this)yield e}*[Symbol.iterator](){for(let e of this.cache){let[t,i]=e;this._deleteIfExpired(t,i)===!1&&(yield[t,i.value])}for(let e of this.oldCache){let[t,i]=e;this.cache.has(t)||this._deleteIfExpired(t,i)===!1&&(yield[t,i.value])}}*entriesDescending(){let e=[...this.cache];for(let t=e.length-1;t>=0;--t){let i=e[t],[n,s]=i;this._deleteIfExpired(n,s)===!1&&(yield[n,s.value])}e=[...this.oldCache];for(let t=e.length-1;t>=0;--t){let i=e[t],[n,s]=i;this.cache.has(n)||this._deleteIfExpired(n,s)===!1&&(yield[n,s.value])}}*entriesAscending(){for(let[e,t]of this._entriesAscending())yield[e,t.value]}get size(){if(!this._size)return this.oldCache.size;let e=0;for(let t of this.oldCache.keys())this.cache.has(t)||e++;return Math.min(this._size+e,this.maxSize)}};_f.exports=Cf});var Ef,Of=P(()=>{u();Ef=r=>r&&r._hash});function Yi(r){return Ef(r,{ignoreUnknown:!0})}var Tf=P(()=>{u();Of()});function kt(r){if(r=`${r}`,r==="0")return"0";if(/^[+-]?(\d+|\d*\.\d+)(e[+-]?\d+)?(%|\w+)?$/.test(r))return r.replace(/^[+-]?/,t=>t==="-"?"":"-");let e=["var","calc","min","max","clamp"];for(let t of e)if(r.includes(`${t}(`))return`calc(${r} * -1)`}var Ki=P(()=>{u()});var Rf,Pf=P(()=>{u();Rf=["preflight","container","accessibility","pointerEvents","visibility","position","inset","isolation","zIndex","order","gridColumn","gridColumnStart","gridColumnEnd","gridRow","gridRowStart","gridRowEnd","float","clear","margin","boxSizing","lineClamp","display","aspectRatio","size","height","maxHeight"Line truncated
`+S+n("^")}let b=i(h.replace(/\d/g," "))+f.slice(0,this.column-1).replace(/[^\t]/g," ");return n(">")+i(h)+s(f)+`
`+b+n("^")}return" "+i(h)+s(f)}).join(`
`)}toString(){let e=this.showSourceCode();return e&&(e=`
`+e+`
`),this.name+": "+this.message+e}};vc.exports=rr;rr.default=rr});var ya=x((H3,kc)=>{u();"use strict";var xc={after:`
`,beforeClose:`
`,beforeComment:`
`,beforeDecl:`
`,beforeOpen:" ",beforeRule:`
`,colon:": ",commentLeft:" ",commentRight:" ",emptyBody:"",indent:" ",semicolon:!1};function Lx(r){return r[0].toUpperCase()+r.slice(1)}var cn=class{constructor(e){this.builder=e}atrule(e,t){let i="@"+e.name,n=e.params?this.rawValue(e,"params"):"";if(typeof e.raws.afterName!="undefined"?i+=e.raws.afterName:n&&(i+=" "),e.nodes)this.block(e,i+n);else{let s=(e.raws.between||"")+(t?";":"");this.builder(i+n+s,e)}}beforeAfter(e,t){let i;e.type==="decl"?i=this.raw(e,null,"beforeDecl"):e.type==="comment"?i=this.raw(e,null,"beforeComment"):t==="before"?i=this.raw(e,null,"beforeRule"):i=this.raw(e,null,"beforeClose");let n=e.parent,s=0;for(;n&&n.type!=="root";)s+=1,n=n.parent;if(i.includes(`
`)){let a=this.raw(e,null,"indent");if(a.length)for(let o=0;o<s;o++)i+=a}return i}block(e,t){let i=this.raw(e,"between","beforeOpen");this.builder(t+i+"{",e,"start");let n;e.nodes&&e.nodes.length?(this.body(e),n=this.raw(e,"after")):n=this.raw(e,"after","emptyBody"),n&&this.builder(n),this.builder("}",e,"end")}body(e){let t=e.nodes.length-1;for(;t>0&&e.nodes[t].type==="comment";)t-=1;let i=this.raw(e,"semicolon");for(let n=0;n<e.nodes.length;n++){let s=e.nodes[n],a=this.raw(s,"before");a&&this.builder(a),this.stringify(s,t!==n||i)}}comment(e){let t=this.raw(e,"left","commentLeft"),i=this.raw(e,"right","commentRight");this.builder("/*"+t+e.text+i+"*/",e)}decl(e,t){let i=this.raw(e,"between","colon"),n=e.prop+i+this.rawValue(e,"value");e.important&&(n+=e.raws.important||" !important"),t&&(n+=";"),this.builder(n,e)}document(e){this.body(e)}raw(e,t,i){let n;if(i||(i=t),t&&(n=e.raws[t],typeof n!="undefined"))return n;let s=e.parent;if(i==="before"&&(!s||s.type==="root"&&s.first===e||s&&s.type==="document"))return"";if(!s)return xc[i];let a=e.root();if(a.rawCache||(a.rawCache={}),typeof a.rawCache[i]!="undefined")return a.rawCache[i];if(i==="before"||i==="after")return this.beforeAfter(e,i);{let o="raw"+Lx(i);this[o]?n=this[o](a,e):a.walk(l=>{if(n=l.raws[t],typeof n!="undefined")return!1})}return typeof n=="undefined"&&(n=xc[i]),a.rawCache[i]=n,n}rawBeforeClose(e){let t;return e.walk(i=>{if(i.nodes&&i.nodes.length>0&&typeof i.raws.after!="undefined")return t=i.raws.after,t.includes(`
`)&&(t=t.replace(/[^\n]+$/,"")),!1}),t&&(t=t.replace(/\S/g,"")),t}rawBeforeComment(e,t){let i;return e.walkComments(n=>{if(typeof n.raws.before!="undefined")return i=n.raws.before,i.includes(`
`)&&(i=i.replace(/[^\n]+$/,"")),!1}),typeof i=="undefined"?i=this.raw(t,null,"beforeDecl"):i&&(i=i.replace(/\S/g,"")),i}rawBeforeDecl(e,t){let i;return e.walkDecls(n=>{if(typeof n.raws.before!="undefined")return i=n.raws.before,i.includes(`
`)&&(i=i.replace(/[^\n]+$/,"")),!1}),typeof i=="undefined"?i=this.raw(t,null,"beforeRule"):i&&(i=i.replace(/\S/g,"")),i}rawBeforeOpen(e){let t;return e.walk(i=>{if(i.type!=="decl"&&(t=i.raws.between,typeof t!="undefined"))return!1}),t}rawBeforeRule(e){let t;return e.walk(i=>{if(i.nodes&&(i.parent!==e||e.first!==i)&&typeof i.raws.before!="undefined")return t=i.raws.before,t.includes(`
`)&&(t=t.replace(/[^\n]+$/,"")),!1}),t&&(t=t.replace(/\S/g,"")),t}rawColon(e){let t;return e.walkDecls(i=>{if(typeof i.raws.between!="undefined")return t=i.raws.between.replace(/[^\s:]/g,""),!1}),t}rawEmptyBody(e){let t;return e.walk(i=>{if(i.nodes&&i.nodes.length===0&&(t=i.raws.after,typeof t!="undefined"))return!1}),t}rawIndent(e){if(e.raws.indent)return e.raws.indent;let t;return e.walk(i=>{let n=i.parent;if(n&&n!==e&&n.parent&&n.parent===e&&typeof i.raws.before!="undefined"){let s=i.raws.before.split(`
`);return t=s[s.length-1],t=t.replace(/\S/g,""),!1}}),t}rawSemicolon(e){let t;return e.walk(i=>{if(i.nodes&&i.nodes.length&&i.last.type==="decl"&&(t=i.raws.semicolon,typeof t!="undefined"))return!1}),t}rawValue(e,t){let i=e[t],n=e.raws[t];return n&&n.value===i?n.raw:i}root(e){this.body(e),e.raws.after&&this.builder(e.raws.after)}rule(e){this.block(e,this.rawValue(e,"selector")),e.raws.ownSemicolon&&this.builder(e.raws.ownSemicolon,e,"end")}stringify(e,t){if(!this[e.type])throw new Error("Unknown AST node type "+e.type+". Maybe you need to change PostCSS stringifier.");this[e.type](e,t)}};kc.exports=cn;cn.default=cn});var Gr=x((W3,Sc)=>{u();"use strict";var Mx=ya();function ba(r,e){new Mx(e).stringify(r)}Sc.exports=ba;ba.default=ba});var pn=x((G3,wa)=>{u();"use strict";wa.exports.isClean=Symbol("isClean");wa.exports.my=Symbol("my")});var Kr=x((Q3,Ac)=>{u();"use strict";var Nx=fn(),Bx=ya(),Fx=Gr(),{isClean:Qr,my:jx}=pn();function va(r,e){let t=new r.constructor;for(let i in r){if(!Object.prototype.hasOwnProperty.call(r,i)||i==="proxyCache")continue;let n=r[i],s=typeof n;i==="parent"&&s==="object"?e&&(t[i]=e):i==="source"?t[i]=n:Array.isArray(n)?t[i]=n.map(a=>va(a,t)):(s==="object"&&n!==null&&(n=va(n)),t[i]=n)}return t}function Yr(r,e){if(e&&typeof e.offset!="undefined")return e.offset;let t=1,i=1,n=0;for(let s=0;s<r.length;s++){if(i===e.line&&t===e.column){n=s;break}r[s]===`
`?(t=1,i+=1):t+=1}return n}var dn=class{constructor(e={}){this.raws={},this[Qr]=!1,this[jx]=!0;for(let t in e)if(t==="nodes"){this.nodes=[];for(let i of e[t])typeof i.clone=="function"?this.append(i.clone()):this.append(i)}else this[t]=e[t]}addToError(e){if(e.postcssNode=this,e.stack&&this.source&&/\n\s{4}at /.test(e.stack)){let t=this.source;e.stack=e.stack.replace(/\n\s{4}at /,`$&${t.input.from}:${t.start.line}:${t.start.column}$&`)}return e}after(e){return this.parent.insertAfter(this,e),this}assign(e={}){for(let t in e)this[t]=e[t];return this}before(e){return this.parent.insertBefore(this,e),this}cleanRaws(e){delete this.raws.before,delete this.raws.after,e||delete this.raws.between}clone(e={}){let t=va(this);for(let i in e)t[i]=e[i];return t}cloneAfter(e={}){let t=this.clone(e);return this.parent.insertAfter(this,t),t}cloneBefore(e={}){let t=this.clone(e);return this.parent.insertBefore(this,t),t}error(e,t={}){if(this.source){let{end:i,start:n}=this.rangeBy(t);return this.source.input.error(e,{column:n.column,line:n.line},{column:i.column,line:i.line},t)}return new Nx(e)}getProxyProcessor(){return{get(e,t){return t==="proxyOf"?e:t==="root"?()=>e.root().toProxy():e[t]},set(e,t,i){return e[t]===i||(e[t]=i,(t==="prop"||t==="value"||t==="name"||t==="params"||t==="important"||t==="text")&&e.markDirty()),!0}}}markClean(){this[Qr]=!0}markDirty(){if(this[Qr]){this[Qr]=!1;let e=this;for(;e=e.parent;)e[Qr]=!1}}next(){if(!this.parent)return;let e=this.parent.index(this);return this.parent.nodes[e+1]}positionBy(e){let t=this.source.start;if(e.index)t=this.positionInside(e.index);else if(e.word){let n=this.source.input.css.slice(Yr(this.source.input.css,this.source.start),Yr(this.source.input.css,this.source.end)).indexOf(e.word);n!==-1&&(t=this.positionInside(n))}return t}positionInside(e){let t=this.source.start.column,i=this.source.start.line,n=Yr(this.source.input.css,this.source.start),s=n+e;for(let a=n;a<s;a++)this.source.input.css[a]===`
`?(t=1,i+=1):t+=1;return{column:t,line:i}}prev(){if(!this.parent)return;let e=this.parent.index(this);return this.parent.nodes[e-1]}rangeBy(e){let t={column:this.source.start.column,line:this.source.start.line},i=this.source.end?{column:this.source.end.column+1,line:this.source.end.line}:{column:t.column+1,line:t.line};if(e.word){let s=this.source.input.css.slice(Yr(this.source.input.css,this.source.start),Yr(this.source.input.css,this.source.end)).indexOf(e.word);s!==-1&&(t=this.positionInside(s),i=this.positionInside(s+e.word.length))}else e.start?t={column:e.start.column,line:e.start.line}:e.index&&(t=this.positionInside(e.index)),e.end?i={column:e.end.column,line:e.end.line}:typeof e.endIndex=="number"?i=this.positionInside(e.endIndex):e.index&&(i=this.positionInside(e.index+1));return(i.line<t.line||i.line===t.line&&i.column<=t.column)&&(i={column:t.column+1,line:t.line}),{end:i,start:t}}raw(e,t){return new Bx().raw(this,e,t)}remove(){return this.parent&&this.parent.removeChild(this),this.parent=void 0,this}replaceWith(...e){if(this.parent){let t=this,i=!1;for(let n of e)n===this?i=!0:i?(this.parent.insertAfter(t,n),t=n):this.parent.insertBefore(t,n);i||this.remove()}return this}root(){let e=this;for(;e.parent&&e.parent.type!=="document";)e=e.parent;return e}toJSON(e,t){let i={},n=t==null;t=t||new Map;let s=0;for(let a in this){if(!Object.prototype.hasOwnProperty.call(this,a)||a==="parent"||a==="proxyCache")continue;let o=this[a];if(Array.isArray(o))i[a]=o.map(l=>typeof l=="object"&&l.toJSON?l.toJSON(null,t):l);else if(typeof o=="object"&&o.toJSON)i[a]=o.toJSON(null,t);else if(a==="source"){let l=t.get(o.input);l==null&&(l=s,t.set(o.input,s),s++),i[a]={end:o.end,inputId:l,start:o.start}}else i[a]=o}return n&&(i.inputs=[...t.keys()].map(a=>a.toJSON())),i}toProxy(){return this.proxyCache||(this.proxyCache=new Proxy(this,this.getProxyProcessor())),this.proxyCache}toString(e=Fx){e.stringify&&(e=e.stringify);let t="";return e(this,i=>{t+=i}),t}warn(e,t,i){let n={node:this};for(let s in i)n[s]=i[s];return e.warn(t,n)}get proxyOf(){return this}};Ac.exports=dn;dn.default=dn});var Xr=x((Y3,Cc)=>{u();"use strict";var zx=Kr(),hn=class extends zx{constructor(e){super(e);this.type="comment"}};Cc.exports=hn;hn.default=hn});var Zr=x((K3,_c)=>{u();"use strict";var Ux=Kr(),mn=class extends Ux{constructor(e){e&&typeof e.value!="undefined"&&typeof e.value!="string"&&(e={...e,value:String(e.value)});super(e);this.type="decl"}get variable(){return this.prop.startsWith("--")||this.prop[0]==="$"}};_c.exports=mn;mn.default=mn});var Ot=x((X3,$c)=>{u();"use strict";var Ec=Xr(),Oc=Zr(),Vx=Kr(),{isClean:Tc,my:Rc}=pn(),xa,Pc,Ic,ka;function Dc(r){return r.map(e=>(e.nodes&&(e.nodes=Dc(e.nodes)),delete e.source,e))}function qc(r){if(r[Tc]=!1,r.proxyOf.nodes)for(let e of r.proxyOf.nodes)qc(e)}var Fe=class extends Vx{append(...e){for(let t of e){let i=this.normalize(t,this.last);for(let n of i)this.proxyOf.nodes.push(n)}return this.markDirty(),this}cleanRaws(e){if(super.cleanRaws(e),this.nodes)for(let t of this.nodes)t.cleanRaws(e)}each(e){if(!this.proxyOf.nodes)return;let t=this.getIterator(),i,n;for(;this.indexes[t]<this.proxyOf.nodes.length&&(i=this.indexes[t],n=e(this.proxyOf.nodes[i],i),n!==!1);)this.indexes[t]+=1;return delete this.indexes[t],n}every(e){return this.nodes.every(e)}getIterator(){this.lastEach||(this.lastEach=0),this.indexes||(this.indexes={}),this.lastEach+=1;let e=this.lastEach;return this.indexes[e]=0,e}getProxyProcessor(){return{get(e,t){return t==="proxyOf"?e:e[t]?t==="each"||typeof t=="string"&&t.startsWith("walk")?(...i)=>e[t](...i.map(n=>typeof n=="function"?(s,a)=>n(s.toProxy(),a):n)):t==="every"||t==="some"?i=>e[t]((n,...s)=>i(n.toProxy(),...s)):t==="root"?()=>e.root().toProxy():t==="nodes"?e.nodes.map(i=>i.toProxy()):t==="first"||t==="last"?e[t].toProxy():e[t]:e[t]},set(e,t,i){return e[t]===i||(e[t]=i,(t==="name"||t==="params"||t==="selector")&&e.markDirty()),!0}}}index(e){return typeof e=="number"?e:(e.proxyOf&&(e=e.proxyOf),this.proxyOf.nodes.indexOf(e))}insertAfter(e,t){let i=this.index(e),n=this.normalize(t,this.proxyOf.nodes[i]).reverse();i=this.index(e);for(let a of n)this.proxyOf.nodes.splice(i+1,0,a);let s;for(let a in this.indexes)s=this.indexes[a],i<s&&(this.indexes[a]=s+n.length);return this.markDirty(),this}insertBefore(e,t){let i=this.index(e),n=i===0?"prepend":!1,s=this.normalize(t,this.proxyOf.nodes[i],n).reverse();i=this.index(e);for(let o of s)this.proxyOf.nodes.splice(i,0,o);let a;for(let o in this.indexes)a=this.indexes[o],i<=a&&(this.indexes[o]=a+s.length);return this.markDirty(),this}normalize(e,t){if(typeof e=="string")e=Dc(Pc(e).nodes);else if(typeof e=="undefined")e=[];else if(Array.isArray(e)){e=e.slice(0);for(let n of e)n.parent&&n.parent.removeChild(n,"ignore")}else if(e.type==="root"&&this.type!=="document"){e=e.nodes.slice(0);for(let n of e)n.parent&&n.parent.removeChild(n,"ignore")}else if(e.type)e=[e];else if(e.prop){if(typeof e.value=="undefined")throw new Error("Value fie Line truncated
`);i=new Array(s.length);let a=0;for(let o=0,l=s.length;o<l;o++)i[o]=a,a+=s[o].length+1;this[Ea]=i}t=i[i.length-1];let n=0;if(e>=t)n=i.length-1;else{let s=i.length-2,a;for(;n<s;)if(a=n+(s-n>>1),e<i[a])s=a-1;else if(e>=i[a+1])n=a+1;else{n=a;break}}return{col:e-i[n]+1,line:n+1}}mapResolve(e){return/^\w+:\/\//.test(e)?e:Ca(this.map.consumer().sourceRoot||this.map.root||".",e)}origin(e,t,i,n){if(!this.map)return!1;let s=this.map.consumer(),a=s.originalPositionFor({column:t,line:e});if(!a.source)return!1;let o;typeof i=="number"&&(o=s.originalPositionFor({column:n,line:i}));let l;Aa(a.source)?l=bn(a.source):l=new URL(a.source,this.map.consumer().sourceRoot||bn(this.map.mapFile));let c={column:a.column,endColumn:o&&o.column,endLine:o&&o.line,line:a.line,url:l.toString()};if(l.protocol==="file:")if(Hc)c.file=Hc(l);else throw new Error("file: protocol is not available in this PostCSS build");let f=s.sourceContentFor(a.source);return f&&(c.source=f),c}toJSON(){let e={};for(let t of["hasBOM","css","file","id"])this[t]!=null&&(e[t]=this[t]);return this.map&&(e.map={...this.map},e.map.consumerCache&&(e.map.consumerCache=void 0)),e}get from(){return this.file||this.id}};Qc.exports=ei;ei.default=ei;_a&&_a.registerInput&&_a.registerInput(ei)});var nr=x((sI,Zc)=>{u();"use strict";var Yc=Ot(),Kc,Xc,Ht=class extends Yc{constructor(e){super(e);this.type="root",this.nodes||(this.nodes=[])}normalize(e,t,i){let n=super.normalize(e);if(t){if(i==="prepend")this.nodes.length>1?t.raws.before=this.nodes[1].raws.before:delete t.raws.before;else if(this.first!==t)for(let s of n)s.raws.before=t.raws.before}return n}removeChild(e,t){let i=this.index(e);return!t&&i===0&&this.nodes.length>1&&(this.nodes[1].raws.before=this.nodes[i].raws.before),super.removeChild(e)}toResult(e={}){return new Kc(new Xc,this,e).stringify()}};Ht.registerLazyResult=r=>{Kc=r};Ht.registerProcessor=r=>{Xc=r};Zc.exports=Ht;Ht.default=Ht;Yc.registerRoot(Ht)});var Oa=x((aI,Jc)=>{u();"use strict";var ti={comma(r){return ti.split(r,[","],!0)},space(r){let e=[" ",`
`," "];return ti.split(r,e)},split(r,e,t){let i=[],n="",s=!1,a=0,o=!1,l="",c=!1;for(let f of r)c?c=!1:f==="\\"?c=!0:o?f===l&&(o=!1):f==='"'||f==="'"?(o=!0,l=f):f==="("?a+=1:f===")"?a>0&&(a-=1):a===0&&e.includes(f)&&(s=!0),s?(n!==""&&i.push(n.trim()),n="",s=!1):n+=f;return(t||n!=="")&&i.push(n.trim()),i}};Jc.exports=ti;ti.default=ti});var vn=x((oI,tp)=>{u();"use strict";var ep=Ot(),e1=Oa(),ri=class extends ep{constructor(e){super(e);this.type="rule",this.nodes||(this.nodes=[])}get selectors(){return e1.comma(this.selector)}set selectors(e){let t=this.selector?this.selector.match(/,\s*/):null,i=t?t[0]:","+this.raw("between","beforeOpen");this.selector=e.join(i)}};tp.exports=ri;ri.default=ri;ep.registerRule(ri)});var ip=x((lI,rp)=>{u();"use strict";var t1=gn(),r1=Xr(),i1=Zr(),n1=wn(),s1=Sa(),a1=nr(),o1=vn();function ii(r,e){if(Array.isArray(r))return r.map(n=>ii(n));let{inputs:t,...i}=r;if(t){e=[];for(let n of t){let s={...n,__proto__:n1.prototype};s.map&&(s.map={...s.map,__proto__:s1.prototype}),e.push(s)}}if(i.nodes&&(i.nodes=r.nodes.map(n=>ii(n,e))),i.source){let{inputId:n,...s}=i.source;i.source=s,n!=null&&(i.source.input=e[n])}if(i.type==="root")return new a1(i);if(i.type==="decl")return new i1(i);if(i.type==="rule")return new o1(i);if(i.type==="comment")return new r1(i);if(i.type==="atrule")return new t1(i);throw new Error("Unknown node type: "+r.type)}rp.exports=ii;ii.default=ii});var Ta=x((uI,np)=>{u();np.exports=function(r,e){return{generate:()=>{let t="";return r(e,i=>{t+=i}),[t]}}}});var up=x((fI,lp)=>{u();"use strict";var Ra="'".charCodeAt(0),sp='"'.charCodeAt(0),xn="\\".charCodeAt(0),ap="/".charCodeAt(0),kn=`
`.charCodeAt(0),ni=" ".charCodeAt(0),Sn="\f".charCodeAt(0),An=" ".charCodeAt(0),Cn="\r".charCodeAt(0),l1="[".charCodeAt(0),u1="]".charCodeAt(0),f1="(".charCodeAt(0),c1=")".charCodeAt(0),p1="{".charCodeAt(0),d1="}".charCodeAt(0),h1=";".charCodeAt(0),m1="*".charCodeAt(0),g1=":".charCodeAt(0),y1="@".charCodeAt(0),_n=/[\t\n\f\r "#'()/;[\\\]{}]/g,En=/[\t\n\f\r !"#'():;@[\\\]{}]|\/(?=\*)/g,b1=/.[\r\n"'(/\\]/,op=/[\da-f]/i;lp.exports=function(e,t={}){let i=e.css.valueOf(),n=t.ignoreErrors,s,a,o,l,c,f,d,p,h,b,v=i.length,y=0,w=[],k=[];function S(){return y}function E(R){throw e.error("Unclosed "+R,y)}function T(){return k.length===0&&y>=v}function B(R){if(k.length)return k.pop();if(y>=v)return;let F=R?R.ignoreUnclosed:!1;switch(s=i.charCodeAt(y),s){case kn:case ni:case An:case Cn:case Sn:{l=y;do l+=1,s=i.charCodeAt(l);while(s===ni||s===kn||s===An||s===Cn||s===Sn);f=["space",i.slice(y,l)],y=l-1;break}case l1:case u1:case p1:case d1:case g1:case h1:case c1:{let Y=String.fromCharCode(s);f=[Y,Y,y];break}case f1:{if(b=w.length?w.pop()[1]:"",h=i.charCodeAt(y+1),b==="url"&&h!==Ra&&h!==sp&&h!==ni&&h!==kn&&h!==An&&h!==Sn&&h!==Cn){l=y;do{if(d=!1,l=i.indexOf(")",l+1),l===-1)if(n||F){l=y;break}else E("bracket");for(p=l;i.charCodeAt(p-1)===xn;)p-=1,d=!d}while(d);f=["brackets",i.slice(y,l+1),y,l],y=l}else l=i.indexOf(")",y+1),a=i.slice(y,l+1),l===-1||b1.test(a)?f=["(","(",y]:(f=["brackets",a,y,l],y=l);break}case Ra:case sp:{c=s===Ra?"'":'"',l=y;do{if(d=!1,l=i.indexOf(c,l+1),l===-1)if(n||F){l=y+1;break}else E("string");for(p=l;i.charCodeAt(p-1)===xn;)p-=1,d=!d}while(d);f=["string",i.slice(y,l+1),y,l],y=l;break}case y1:{_n.lastIndex=y+1,_n.test(i),_n.lastIndex===0?l=i.length-1:l=_n.lastIndex-2,f=["at-word",i.slice(y,l+1),y,l],y=l;break}case xn:{for(l=y,o=!0;i.charCodeAt(l+1)===xn;)l+=1,o=!o;if(s=i.charCodeAt(l+1),o&&s!==ap&&s!==ni&&s!==kn&&s!==An&&s!==Cn&&s!==Sn&&(l+=1,op.test(i.charAt(l)))){for(;op.test(i.charAt(l+1));)l+=1;i.charCodeAt(l+1)===ni&&(l+=1)}f=["word",i.slice(y,l+1),y,l],y=l;break}default:{s===ap&&i.charCodeAt(y+1)===m1?(l=i.indexOf("*/",y+2)+1,l===0&&(n||F?l=i.length:E("comment")),f=["comment",i.slice(y,l+1),y,l],y=l):(En.lastIndex=y+1,En.test(i),En.lastIndex===0?l=i.length-1:l=En.lastIndex-2,f=["word",i.slice(y,l+1),y,l],w.push(f),y=l);break}}return y++,f}function N(R){k.push(R)}return{back:N,endOfFile:T,nextToken:B,position:S}}});var hp=x((cI,dp)=>{u();"use strict";var w1=gn(),v1=Xr(),x1=Zr(),k1=nr(),fp=vn(),S1=up(),cp={empty:!0,space:!0};function A1(r){for(let e=r.length-1;e>=0;e--){let t=r[e],i=t[3]||t[2];if(i)return i}}var pp=class{constructor(e){this.input=e,this.root=new k1,this.current=this.root,this.spaces="",this.semicolon=!1,this.createTokenizer(),this.root.source={input:e,start:{column:1,line:1,offset:0}}}atrule(e){let t=new w1;t.name=e[1].slice(1),t.name===""&&this.unnamedAtrule(t,e),this.init(t,e[2]);let i,n,s,a=!1,o=!1,l=[],c=[];for(;!this.tokenizer.endOfFile();){if(e=this.tokenizer.nextToken(),i=e[0],i==="("||i==="["?c.push(i==="("?")":"]"):i==="{"&&c.length>0?c.push("}"):i===c[c.length-1]&&c.pop(),c.length===0)if(i===";"){t.source.end=this.getPosition(e[2]),t.source.end.offset++,this.semicolon=!0;break}else if(i==="{"){o=!0;break}else if(i==="}"){if(l.length>0){for(s=l.length-1,n=l[s];n&&n[0]==="space";)n=l[--s];n&&(t.source.end=this.getPosition(n[3]||n[2]),t.source.end.offset++)}this.end(e);break}else l.push(e);else l.push(e);if(this.tokenizer.endOfFile()){a=!0;break}}t.raws.between=this.spacesAndCommentsFromEnd(l),l.length?(t.raws.afterName=this.spacesAndCommentsFromStart(l),this.raw(t,"params",l),a&&(e=l[l.length-1],t.source.end=this.getPosition(e[3]||e[2]),t.source.end.offset++,this.spaces=t.raws.between,t.raws.between="")):(t.raws.afterName="",t.params=""),o&&(t.nodes=[],this.current=t)}checkMissedSemicolon(e){let t=this.colon(e);if(t===!1)return;let i=0,n;for(let s=t-1;s>=0&&(n=e[s],!(n[0]!=="space"&&(i+=1,i===2)));s--);throw this.input.error("Missed semicolon",n[0]==="word"?n[3]+1:n[2])}colon(e){let t=0,i,n,s;for(let[a,o]of e.entries()){if(n=o,s=n[0],s==="("&&(t+=1),s===")"&&(t-=1),t===0&&s===":")if(!i)this.doubleColon(n);else{if(i[0]==="word"&&i[1]==="progid")continue;return a}i=n}return!1}comment(e){let t=new v1;this.init(t,e[2]),t.source.end=this.getPosition(e[3]||e[2]),t.source.end.offset++;let i=e[1].slice(2,-2);if(/^\s*$/.test(i))t.text="",t.raws.left=i,t.raws.right="";else{let n=i.match(/^(\s*)([^]*\S)(\s*)$/);t.text=n[2],t.raws.left=n[1],t.raws.right=n[3]}}createTokenizer(){this.tokenizer=S1(this.input)}decl(e,t){let i=new x1;this.init(i,e[0][2]);let n=e[e.length-1];for(n[0]===";"&&(this.semicolon=!0,e.pop()),i.source.end=this.getPosition(n[3]||n[2]||A1(e)),i.source.end.offset++;e[0][0]!=="word";)e.length===1&&this.unknownWord(e),i.raws.before+=e.shift()[1];for(i.source.start=this.getPosition(e[0][2]),i.prop="";e.length;){let c=e[0][0];if(c===":"||c==="space"||c==="comment")break;i.prop+=e.shift()[1]}i.raws.between="";let s;for(;e.length;)if(s=e.shift(),s[0]===":"){i.raws.between+=s[1 Line truncated
https://evilmartians.com/chronicles/postcss-8-plugin-migration`),m.env.LANG&&m.env.LANG.startsWith("cn")&&console.warn(e+`: \u91CC\u9762 postcss.plugin \u88AB\u5F03\u7528. \u8FC1\u79FB\u6307\u5357:
https://www.w3ctech.com/topic/2226`));let o=t(...a);return o.postcssPlugin=e,o.postcssVersion=new La().version,o}let s;return Object.defineProperty(n,"postcss",{get(){return s||(s=n()),s}}),n.process=function(a,o,l){return J([n(l)]).process(a,o)},n};J.stringify=rk;J.parse=ek;J.fromJSON=Y1;J.list=Z1;J.comment=r=>new Tp(r);J.atRule=r=>new Op(r);J.decl=r=>new Rp(r);J.rule=r=>new Dp(r);J.root=r=>new Ip(r);J.document=r=>new Pp(r);J.CssSyntaxError=Q1;J.Declaration=Rp;J.Container=G1;J.Processor=La;J.Document=Pp;J.Comment=Tp;J.Warning=ik;J.AtRule=Op;J.Result=tk;J.Input=K1;J.Rule=Dp;J.Root=Ip;J.Node=J1;X1.registerPostcss(J);qp.exports=J;J.default=J});var re,ee,kI,SI,AI,CI,_I,EI,OI,TI,RI,PI,II,DI,qI,$I,LI,MI,NI,BI,FI,jI,zI,UI,VI,HI,Tt=P(()=>{u();re=pe($e()),ee=re.default,kI=re.default.stringify,SI=re.default.fromJSON,AI=re.default.plugin,CI=re.default.parse,_I=re.default.list,EI=re.default.document,OI=re.default.comment,TI=re.default.atRule,RI=re.default.rule,PI=re.default.decl,II=re.default.root,DI=re.default.CssSyntaxError,qI=re.default.Declaration,$I=re.default.Container,LI=re.default.Processor,MI=re.default.Document,NI=re.default.Comment,BI=re.default.Warning,FI=re.default.AtRule,jI=re.default.Result,zI=re.default.Input,UI=re.default.Rule,VI=re.default.Root,HI=re.default.Node});var Ma=x((GI,$p)=>{u();$p.exports=function(r,e,t,i,n){for(e=e.split?e.split("."):e,i=0;i<e.length;i++)r=r?r[e[i]]:n;return r===n?t:r}});var $n=x((qn,Lp)=>{u();"use strict";qn.__esModule=!0;qn.default=ak;function nk(r){for(var e=r.toLowerCase(),t="",i=!1,n=0;n<6&&e[n]!==void 0;n++){var s=e.charCodeAt(n),a=s>=97&&s<=102||s>=48&&s<=57;if(i=s===32,!a)break;t+=e[n]}if(t.length!==0){var o=parseInt(t,16),l=o>=55296&&o<=57343;return l||o===0||o>1114111?["\uFFFD",t.length+(i?1:0)]:[String.fromCodePoint(o),t.length+(i?1:0)]}}var sk=/\\/;function ak(r){var e=sk.test(r);if(!e)return r;for(var t="",i=0;i<r.length;i++){if(r[i]==="\\"){var n=nk(r.slice(i+1,i+7));if(n!==void 0){t+=n[0],i+=n[1];continue}if(r[i+1]==="\\"){t+="\\",i++;continue}r.length===i+1&&(t+=r[i]);continue}t+=r[i]}return t}Lp.exports=qn.default});var Np=x((Ln,Mp)=>{u();"use strict";Ln.__esModule=!0;Ln.default=ok;function ok(r){for(var e=arguments.length,t=new Array(e>1?e-1:0),i=1;i<e;i++)t[i-1]=arguments[i];for(;t.length>0;){var n=t.shift();if(!r[n])return;r=r[n]}return r}Mp.exports=Ln.default});var Fp=x((Mn,Bp)=>{u();"use strict";Mn.__esModule=!0;Mn.default=lk;function lk(r){for(var e=arguments.length,t=new Array(e>1?e-1:0),i=1;i<e;i++)t[i-1]=arguments[i];for(;t.length>0;){var n=t.shift();r[n]||(r[n]={}),r=r[n]}}Bp.exports=Mn.default});var zp=x((Nn,jp)=>{u();"use strict";Nn.__esModule=!0;Nn.default=uk;function uk(r){for(var e="",t=r.indexOf("/*"),i=0;t>=0;){e=e+r.slice(i,t);var n=r.indexOf("*/",t+2);if(n<0)return e;i=n+2,t=r.indexOf("/*",i)}return e=e+r.slice(i),e}jp.exports=Nn.default});var ai=x(rt=>{u();"use strict";rt.__esModule=!0;rt.unesc=rt.stripComments=rt.getProp=rt.ensureObject=void 0;var fk=Bn($n());rt.unesc=fk.default;var ck=Bn(Np());rt.getProp=ck.default;var pk=Bn(Fp());rt.ensureObject=pk.default;var dk=Bn(zp());rt.stripComments=dk.default;function Bn(r){return r&&r.__esModule?r:{default:r}}});var dt=x((oi,Hp)=>{u();"use strict";oi.__esModule=!0;oi.default=void 0;var Up=ai();function Vp(r,e){for(var t=0;t<e.length;t++){var i=e[t];i.enumerable=i.enumerable||!1,i.configurable=!0,"value"in i&&(i.writable=!0),Object.defineProperty(r,i.key,i)}}function hk(r,e,t){return e&&Vp(r.prototype,e),t&&Vp(r,t),Object.defineProperty(r,"prototype",{writable:!1}),r}var mk=function r(e,t){if(typeof e!="object"||e===null)return e;var i=new e.constructor;for(var n in e)if(!!e.hasOwnProperty(n)){var s=e[n],a=typeof s;n==="parent"&&a==="object"?t&&(i[n]=t):s instanceof Array?i[n]=s.map(function(o){return r(o,i)}):i[n]=r(s,i)}return i},gk=function(){function r(t){t===void 0&&(t={}),Object.assign(this,t),this.spaces=this.spaces||{},this.spaces.before=this.spaces.before||"",this.spaces.after=this.spaces.after||""}var e=r.prototype;return e.remove=function(){return this.parent&&this.parent.removeChild(this),this.parent=void 0,this},e.replaceWith=function(){if(this.parent){for(var i in arguments)this.parent.insertBefore(this,arguments[i]);this.remove()}return this},e.next=function(){return this.parent.at(this.parent.index(this)+1)},e.prev=function(){return this.parent.at(this.parent.index(this)-1)},e.clone=function(i){i===void 0&&(i={});var n=mk(this);for(var s in i)n[s]=i[s];return n},e.appendToPropertyAndEscape=function(i,n,s){this.raws||(this.raws={});var a=this[i],o=this.raws[i];this[i]=a+n,o||s!==n?this.raws[i]=(o||a)+s:delete this.raws[i]},e.setPropertyAndEscape=function(i,n,s){this.raws||(this.raws={}),this[i]=n,this.raws[i]=s},e.setPropertyWithoutEscape=function(i,n){this[i]=n,this.raws&&delete this.raws[i]},e.isAtPosition=function(i,n){if(this.source&&this.source.start&&this.source.end)return!(this.source.start.line>i||this.source.end.line<i||this.source.start.line===i&&this.source.start.col Line truncated
In order to be iterable, non-array objects must have a [Symbol.iterator]() method.`)}function Dk(r,e){if(!!r){if(typeof r=="string")return Gp(r,e);var t=Object.prototype.toString.call(r).slice(8,-1);if(t==="Object"&&r.constructor&&(t=r.constructor.name),t==="Map"||t==="Set")return Array.from(r);if(t==="Arguments"||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(t))return Gp(r,e)}}function Gp(r,e){(e==null||e>r.length)&&(e=r.length);for(var t=0,i=new Array(e);t<e;t++)i[t]=r[t];return i}function Qp(r,e){for(var t=0;t<e.length;t++){var i=e[t];i.enumerable=i.enumerable||!1,i.configurable=!0,"value"in i&&(i.writable=!0),Object.defineProperty(r,i.key,i)}}function qk(r,e,t){return e&&Qp(r.prototype,e),t&&Qp(r,t),Object.defineProperty(r,"prototype",{writable:!1}),r}function $k(r,e){r.prototype=Object.create(e.prototype),r.prototype.constructor=r,Na(r,e)}function Na(r,e){return Na=Object.setPrototypeOf?Object.setPrototypeOf.bind():function(i,n){return i.__proto__=n,i},Na(r,e)}var Lk=function(r){$k(e,r);function e(i){var n;return n=r.call(this,i)||this,n.nodes||(n.nodes=[]),n}var t=e.prototype;return t.append=function(n){return n.parent=this,this.nodes.push(n),this},t.prepend=function(n){return n.parent=this,this.nodes.unshift(n),this},t.at=function(n){return this.nodes[n]},t.index=function(n){return typeof n=="number"?n:this.nodes.indexOf(n)},t.removeChild=function(n){n=this.index(n),this.at(n).parent=void 0,this.nodes.splice(n,1);var s;for(var a in this.indexes)s=this.indexes[a],s>=n&&(this.indexes[a]=s-1);return this},t.removeAll=function(){for(var n=Ik(this.nodes),s;!(s=n()).done;){var a=s.value;a.parent=void 0}return this.nodes=[],this},t.empty=function(){return this.removeAll()},t.insertAfter=function(n,s){s.parent=this;var a=this.index(n);this.nodes.splice(a+1,0,s),s.parent=this;var o;for(var l in this.indexes)o=this.indexes[l],a<=o&&(this.indexes[l]=o+1);return this},t.insertBefore=function(n,s){s.parent=this;var a=this.index(n);this.nodes.splice(a,0,s),s.parent=this;var o;for(var l in this.indexes)o=this.indexes[l],o<=a&&(this.indexes[l]=o+1);return this},t._findChildAtPosition=function(n,s){var a=void 0;return this.each(function(o){if(o.atPosition){var l=o.atPosition(n,s);if(l)return a=l,!1}else if(o.isAtPosition(n,s))return a=o,!1}),a},t.atPosition=function(n,s){if(this.isAtPosition(n,s))return this._findChildAtPosition(n,s)||this},t._inferEndPosition=function(){this.last&&this.last.source&&this.last.source.end&&(this.source=this.source||{},this.source.end=this.source.end||{},Object.assign(this.source.end,this.last.source.end))},t.each=function(n){this.lastEach||(this.lastEach=0),this.indexes||(this.indexes={}),this.lastEach++;var s=this.lastEach;if(this.indexes[s]=0,!!this.length){for(var a,o;this.indexes[s]<this.length&&(a=this.indexes[s],o=n(this.at(a),a),o!==!1);)this.indexes[s]+=1;if(delete this.indexes[s],o===!1)return!1}},t.walk=function(n){return this.each(function(s,a){var o=n(s,a);if(o!==!1&&s.length&&(o=s.walk(n)),o===!1)return!1})},t.walkAttributes=function(n){var s=this;return this.walk(function(a){if(a.type===ht.ATTRIBUTE)return n.call(s,a)})},t.walkClasses=function(n){var s=this;return this.walk(function(a){if(a.type===ht.CLASS)return n.call(s,a)})},t.walkCombinators=function(n){var s=this;return this.walk(function(a){if(a.type===ht.COMBINATOR)return n.call(s,a)})},t.walkComments=function(n){var s=this;return this.walk(function(a){if(a.type===ht.COMMENT)return n.call(s,a)})},t.walkIds=function(n){var s=this;return this.walk(function(a){if(a.type===ht.ID)return n.call(s,a)})},t.walkNesting=function(n){var s=this;return this.walk(function(a){if(a.type===ht.NESTING)return n.call(s,a)})},t.walkPseudos=function(n){var s=this;return this.walk(function(a){if(a.type===ht.PSEUDO)return n.call(s,a)})},t.walkTags=function(n){var s=this;return this.walk(function(a){if(a.type===ht.TAG)return n.call(s,a)})},t.walkUniversals=function(n){var s=this;return this.walk(function(a){if(a.type===ht.UNIVERSAL)return n.call(s,a)})},t.split=function(n){var s=this,a=[];return this.reduce(function(o,l,c){var f=n.call(s,l);return a.push(l),f?(o.push(a),a=[]):c===s.length-1&&o.push(a),o},[])},t.map=function(n){return this.nodes.map(n)},t.reduce=function(n,s){return this.nodes.reduce(n,s)},t.every=function(n){return this.nodes.every(n)},t.some=function(n){return this.nodes.some(n)},t.filter=function(n){return this.nodes.filter(n)},t.sort=function(n){return this.nodes.sort(n)},t.toString=function(){return this.map(String).join("")},qk(e,[{key:"first",get:function(){return this.at(0)}},{key:"last",get:function(){return this.at(this.length-1)}},{key:"length",get:function(){return this.nodes.length}}]),e}(Tk.default);li.default=Lk;Yp.exports=li.default});var Fa=x((ui,Xp)=>{u();"use strict";ui.__esModule=!0;ui.default=void 0;var Mk=Bk(Fn()),Nk=Se();function Bk(r){return r&&r.__esModule?r:{default:r}}function Kp(r,e){for(var t=0;t<e.length;t++){var i=e[t];i.enumerable=i.enumerable||!1,i.configurable=!0,"value"in i&&(i.writable Line truncated
`),v=y.length-1,v>0?(k=a+v,S=w-y[v].length):(k=a,S=s),T=D.comment,a=k,p=k,d=w-S):c===D.slash?(w=o,T=c,p=a,d=o-s,l=w+1):(w=VA(t,o),T=D.word,p=a,d=w-s),l=w+1;break}e.push([T,a,o-s,p,d,o,l]),S&&(s=S,S=null),o=l}return e}});var Pd=x((Ci,Rd)=>{u();"use strict";Ci.__esModule=!0;Ci.default=void 0;var QA=je(Fa()),bo=je(za()),YA=je(Ha()),Ad=je(Ga()),KA=je(Ya()),XA=je(Za()),wo=je(eo()),ZA=je(ro()),Cd=Qn(lo()),JA=je(fo()),vo=je(po()),eC=je(mo()),tC=je(wd()),O=Qn(Sd()),q=Qn(go()),rC=Qn(Se()),ue=ai(),Wt,xo;function _d(r){if(typeof WeakMap!="function")return null;var e=new WeakMap,t=new WeakMap;return(_d=function(n){return n?t:e})(r)}function Qn(r,e){if(!e&&r&&r.__esModule)return r;if(r===null||typeof r!="object"&&typeof r!="function")return{default:r};var t=_d(e);if(t&&t.has(r))return t.get(r);var i={},n=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var s in r)if(s!=="default"&&Object.prototype.hasOwnProperty.call(r,s)){var a=n?Object.getOwnPropertyDescriptor(r,s):null;a&&(a.get||a.set)?Object.defineProperty(i,s,a):i[s]=r[s]}return i.default=r,t&&t.set(r,i),i}function je(r){return r&&r.__esModule?r:{default:r}}function Ed(r,e){for(var t=0;t<e.length;t++){var i=e[t];i.enumerable=i.enumerable||!1,i.configurable=!0,"value"in i&&(i.writable=!0),Object.defineProperty(r,i.key,i)}}function iC(r,e,t){return e&&Ed(r.prototype,e),t&&Ed(r,t),Object.defineProperty(r,"prototype",{writable:!1}),r}var ko=(Wt={},Wt[q.space]=!0,Wt[q.cr]=!0,Wt[q.feed]=!0,Wt[q.newline]=!0,Wt[q.tab]=!0,Wt),nC=Object.assign({},ko,(xo={},xo[q.comment]=!0,xo));function Od(r){return{line:r[O.FIELDS.START_LINE],column:r[O.FIELDS.START_COL]}}function Td(r){return{line:r[O.FIELDS.END_LINE],column:r[O.FIELDS.END_COL]}}function Gt(r,e,t,i){return{start:{line:r,column:e},end:{line:t,column:i}}}function lr(r){return Gt(r[O.FIELDS.START_LINE],r[O.FIELDS.START_COL],r[O.FIELDS.END_LINE],r[O.FIELDS.END_COL])}function So(r,e){if(!!r)return Gt(r[O.FIELDS.START_LINE],r[O.FIELDS.START_COL],e[O.FIELDS.END_LINE],e[O.FIELDS.END_COL])}function ur(r,e){var t=r[e];if(typeof t=="string")return t.indexOf("\\")!==-1&&((0,ue.ensureObject)(r,"raws"),r[e]=(0,ue.unesc)(t),r.raws[e]===void 0&&(r.raws[e]=t)),r}function Ao(r,e){for(var t=-1,i=[];(t=r.indexOf(e,t+1))!==-1;)i.push(t);return i}function sC(){var r=Array.prototype.concat.apply([],arguments);return r.filter(function(e,t){return t===r.indexOf(e)})}var aC=function(){function r(t,i){i===void 0&&(i={}),this.rule=t,this.options=Object.assign({lossy:!1,safe:!1},i),this.position=0,this.css=typeof this.rule=="string"?this.rule:this.rule.selector,this.tokens=(0,O.default)({css:this.css,error:this._errorGenerator(),safe:this.options.safe});var n=So(this.tokens[0],this.tokens[this.tokens.length-1]);this.root=new QA.default({source:n}),this.root.errorGenerator=this._errorGenerator();var s=new bo.default({source:{start:{line:1,column:1}},sourceIndex:0});this.root.append(s),this.current=s,this.loop()}var e=r.prototype;return e._errorGenerator=function(){var i=this;return function(n,s){return typeof i.rule=="string"?new Error(n):i.rule.error(n,s)}},e.attribute=function(){var i=[],n=this.currToken;for(this.position++;this.position<this.tokens.length&&this.currToken[O.FIELDS.TYPE]!==q.closeSquare;)i.push(this.currToken),this.position++;if(this.currToken[O.FIELDS.TYPE]!==q.closeSquare)return this.expected("closing square bracket",this.currToken[O.FIELDS.START_POS]);var s=i.length,a={source:Gt(n[1],n[2],this.currToken[3],this.currToken[4]),sourceIndex:n[O.FIELDS.START_POS]};if(s===1&&!~[q.word].indexOf(i[0][O.FIELDS.TYPE]))return this.expected("attribute",i[0][O.FIELDS.START_POS]);for(var o=0,l="",c="",f=null,d=!1;o<s;){var p=i[o],h=this.content(p),b=i[o+1];switch(p[O.FIELDS.TYPE]){case q.space:if(d=!0,this.options.lossy)break;if(f){(0,ue.ensureObject)(a,"spaces",f);var v=a.spaces[f].after||"";a.spaces[f].after=v+h;var y=(0,ue.getProp)(a,"raws","spaces",f,"after")||null;y&&(a.raws.spaces[f].after=y+h)}else l=l+h,c=c+h;break;case q.asterisk:if(b[O.FIELDS.TYPE]===q.equals)a.operator=h,f="operator";else if((!a.namespace||f==="namespace"&&!d)&&b){l&&((0,ue.ensureObject)(a,"spaces","attribute"),a.spaces.attribute.before=l,l=""),c&&((0,ue.ensureObject)(a,"raws","spaces","attribute"),a.raws.spaces.attribute.before=l,c=""),a.namespace=(a.namespace||"")+h;var w=(0,ue.getProp)(a,"raws","namespace")||null;w&&(a.raws.namespace+=h),f="namespace"}d=!1;break;case q.dollar:if(f==="value"){var k=(0,ue.getProp)(a,"raws","value");a.value+="$",k&&(a.raws.value=k+"$");break}case q.caret:b[O.FIELDS.TYPE]===q.equals&&(a.operator=h,f="operator"),d=!1;break;case q.combinator:if(h==="~"&&b[O.FIELDS.TYPE]===q.equals&&(a.operator=h,f="operator"),h!=="|"){d=!1;break}b[O.FIELDS.TYPE]===q.equals?(a.operator=h,f="operator"):!a.namespace&&!a.attribute&&(a.namespace=!0),d=!1;break;case q.word:if(b&&this.content(b)==="|"&&i[o+2]&&i[o+2][O.FIELDS.TYPE]!==q.equals&&!a.operator&&!a.namespace)a.namespace=h,f="namespace";else if(!a.attribute||f==="attribute"&&!d){l&&((0,ue. Line truncated
`).slice(1,-1).map(N=>N.trim()).map(N=>` ${N}`).join(`
`)).join(`
`);w.push(` Use \`${r.replace("[",`[${E}:`)}\` for \`${T.trim()}\``);break}G.warn([`The class \`${r}\` is ambiguous and matches multiple utilities.`,...w,`If this is content and not a class, replace it with \`${r.replace("[","&lsqb;").replace("]","&rsqb;")}\` to silence this warning.`]);continue}}o=o.map(p=>p.filter(h=>Lh(h[1])))}o=o.flat(),o=Array.from(G_(o,i)),o=N_(o,e),s&&(o=B_(o,i));for(let p of n)o=F_(p,o,e);for(let p of o)p[1].raws.tailwind={...p[1].raws.tailwind,candidate:r},p=Q_(p,{context:e,candidate:r}),p!==null&&(yield p)}}function Q_(r,{context:e,candidate:t}){if(!r[0].collectedFormats)return r;let i=!0,n;try{n=gr(r[0].collectedFormats,{context:e,candidate:t})}catch{return null}let s=ee.root({nodes:[r[1].clone()]});return s.walkRules(a=>{if(!us(a))try{let o=as(a.selector,n,{candidate:t,context:e});if(o===null){a.remove();return}a.selector=o}catch{return i=!1,!1}}),!i||s.nodes.length===0?null:(r[1]=s.nodes[0],r)}function us(r){return r.parent&&r.parent.type==="atrule"&&r.parent.name==="keyframes"}function Y_(r){if(r===!0)return e=>{us(e)||e.walkDecls(t=>{t.parent.type==="rule"&&!us(t.parent)&&(t.important=!0)})};if(typeof r=="string")return e=>{us(e)||(e.selectors=e.selectors.map(t=>os(t,r)))}}function fs(r,e,t=!1){let i=[],n=Y_(e.tailwindConfig.important);for(let s of r){if(e.notClassCache.has(s))continue;if(e.candidateRuleCache.has(s)){i=i.concat(Array.from(e.candidateRuleCache.get(s)));continue}let a=Array.from(rl(s,e));if(a.length===0){e.notClassCache.add(s);continue}e.classCache.set(s,a);let o=e.candidateRuleCache.get(s)??new Set;e.candidateRuleCache.set(s,o);for(let l of a){let[{sort:c,options:f},d]=l;if(f.respectImportant&&n){let h=ee.root({nodes:[d.clone()]});h.walkRules(n),d=h.nodes[0]}let p=[c,t?d.clone():d];o.add(p),e.ruleCache.add(p),i.push(p)}}return i}function il(r){return r.startsWith("[")&&r.endsWith("]")}var ls,L_,j_,cs=P(()=>{u();Tt();ls=pe(it());jo();Jt();Xn();Ur();Be();Dt();Xo();Uo();zr();Pi();Wo();Vt();ct();Jo();L_=(0,ls.default)(r=>r.first.filter(({type:e})=>e==="class").pop().value);j_=/^[a-z_-]/});var Nh,Bh=P(()=>{u();Nh={}});function K_(r){try{return Nh.createHash("md5").update(r,"utf-8").digest("binary")}catch(e){return""}}function Fh(r,e){let t=e.toString();if(!t.includes("@tailwind"))return!1;let i=Ho.get(r),n=K_(t),s=i!==n;return Ho.set(r,n),s}var jh=P(()=>{u();Bh();Dt()});function ds(r){return(r>0n)-(r<0n)}var zh=P(()=>{u()});function Uh(r,e){let t=0n,i=0n;for(let[n,s]of e)r&n&&(t=t|n,i=i|s);return r&~t|i}var Vh=P(()=>{u()});function Hh(r){let e=null;for(let t of r)e=e??t,e=e>t?e:t;return e}function X_(r,e){let t=r.length,i=e.length,n=t<i?t:i;for(let s=0;s<n;s++){let a=r.charCodeAt(s)-e.charCodeAt(s);if(a!==0)return a}return t-i}var nl,Wh=P(()=>{u();zh();Vh();nl=class{constructor(){this.offsets={defaults:0n,base:0n,components:0n,utilities:0n,variants:0n,user:0n},this.layerPositions={defaults:0n,base:1n,components:2n,utilities:3n,user:4n,variants:5n},this.reservedVariantBits=0n,this.variantOffsets=new Map}create(e){return{layer:e,parentLayer:e,arbitrary:0n,variants:0n,parallelIndex:0n,index:this.offsets[e]++,propertyOffset:0n,property:"",options:[]}}arbitraryProperty(e){return{...this.create("utilities"),arbitrary:1n,property:e}}forVariant(e,t=0){let i=this.variantOffsets.get(e);if(i===void 0)throw new Error(`Cannot find offset for unknown variant ${e}`);return{...this.create("variants"),variants:i<<BigInt(t)}}applyVariantOffset(e,t,i){return i.variant=t.variants,{...e,layer:"variants",parentLayer:e.layer==="variants"?e.parentLayer:e.layer,variants:e.variants|t.variants,options:i.sort?[].concat(i,e.options):e.options,parallelIndex:Hh([e.parallelIndex,t.parallelIndex])}}applyParallelOffset(e,t){return{...e,parallelIndex:BigInt(t)}}recordVariants(e,t){for(let i of e)this.recordVariant(i,t(i))}recordVariant(e,t=1){return this.variantOffsets.set(e,1n<<this.reservedVariantBits),this.reservedVariantBits+=BigInt(t),{...this.create("variants"),variants:this.variantOffsets.get(e)}}compare(e,t){if(e.layer!==t.layer)return this.layerPositions[e.layer]-this.layerPositions[t.layer];if(e.parentLayer!==t.parentLayer)return this.layerPositions[e.parentLayer]-this.layerPositions[t.parentLayer];for(let i of e.options)for(let n of t.options){if(i.id!==n.id||!i.sort||!n.sort)continue;let s=Hh([i.variant,n.variant])??0n,a=~(s|s-1n),o=e.variants&a,l=t.variants&a;if(o!==l)continue;let c=i.sort({value:i.value,modifier:i.modifier},{value:n.value,modifier:n.modifier});if(c!==0)return c}return e.variants!==t.variants?e.variants-t.variants:e.parallelIndex!==t.parallelIndex?e.parallelIndex-t.parallelIndex:e.arbitrary!==t.arbitrary?e.arbitrary-t.arbitrary:e.propertyOffset!==t.propertyOffset?e.propertyOffset-t.propertyOffset:e.index-t.index}recalculateVariantOffsets(){let e=Array.from(this.variantOffsets.entries()).filter(([n])=>n.startsWith("[")).sort(([n],[s])=>X_(n,s)),t=e.map(([,n])=>n).sort((n,s)=>ds(n-s));return e.map(([,n],s)=>[n,t[s]]).filter(([n,s])=>n!==s)}remapArbitraryVariantOffsets(eLine truncated
`,CHAR_NO_BREAK_SPACE:"\xA0",CHAR_PERCENT:"%",CHAR_PLUS:"+",CHAR_QUESTION_MARK:"?",CHAR_RIGHT_ANGLE_BRACKET:">",CHAR_RIGHT_CURLY_BRACE:"}",CHAR_RIGHT_SQUARE_BRACKET:"]",CHAR_SEMICOLON:";",CHAR_SINGLE_QUOTE:"'",CHAR_SPACE:" ",CHAR_TAB:" ",CHAR_UNDERSCORE:"_",CHAR_VERTICAL_LINE:"|",CHAR_ZERO_WIDTH_NOBREAK_SPACE:"\uFEFF"}});var Gm=x((T6,Wm)=>{u();"use strict";var PE=vs(),{MAX_LENGTH:Um,CHAR_BACKSLASH:bl,CHAR_BACKTICK:IE,CHAR_COMMA:DE,CHAR_DOT:qE,CHAR_LEFT_PARENTHESES:$E,CHAR_RIGHT_PARENTHESES:LE,CHAR_LEFT_CURLY_BRACE:ME,CHAR_RIGHT_CURLY_BRACE:NE,CHAR_LEFT_SQUARE_BRACKET:Vm,CHAR_RIGHT_SQUARE_BRACKET:Hm,CHAR_DOUBLE_QUOTE:BE,CHAR_SINGLE_QUOTE:FE,CHAR_NO_BREAK_SPACE:jE,CHAR_ZERO_WIDTH_NOBREAK_SPACE:zE}=zm(),UE=(r,e={})=>{if(typeof r!="string")throw new TypeError("Expected a string");let t=e||{},i=typeof t.maxLength=="number"?Math.min(Um,t.maxLength):Um;if(r.length>i)throw new SyntaxError(`Input length (${r.length}), exceeds max characters (${i})`);let n={type:"root",input:r,nodes:[]},s=[n],a=n,o=n,l=0,c=r.length,f=0,d=0,p,h=()=>r[f++],b=v=>{if(v.type==="text"&&o.type==="dot"&&(o.type="text"),o&&o.type==="text"&&v.type==="text"){o.value+=v.value;return}return a.nodes.push(v),v.parent=a,v.prev=o,o=v,v};for(b({type:"bos"});f<c;)if(a=s[s.length-1],p=h(),!(p===zE||p===jE)){if(p===bl){b({type:"text",value:(e.keepEscaping?p:"")+h()});continue}if(p===Hm){b({type:"text",value:"\\"+p});continue}if(p===Vm){l++;let v;for(;f<c&&(v=h());){if(p+=v,v===Vm){l++;continue}if(v===bl){p+=h();continue}if(v===Hm&&(l--,l===0))break}b({type:"text",value:p});continue}if(p===$E){a=b({type:"paren",nodes:[]}),s.push(a),b({type:"text",value:p});continue}if(p===LE){if(a.type!=="paren"){b({type:"text",value:p});continue}a=s.pop(),b({type:"text",value:p}),a=s[s.length-1];continue}if(p===BE||p===FE||p===IE){let v=p,y;for(e.keepQuotes!==!0&&(p="");f<c&&(y=h());){if(y===bl){p+=y+h();continue}if(y===v){e.keepQuotes===!0&&(p+=y);break}p+=y}b({type:"text",value:p});continue}if(p===ME){d++;let v=o.value&&o.value.slice(-1)==="$"||a.dollar===!0;a=b({type:"brace",open:!0,close:!1,dollar:v,depth:d,commas:0,ranges:0,nodes:[]}),s.push(a),b({type:"open",value:p});continue}if(p===NE){if(a.type!=="brace"){b({type:"text",value:p});continue}let v="close";a=s.pop(),a.close=!0,b({type:v,value:p}),d--,a=s[s.length-1];continue}if(p===DE&&d>0){if(a.ranges>0){a.ranges=0;let v=a.nodes.shift();a.nodes=[v,{type:"text",value:PE(a)}]}b({type:"comma",value:p}),a.commas++;continue}if(p===qE&&d>0&&a.commas===0){let v=a.nodes;if(d===0||v.length===0){b({type:"text",value:p});continue}if(o.type==="dot"){if(a.range=[],o.value+=p,o.type="range",a.nodes.length!==3&&a.nodes.length!==5){a.invalid=!0,a.ranges=0,o.type="text";continue}a.ranges++,a.args=[];continue}if(o.type==="range"){v.pop();let y=v[v.length-1];y.value+=o.value+p,o=y,a.ranges--;continue}b({type:"dot",value:p});continue}b({type:"text",value:p})}do if(a=s.pop(),a.type!=="root"){a.nodes.forEach(w=>{w.nodes||(w.type==="open"&&(w.isOpen=!0),w.type==="close"&&(w.isClose=!0),w.nodes||(w.type="text"),w.invalid=!0)});let v=s[s.length-1],y=v.nodes.indexOf(a);v.nodes.splice(y,1,...a.nodes)}while(s.length>0);return b({type:"eos"}),n};Wm.exports=UE});var Km=x((R6,Ym)=>{u();"use strict";var Qm=vs(),VE=Mm(),HE=Fm(),WE=Gm(),Le=(r,e={})=>{let t=[];if(Array.isArray(r))for(let i of r){let n=Le.create(i,e);Array.isArray(n)?t.push(...n):t.push(n)}else t=[].concat(Le.create(r,e));return e&&e.expand===!0&&e.nodupes===!0&&(t=[...new Set(t)]),t};Le.parse=(r,e={})=>WE(r,e);Le.stringify=(r,e={})=>typeof r=="string"?Qm(Le.parse(r,e),e):Qm(r,e);Le.compile=(r,e={})=>(typeof r=="string"&&(r=Le.parse(r,e)),VE(r,e));Le.expand=(r,e={})=>{typeof r=="string"&&(r=Le.parse(r,e));let t=HE(r,e);return e.noempty===!0&&(t=t.filter(Boolean)),e.nodupes===!0&&(t=[...new Set(t)]),t};Le.create=(r,e={})=>r===""||r.length<3?[r]:e.expand!==!0?Le.compile(r,e):Le.expand(r,e);Ym.exports=Le});var Li=x((P6,tg)=>{u();"use strict";var GE=(et(),Wr),at="\\\\/",Xm=`[^${at}]`,yt="\\.",QE="\\+",YE="\\?",Ss="\\/",KE="(?=.)",Zm="[^/]",wl=`(?:${Ss}|$)`,Jm=`(?:^|${Ss})`,vl=`${yt}{1,2}${wl}`,XE=`(?!${yt})`,ZE=`(?!${Jm}${vl})`,JE=`(?!${yt}{0,1}${wl})`,e2=`(?!${vl})`,t2=`[^.${Ss}]`,r2=`${Zm}*?`,eg={DOT_LITERAL:yt,PLUS_LITERAL:QE,QMARK_LITERAL:YE,SLASH_LITERAL:Ss,ONE_CHAR:KE,QMARK:Zm,END_ANCHOR:wl,DOTS_SLASH:vl,NO_DOT:XE,NO_DOTS:ZE,NO_DOT_SLASH:JE,NO_DOTS_SLASH:e2,QMARK_NO_DOT:t2,STAR:r2,START_ANCHOR:Jm},i2={...eg,SLASH_LITERAL:`[${at}]`,QMARK:Xm,STAR:`${Xm}*?`,DOTS_SLASH:`${yt}{1,2}(?:[${at}]|$)`,NO_DOT:`(?!${yt})`,NO_DOTS:`(?!(?:^|[${at}])${yt}{1,2}(?:[${at}]|$))`,NO_DOT_SLASH:`(?!${yt}{0,1}(?:[${at}]|$))`,NO_DOTS_SLASH:`(?!${yt}{1,2}(?:[${at}]|$))`,QMARK_NO_DOT:`[^.${at}]`,START_ANCHOR:`(?:^|[${at}])`,END_ANCHOR:`(?:[${at}]|$)`},n2={alnum:"a-zA-Z0-9",alpha:"a-zA-Z",ascii:"\\x00-\\x7F",blank:" \\t",cntrl:"\\x00-\\x1F\\x7F",digit:"0-9",graph:"\\x21-\\x7E",lower:"a-z",print:"\\x20-\\x7E ",punct:"\\-!\"#$%&'()\\*+,./:;<=>?@[\\]^_`{|}~",space:" \\t\\r\\n\\v\\f",upper:"A-Z",word:"A-Za-z0-9_Line truncated
`))if(n=n.trim(),!i.has(n))if(i.add(n),Bi.get(e).has(n))for(let s of Bi.get(e).get(n))t.add(s);else{let s=e(n).filter(o=>o!=="!*"),a=new Set(s);for(let o of a)t.add(o);Bi.get(e).set(n,a)}}function H2(r,e){let t=e.offsets.sort(r),i={base:new Set,defaults:new Set,components:new Set,utilities:new Set,variants:new Set};for(let[n,s]of t)i[n.layer].add(s);return i}function Ll(r){return async e=>{let t={base:null,components:null,utilities:null,variants:null};if(e.walkAtRules(y=>{y.name==="tailwind"&&Object.keys(t).includes(y.params)&&(t[y.params]=y)}),Object.values(t).every(y=>y===null))return e;let i=new Set([...r.candidates??[],gt]),n=new Set;bt.DEBUG&&console.time("Reading changed files");let s=[];for(let y of r.changedContent){let w=U2(r.tailwindConfig,y.extension),k=z2(r,y.extension);s.push([y,{transformer:w,extractor:k}])}let a=500;for(let y=0;y<s.length;y+=a){let w=s.slice(y,y+a);await Promise.all(w.map(async([{file:k,content:S},{transformer:E,extractor:T}])=>{S=k?await be.promises.readFile(k,"utf8"):S,V2(E(S),T,i,n)}))}bt.DEBUG&&console.timeEnd("Reading changed files");let o=r.classCache.size;bt.DEBUG&&console.time("Generate rules"),bt.DEBUG&&console.time("Sorting candidates");let l=new Set([...i].sort((y,w)=>y===w?0:y<w?-1:1));bt.DEBUG&&console.timeEnd("Sorting candidates"),fs(l,r),bt.DEBUG&&console.timeEnd("Generate rules"),bt.DEBUG&&console.time("Build stylesheet"),(r.stylesheetCache===null||r.classCache.size!==o)&&(r.stylesheetCache=H2([...r.ruleCache],r)),bt.DEBUG&&console.timeEnd("Build stylesheet");let{defaults:c,base:f,components:d,utilities:p,variants:h}=r.stylesheetCache;t.base&&(t.base.before(Kt([...c,...f],t.base.source,{layer:"base"})),t.base.remove()),t.components&&(t.components.before(Kt([...d],t.components.source,{layer:"components"})),t.components.remove()),t.utilities&&(t.utilities.before(Kt([...p],t.utilities.source,{layer:"utilities"})),t.utilities.remove());let b=Array.from(h).filter(y=>{let w=y.raws.tailwind?.parentLayer;return w==="components"?t.components!==null:w==="utilities"?t.utilities!==null:!0});t.variants?(t.variants.before(Kt(b,t.variants.source,{layer:"variants"})),t.variants.remove()):b.length>0&&e.append(Kt(b,e.source,{layer:"variants"})),e.source.end=e.source.end??e.source.start;let v=b.some(y=>y.raws.tailwind?.parentLayer==="utilities");t.utilities&&p.size===0&&!v&&G.warn("content-problems",["No utility classes were detected in your source files. If this is unexpected, double-check the `content` option in your Tailwind CSS configuration.","https://tailwindcss.com/docs/content-configuration"]),bt.DEBUG&&(console.log("Potential classes: ",i.size),console.log("Active contexts: ",ns.size)),r.changedContent=[],e.walkAtRules("layer",y=>{Object.keys(t).includes(y.params)&&y.remove()})}}var Hg,bt,Wg,Gg,Bi,Qg=P(()=>{u();ft();Hg=pe(Gs());Dt();cs();Be();Bg();Vg();bt=Ze,Wg={DEFAULT:Ug},Gg={DEFAULT:r=>r,svelte:r=>r.replace(/(?:^|\s)class:/g," ")};Bi=new WeakMap});function Es(r){let e=new Map;ee.root({nodes:[r.clone()]}).walkRules(s=>{(0,_s.default)(a=>{a.walkClasses(o=>{let l=o.parent.toString(),c=e.get(l);c||e.set(l,c=new Set),c.add(o.value)})}).processSync(s.selector)});let i=Array.from(e.values(),s=>Array.from(s)),n=i.flat();return Object.assign(n,{groups:i})}function Ml(r){return W2.astSync(r)}function Yg(r,e){let t=new Set;for(let i of r)t.add(i.split(e).pop());return Array.from(t)}function Kg(r,e){let t=r.tailwindConfig.prefix;return typeof t=="function"?t(e):t+e}function*Xg(r){for(yield r;r.parent;)yield r.parent,r=r.parent}function G2(r,e={}){let t=r.nodes;r.nodes=[];let i=r.clone(e);return r.nodes=t,i}function Q2(r){for(let e of Xg(r))if(r!==e){if(e.type==="root")break;r=G2(e,{nodes:[r]})}return r}function Y2(r,e){let t=new Map;return r.walkRules(i=>{for(let a of Xg(i))if(a.raws.tailwind?.layer!==void 0)return;let n=Q2(i),s=e.offsets.create("user");for(let a of Es(i)){let o=t.get(a)||[];t.set(a,o),o.push([{layer:"user",sort:s,important:!1},n])}}),t}function K2(r,e){for(let t of r){if(e.notClassCache.has(t)||e.applyClassCache.has(t))continue;if(e.classCache.has(t)){e.applyClassCache.set(t,e.classCache.get(t).map(([n,s])=>[n,s.clone()]));continue}let i=Array.from(rl(t,e));if(i.length===0){e.notClassCache.add(t);continue}e.applyClassCache.set(t,i)}return e.applyClassCache}function X2(r){let e=null;return{get:t=>(e=e||r(),e.get(t)),has:t=>(e=e||r(),e.has(t))}}function Z2(r){return{get:e=>r.flatMap(t=>t.get(e)||[]),has:e=>r.some(t=>t.has(e))}}function Zg(r){let e=r.split(/[\s\t\n]+/g);return e[e.length-1]==="!important"?[e.slice(0,-1),!0]:[e,!1]}function Jg(r,e,t){let i=new Set,n=[];if(r.walkAtRules("apply",l=>{let[c]=Zg(l.params);for(let f of c)i.add(f);n.push(l)}),n.length===0)return;let s=Z2([t,K2(i,e)]);function a(l,c,f){let d=Ml(l),p=Ml(c),b=Ml(`.${Te(f)}`).nodes[0].nodes[0];return d.each(v=>{let y=new Set;p.each(w=>{let k=!1;w=w.clone(),w.walkClasses(S=>{S.value===b.value&&(k||(S.replaceWith(...v.nodes.map(E=>E.clone())),y.add(w),k=!0))})});for(let w of y){let k=[[]];for( Line truncated
`))});c.push([p,d,h])}}for(let[l,[c,f]]of o){let d=[];for(let[h,b,v]of c){let y=[h,...Yg([h],e.tailwindConfig.separator)];for(let[w,k]of v){let S=Es(l),E=Es(k);if(E=E.groups.filter(R=>R.some(F=>y.includes(F))).flat(),E=E.concat(Yg(E,e.tailwindConfig.separator)),S.some(R=>E.includes(R)))throw k.error(`You cannot \`@apply\` the \`${h}\` utility here because it creates a circular dependency.`);let B=ee.root({nodes:[k.clone()]});B.walk(R=>{R.source=f}),(k.type!=="atrule"||k.type==="atrule"&&k.name!=="keyframes")&&B.walkRules(R=>{if(!Es(R).some(U=>U===h)){R.remove();return}let F=typeof e.tailwindConfig.important=="string"?e.tailwindConfig.important:null,_=l.raws.tailwind!==void 0&&F&&l.selector.indexOf(F)===0?l.selector.slice(F.length):l.selector;_===""&&(_=l.selector),R.selector=a(_,R.selector,h),F&&_!==l.selector&&(R.selector=os(R.selector,F)),R.walkDecls(U=>{U.important=w.important||b});let Q=(0,_s.default)().astSync(R.selector);Q.each(U=>mr(U)),R.selector=Q.toString()}),!!B.nodes[0]&&d.push([w.sort,B.nodes[0]])}}let p=e.offsets.sort(d).map(h=>h[1]);l.after(p)}for(let l of n)l.parent.nodes.length>1?l.remove():l.parent.remove();Jg(r,e,t)}function Nl(r){return e=>{let t=X2(()=>Y2(e,r));Jg(e,r,t)}}var _s,W2,ey=P(()=>{u();Tt();_s=pe(it());cs();dr();Jo();ss();W2=(0,_s.default)()});var ty=x((Oq,Os)=>{u();(function(){"use strict";function r(i,n,s){if(!i)return null;r.caseSensitive||(i=i.toLowerCase());var a=r.threshold===null?null:r.threshold*i.length,o=r.thresholdAbsolute,l;a!==null&&o!==null?l=Math.min(a,o):a!==null?l=a:o!==null?l=o:l=null;var c,f,d,p,h,b=n.length;for(h=0;h<b;h++)if(f=n[h],s&&(f=f[s]),!!f&&(r.caseSensitive?d=f:d=f.toLowerCase(),p=t(i,d,l),(l===null||p<l)&&(l=p,s&&r.returnWinningObject?c=n[h]:c=f,r.returnFirstMatch)))return c;return c||r.nullResultValue}r.threshold=.4,r.thresholdAbsolute=20,r.caseSensitive=!1,r.nullResultValue=null,r.returnWinningObject=null,r.returnFirstMatch=!1,typeof Os!="undefined"&&Os.exports?Os.exports=r:window.didYouMean=r;var e=Math.pow(2,32)-1;function t(i,n,s){s=s||s===0?s:e;var a=i.length,o=n.length;if(a===0)return Math.min(s+1,o);if(o===0)return Math.min(s+1,a);if(Math.abs(a-o)>s)return s+1;var l=[],c,f,d,p,h;for(c=0;c<=o;c++)l[c]=[c];for(f=0;f<=a;f++)l[0][f]=f;for(c=1;c<=o;c++){for(d=e,p=1,c>s&&(p=c-s),h=o+1,h>s+c&&(h=s+c),f=1;f<=a;f++)f<p||f>h?l[c][f]=s+1:n.charAt(c-1)===i.charAt(f-1)?l[c][f]=l[c-1][f-1]:l[c][f]=Math.min(l[c-1][f-1]+1,Math.min(l[c][f-1]+1,l[c-1][f]+1)),l[c][f]<d&&(d=l[c][f]);if(d>s)return s+1}return l[o][a]}})()});var iy=x((Tq,ry)=>{u();var Bl="(".charCodeAt(0),Fl=")".charCodeAt(0),Ts="'".charCodeAt(0),jl='"'.charCodeAt(0),zl="\\".charCodeAt(0),vr="/".charCodeAt(0),Ul=",".charCodeAt(0),Vl=":".charCodeAt(0),Rs="*".charCodeAt(0),J2="u".charCodeAt(0),eO="U".charCodeAt(0),tO="+".charCodeAt(0),rO=/^[a-f0-9?-]+$/i;ry.exports=function(r){for(var e=[],t=r,i,n,s,a,o,l,c,f,d=0,p=t.charCodeAt(d),h=t.length,b=[{nodes:e}],v=0,y,w="",k="",S="";d<h;)if(p<=32){i=d;do i+=1,p=t.charCodeAt(i);while(p<=32);a=t.slice(d,i),s=e[e.length-1],p===Fl&&v?S=a:s&&s.type==="div"?(s.after=a,s.sourceEndIndex+=a.length):p===Ul||p===Vl||p===vr&&t.charCodeAt(i+1)!==Rs&&(!y||y&&y.type==="function"&&!1)?k=a:e.push({type:"space",sourceIndex:d,sourceEndIndex:i,value:a}),d=i}else if(p===Ts||p===jl){i=d,n=p===Ts?"'":'"',a={type:"string",sourceIndex:d,quote:n};do if(o=!1,i=t.indexOf(n,i+1),~i)for(l=i;t.charCodeAt(l-1)===zl;)l-=1,o=!o;else t+=n,i=t.length-1,a.unclosed=!0;while(o);a.value=t.slice(d+1,i),a.sourceEndIndex=a.unclosed?i:i+1,e.push(a),d=i+1,p=t.charCodeAt(d)}else if(p===vr&&t.charCodeAt(d+1)===Rs)i=t.indexOf("*/",d),a={type:"comment",sourceIndex:d,sourceEndIndex:i+2},i===-1&&(a.unclosed=!0,i=t.length,a.sourceEndIndex=i),a.value=t.slice(d+2,i),e.push(a),d=i+2,p=t.charCodeAt(d);else if((p===vr||p===Rs)&&y&&y.type==="function")a=t[d],e.push({type:"word",sourceIndex:d-k.length,sourceEndIndex:d+a.length,value:a}),d+=1,p=t.charCodeAt(d);else if(p===vr||p===Ul||p===Vl)a=t[d],e.push({type:"div",sourceIndex:d-k.length,sourceEndIndex:d+a.length,value:a,before:k,after:""}),k="",d+=1,p=t.charCodeAt(d);else if(Bl===p){i=d;do i+=1,p=t.charCodeAt(i);while(p<=32);if(f=d,a={type:"function",sourceIndex:d-w.length,value:w,before:t.slice(f+1,i)},d=i,w==="url"&&p!==Ts&&p!==jl){i-=1;do if(o=!1,i=t.indexOf(")",i+1),~i)for(l=i;t.charCodeAt(l-1)===zl;)l-=1,o=!o;else t+=")",i=t.length-1,a.unclosed=!0;while(o);c=i;do c-=1,p=t.charCodeAt(c);while(p<=32);f<c?(d!==c+1?a.nodes=[{type:"word",sourceIndex:d,sourceEndIndex:c+1,value:t.slice(d,c+1)}]:a.nodes=[],a.unclosed&&c+1!==i?(a.after="",a.nodes.push({type:"space",sourceIndex:c+1,sourceEndIndex:i,value:t.slice(c+1,i)})):(a.after=t.slice(c+1,i),a.sourceEndIndex=i)):(a.after="",a.nodes=[]),d=i+1,a.sourceEndIndex=a.unclosed?i:d,p=t.charCodeAt(d),e.push(a)}else v+=1,a.after="",a.sourceEndIndex=d+1,e.push(a),b.push(a),e=a.nodes=[],y=a;w=""}else if(Fl===p&&v)d+=1,p=t.charCodeAt(d),y.after=S,y.sourceEndIndex+=S.length,S="",v-=1,b[b.length-1].sourceEndIndex=d,b.pop(),y=bLine truncated
`),console.time("JIT TOTAL"),t},async function(t,i){e=Py(t,i)??e;let n=Il(e);if(t.type==="document"){let s=t.nodes.filter(a=>a.type==="root");for(let a of s)a.type==="root"&&await $s(n)(a,i);return}await $s(n)(t,i)},Ze.DEBUG&&function(t){return console.timeEnd("JIT TOTAL"),console.log(`
`),t}].filter(Boolean)}};Jl.exports.postcss=!0});var $y=x((y$,qy)=>{u();qy.exports=Dy()});var eu=x((b$,Ly)=>{u();Ly.exports=()=>["and_chr 114","and_uc 15.5","chrome 114","chrome 113","chrome 109","edge 114","firefox 114","ios_saf 16.5","ios_saf 16.4","ios_saf 16.3","ios_saf 16.1","opera 99","safari 16.5","samsung 21"]});var Ls={};Ge(Ls,{agents:()=>vO,feature:()=>xO});function xO(){return{status:"cr",title:"CSS Feature Queries",stats:{ie:{"6":"n","7":"n","8":"n","9":"n","10":"n","11":"n","5.5":"n"},edge:{"12":"y","13":"y","14":"y","15":"y","16":"y","17":"y","18":"y","79":"y","80":"y","81":"y","83":"y","84":"y","85":"y","86":"y","87":"y","88":"y","89":"y","90":"y","91":"y","92":"y","93":"y","94":"y","95":"y","96":"y","97":"y","98":"y","99":"y","100":"y","101":"y","102":"y","103":"y","104":"y","105":"y","106":"y","107":"y","108":"y","109":"y","110":"y","111":"y","112":"y","113":"y","114":"y"},firefox:{"2":"n","3":"n","4":"n","5":"n","6":"n","7":"n","8":"n","9":"n","10":"n","11":"n","12":"n","13":"n","14":"n","15":"n","16":"n","17":"n","18":"n","19":"n","20":"n","21":"n","22":"y","23":"y","24":"y","25":"y","26":"y","27":"y","28":"y","29":"y","30":"y","31":"y","32":"y","33":"y","34":"y","35":"y","36":"y","37":"y","38":"y","39":"y","40":"y","41":"y","42":"y","43":"y","44":"y","45":"y","46":"y","47":"y","48":"y","49":"y","50":"y","51":"y","52":"y","53":"y","54":"y","55":"y","56":"y","57":"y","58":"y","59":"y","60":"y","61":"y","62":"y","63":"y","64":"y","65":"y","66":"y","67":"y","68":"y","69":"y","70":"y","71":"y","72":"y","73":"y","74":"y","75":"y","76":"y","77":"y","78":"y","79":"y","80":"y","81":"y","82":"y","83":"y","84":"y","85":"y","86":"y","87":"y","88":"y","89":"y","90":"y","91":"y","92":"y","93":"y","94":"y","95":"y","96":"y","97":"y","98":"y","99":"y","100":"y","101":"y","102":"y","103":"y","104":"y","105":"y","106":"y","107":"y","108":"y","109":"y","110":"y","111":"y","112":"y","113":"y","114":"y","115":"y","116":"y","117":"y","3.5":"n","3.6":"n"},chrome:{"4":"n","5":"n","6":"n","7":"n","8":"n","9":"n","10":"n","11":"n","12":"n","13":"n","14":"n","15":"n","16":"n","17":"n","18":"n","19":"n","20":"n","21":"n","22":"n","23":"n","24":"n","25":"n","26":"n","27":"n","28":"y","29":"y","30":"y","31":"y","32":"y","33":"y","34":"y","35":"y","36":"y","37":"y","38":"y","39":"y","40":"y","41":"y","42":"y","43":"y","44":"y","45":"y","46":"y","47":"y","48":"y","49":"y","50":"y","51":"y","52":"y","53":"y","54":"y","55":"y","56":"y","57":"y","58":"y","59":"y","60":"y","61":"y","62":"y","63":"y","64":"y","65":"y","66":"y","67":"y","68":"y","69":"y","70":"y","71":"y","72":"y","73":"y","74":"y","75":"y","76":"y","77":"y","78":"y","79":"y","80":"y","81":"y","83":"y","84":"y","85":"y","86":"y","87":"y","88":"y","89":"y","90":"y","91":"y","92":"y","93":"y","94":"y","95":"y","96":"y","97":"y","98":"y","99":"y","100":"y","101":"y","102":"y","103":"y","104":"y","105":"y","106":"y","107":"y","108":"y","109":"y","110":"y","111":"y","112":"y","113":"y","114":"y","115":"y","116":"y","117":"y"},safari:{"4":"n","5":"n","6":"n","7":"n","8":"n","9":"y","10":"y","11":"y","12":"y","13":"y","14":"y","15":"y","17":"y","9.1":"y","10.1":"y","11.1":"y","12.1":"y","13.1":"y","14.1":"y","15.1":"y","15.2-15.3":"y","15.4":"y","15.5":"y","15.6":"y","16.0":"y","16.1":"y","16.2":"y","16.3":"y","16.4":"y","16.5":"y","16.6":"y",TP:"y","3.1":"n","3.2":"n","5.1":"n","6.1":"n","7.1":"n"},opera:{"9":"n","11":"n","12":"n","15":"y","16":"y","17":"y","18":"y","19":"y","20":"y","21":"y","22":"y","23":"y","24":"y","25":"y","26":"y","27":"y","28":"y","29":"y","30":"y","31":"y","32":"y","33":"y","34":"y","35":"y","36":"y","37":"y","38":"y","39":"y","40":"y","41":"y","42":"y","43":"y","44":"y","45":"y","46":"y","47":"y","48":"y","49":"y","50":"y","51":"y","52":"y","53":"y","54":"y","55":"y","56":"y","57":"y","58":"y","60":"y","62":"y","63":"y","64":"y","65":"y","66":"y","67":"y","68":"y","69":"y","70":"y","71":"y","72":"y","73":"y","74":"y","75":"y","76":"y","77":"y","78":"y","79":"y","80":"y","81":"y","82":"y","83":"y","84":"y","85":"y","86":"y","87":"y","88":"y","89":"y","90":"y","91":"y","92":"y","93":"y","94":"y","95":"y","96":"y","97":"y","98":"y","99":"y","100":"y","12.1":"y","9.5-9.6":"n","10.0-10.1":"n","10.5":"n","10.6":"n","11.1":"n","11.5":"n","11.6":"n"},ios_saf:{"8":"n","17":"y","9.0-9.2":"y","9.3":"y","10.0-10.2":"y","10.3":"y","11.0-11.2":"y","11.3-11.4":"y","12.0-12.1":"y","12.2-12.5":"y","13.0-13.1":"y","13.2":"y","13.3":"y","13.4-13.7":"y","14.0-14.4":"y","14.5-14.8":"y","15.0-15.1":"y","15.2-15.3":"y","15.4":"y","15.5":"y","15.6":"y","16.0":"y","16.1":"y","16.2":"y","16.3":"y","16.4":"y","16.5":"y","16.6":"y","3.2":"n","4.0-4.1":"n","4.2-4.3":"n","5.0-5.1":"n","6.0-6.1":"n","7.0-7.1":"n","8.1-8.4":"n"},op_mini:{all:"y"},android:{"3":"n","4":"n","114":"y","4.4":"y","4.4.3-4.4.4":"y","2.1":"n","2.2":"n","2.3":"n","4.1":"n","4.2-4.3":"n"},bb:{"7":"n","10":"n"},op_mob:{"10":"n","11":"n","12":"n","73":"y","11.1":"n","11.5":"n","12.1":"n"},and_chr: Line truncated
`)),e._autoprefixerCascade}maxPrefixed(e,t){if(t._autoprefixerMax)return t._autoprefixerMax;let i=0;for(let n of e)n=Vy.removeNote(n),n.length>i&&(i=n.length);return t._autoprefixerMax=i,t._autoprefixerMax}calcBefore(e,t,i=""){let s=this.maxPrefixed(e,t)-Vy.removeNote(i).length,a=t.raw("before");return s>0&&(a+=Array(s).fill(" ").join("")),a}restoreBefore(e){let t=e.raw("before").split(`
`),i=t[t.length-1];this.all.group(e).up(n=>{let s=n.raw("before").split(`
`),a=s[s.length-1];a.length<i.length&&(i=a)}),t[t.length-1]=i,e.raws.before=t.join(`
`)}insert(e,t,i){let n=this.set(this.clone(e),t);if(!(!n||e.parent.some(a=>a.prop===n.prop&&a.value===n.value)))return this.needCascade(e)&&(n.raws.before=this.calcBefore(i,e,t)),e.parent.insertBefore(e,n)}isAlready(e,t){let i=this.all.group(e).up(n=>n.prop===t);return i||(i=this.all.group(e).down(n=>n.prop===t)),i}add(e,t,i,n){let s=this.prefixed(e.prop,t);if(!(this.isAlready(e,s)||this.otherPrefixes(e.value,t)))return this.insert(e,t,i,n)}process(e,t){if(!this.needCascade(e)){super.process(e,t);return}let i=super.process(e,t);!i||!i.length||(this.restoreBefore(e),e.raws.before=this.calcBefore(i,e))}old(e,t){return[this.prefixed(e,t)]}};Wy.exports=Hy});var Qy=x((_$,Gy)=>{u();Gy.exports=function r(e){return{mul:t=>new r(e*t),div:t=>new r(e/t),simplify:()=>new r(e),toString:()=>e.toString()}}});var Xy=x((E$,Ky)=>{u();var OO=Qy(),TO=kr(),iu=_e(),RO=/(min|max)-resolution\s*:\s*\d*\.?\d+(dppx|dpcm|dpi|x)/gi,PO=/(min|max)-resolution(\s*:\s*)(\d*\.?\d+)(dppx|dpcm|dpi|x)/i,Yy=class extends TO{prefixName(e,t){return e==="-moz-"?t+"--moz-device-pixel-ratio":e+t+"-device-pixel-ratio"}prefixQuery(e,t,i,n,s){return n=new OO(n),s==="dpi"?n=n.div(96):s==="dpcm"&&(n=n.mul(2.54).div(96)),n=n.simplify(),e==="-o-"&&(n=n.n+"/"+n.d),this.prefixName(e,t)+i+n}clean(e){if(!this.bad){this.bad=[];for(let t of this.prefixes)this.bad.push(this.prefixName(t,"min")),this.bad.push(this.prefixName(t,"max"))}e.params=iu.editList(e.params,t=>t.filter(i=>this.bad.every(n=>!i.includes(n))))}process(e){let t=this.parentPrefix(e),i=t?[t]:this.prefixes;e.params=iu.editList(e.params,(n,s)=>{for(let a of n){if(!a.includes("min-resolution")&&!a.includes("max-resolution")){s.push(a);continue}for(let o of i){let l=a.replace(RO,c=>{let f=c.match(PO);return this.prefixQuery(o,f[1],f[2],f[3],f[4])});s.push(l)}s.push(a)}return iu.uniq(s)})}};Ky.exports=Yy});var Jy=x((O$,Zy)=>{u();var nu="(".charCodeAt(0),su=")".charCodeAt(0),Bs="'".charCodeAt(0),au='"'.charCodeAt(0),ou="\\".charCodeAt(0),Sr="/".charCodeAt(0),lu=",".charCodeAt(0),uu=":".charCodeAt(0),Fs="*".charCodeAt(0),IO="u".charCodeAt(0),DO="U".charCodeAt(0),qO="+".charCodeAt(0),$O=/^[a-f0-9?-]+$/i;Zy.exports=function(r){for(var e=[],t=r,i,n,s,a,o,l,c,f,d=0,p=t.charCodeAt(d),h=t.length,b=[{nodes:e}],v=0,y,w="",k="",S="";d<h;)if(p<=32){i=d;do i+=1,p=t.charCodeAt(i);while(p<=32);a=t.slice(d,i),s=e[e.length-1],p===su&&v?S=a:s&&s.type==="div"?(s.after=a,s.sourceEndIndex+=a.length):p===lu||p===uu||p===Sr&&t.charCodeAt(i+1)!==Fs&&(!y||y&&y.type==="function"&&y.value!=="calc")?k=a:e.push({type:"space",sourceIndex:d,sourceEndIndex:i,value:a}),d=i}else if(p===Bs||p===au){i=d,n=p===Bs?"'":'"',a={type:"string",sourceIndex:d,quote:n};do if(o=!1,i=t.indexOf(n,i+1),~i)for(l=i;t.charCodeAt(l-1)===ou;)l-=1,o=!o;else t+=n,i=t.length-1,a.unclosed=!0;while(o);a.value=t.slice(d+1,i),a.sourceEndIndex=a.unclosed?i:i+1,e.push(a),d=i+1,p=t.charCodeAt(d)}else if(p===Sr&&t.charCodeAt(d+1)===Fs)i=t.indexOf("*/",d),a={type:"comment",sourceIndex:d,sourceEndIndex:i+2},i===-1&&(a.unclosed=!0,i=t.length,a.sourceEndIndex=i),a.value=t.slice(d+2,i),e.push(a),d=i+2,p=t.charCodeAt(d);else if((p===Sr||p===Fs)&&y&&y.type==="function"&&y.value==="calc")a=t[d],e.push({type:"word",sourceIndex:d-k.length,sourceEndIndex:d+a.length,value:a}),d+=1,p=t.charCodeAt(d);else if(p===Sr||p===lu||p===uu)a=t[d],e.push({type:"div",sourceIndex:d-k.length,sourceEndIndex:d+a.length,value:a,before:k,after:""}),k="",d+=1,p=t.charCodeAt(d);else if(nu===p){i=d;do i+=1,p=t.charCodeAt(i);while(p<=32);if(f=d,a={type:"function",sourceIndex:d-w.length,value:w,before:t.slice(f+1,i)},d=i,w==="url"&&p!==Bs&&p!==au){i-=1;do if(o=!1,i=t.indexOf(")",i+1),~i)for(l=i;t.charCodeAt(l-1)===ou;)l-=1,o=!o;else t+=")",i=t.length-1,a.unclosed=!0;while(o);c=i;do c-=1,p=t.charCodeAt(c);while(p<=32);f<c?(d!==c+1?a.nodes=[{type:"word",sourceIndex:d,sourceEndIndex:c+1,value:t.slice(d,c+1)}]:a.nodes=[],a.unclosed&&c+1!==i?(a.after="",a.nodes.push({type:"space",sourceIndex:c+1,sourceEndIndex:i,value:t.slice(c+1,i)})):(a.after=t.slice(c+1,i),a.sourceEndIndex=i)):(a.after="",a.nodes=[]),d=i+1,a.sourceEndIndex=a.unclosed?i:d,p=t.charCodeAt(d),e.push(a)}else v+=1,a.after="",a.sourceEndIndex=d+1,e.push(a),b.push(a),e=a.nodes=[],y=a;w=""}else if(su===p&&v)d+=1,p=t.charCodeAt(d),y.after=S,y.sourceEndIndex+=S.length,S="",v-=1,b[b.length-1].sourceEndIndex=d,b.pop(),y=b[v],e=y.nodes;else{i=d;do p===ou&&(i+=1),i+=1,p=t.charCodeAt(i);while(i<h&&!(p<=32||p===Bs||p===au||p===lu||p===uu||p===Sr||p===nu||p===Fs&&y&&y.type==="function"&&y.value==="calc"||p===Sr&&y.type==="function"&&y.value==="calc"||p===su&&v));a=t.slice(d,i),nu===p?w=a:(IO===a.charCodeAt(0)||DO===a.charCodeAt(0))&&qO===a.charCodeAt(1)&&$O.test(a.slice(2))?e.push({type:"unicode-range",sourceIndex:d,sourceEndIndex:i,value:a}):e.push({type:"word",sourceIndex:d,sourceEndIndex:i,value:a}),d=i}for(d=b.length-1;d;d-=1)b[d].unclosed=!0,b[d].sourceEndIndex=t.length;return b[0].nodes}});var tb=x((T$,eb)=>{u();eb.exports=function r(e,t,i){var n,s Line truncated
`)&&this.reduceSpaces(n),a.removeChild(s);return}}for(let l of this.prefixes.values("remove",o)){if(!l.check||!l.check(n.value))continue;if(o=l.unprefixed,this.prefixes.group(n).down(f=>f.value.includes(o))){a.removeChild(s);return}}})}withHackValue(e){return e.prop==="-webkit-background-clip"&&e.value==="text"}disabledValue(e,t){return this.gridStatus(e,t)===!1&&e.type==="decl"&&e.prop==="display"&&e.value.includes("grid")||this.prefixes.options.flexbox===!1&&e.type==="decl"&&e.prop==="display"&&e.value.includes("flex")||e.type==="decl"&&e.prop==="content"?!0:this.disabled(e,t)}disabledDecl(e,t){if(this.gridStatus(e,t)===!1&&e.type==="decl"&&(e.prop.includes("grid")||e.prop==="justify-items"))return!0;if(this.prefixes.options.flexbox===!1&&e.type==="decl"){let i=["order","justify-content","align-items","align-content"];if(e.prop.includes("flex")||i.includes(e.prop))return!0}return this.disabled(e,t)}disabled(e,t){if(!e)return!1;if(e._autoprefixerDisabled!==void 0)return e._autoprefixerDisabled;if(e.parent){let n=e.prev();if(n&&n.type==="comment"&&XO.test(n.text))return e._autoprefixerDisabled=!0,e._autoprefixerSelfDisabled=!0,!0}let i=null;if(e.nodes){let n;e.each(s=>{s.type==="comment"&&/(!\s*)?autoprefixer:\s*(off|on)/i.test(s.text)&&(typeof n!="undefined"?t.warn("Second Autoprefixer control comment was ignored. Autoprefixer applies control comment to whole block, not to next rules.",{node:s}):n=/on/i.test(s.text))}),n!==void 0&&(i=!n)}if(!e.nodes||i===null)if(e.parent){let n=this.disabled(e.parent,t);e.parent._autoprefixerSelfDisabled===!0?i=!1:i=n}else i=!1;return e._autoprefixerDisabled=i,i}reduceSpaces(e){let t=!1;if(this.prefixes.group(e).up(()=>(t=!0,!0)),t)return;let i=e.raw("before").split(`
`),n=i[i.length-1].length,s=!1;this.prefixes.group(e).down(a=>{i=a.raw("before").split(`
`);let o=i.length-1;i[o].length>n&&(s===!1&&(s=i[o].length-n),i[o]=i[o].slice(0,-s),a.raws.before=i.join(`
`))})}displayType(e){for(let t of e.parent.nodes)if(t.prop==="display"){if(t.value.includes("flex"))return"flex";if(t.value.includes("grid"))return"grid"}return!1}gridStatus(e,t){if(!e)return!1;if(e._autoprefixerGridStatus!==void 0)return e._autoprefixerGridStatus;let i=null;if(e.nodes){let n;e.each(s=>{if(s.type==="comment"&&ZO.test(s.text)){let a=/:\s*autoplace/i.test(s.text),o=/no-autoplace/i.test(s.text);typeof n!="undefined"?t.warn("Second Autoprefixer grid control comment was ignored. Autoprefixer applies control comments to the whole block, not to the next rules.",{node:s}):a?n="autoplace":o?n=!0:n=/on/i.test(s.text)}}),n!==void 0&&(i=n)}if(e.type==="atrule"&&e.name==="supports"){let n=e.params;n.includes("grid")&&n.includes("auto")&&(i=!1)}if(!e.nodes||i===null)if(e.parent){let n=this.gridStatus(e.parent,t);e.parent._autoprefixerSelfDisabled===!0?i=!1:i=n}else typeof this.prefixes.options.grid!="undefined"?i=this.prefixes.options.grid:typeof m.env.AUTOPREFIXER_GRID!="undefined"?m.env.AUTOPREFIXER_GRID==="autoplace"?i="autoplace":i=!0:i=!1;return e._autoprefixerGridStatus=i,i}};Sb.exports=kb});var Cb=x((N$,Ab)=>{u();Ab.exports={A:{A:{"2":"K E F G A B JC"},B:{"1":"C L M H N D O P Q R S T U V W X Y Z a b c d e f g h i j n o p q r s t u v w x y z I"},C:{"1":"2 3 4 5 6 7 8 9 AB BB CB DB EB FB GB HB IB JB KB LB MB NB OB PB QB RB SB TB UB VB WB XB YB ZB aB bB cB 0B dB 1B eB fB gB hB iB jB kB lB mB nB oB m pB qB rB sB tB P Q R 2B S T U V W X Y Z a b c d e f g h i j n o p q r s t u v w x y z I uB 3B 4B","2":"0 1 KC zB J K E F G A B C L M H N D O k l LC MC"},D:{"1":"8 9 AB BB CB DB EB FB GB HB IB JB KB LB MB NB OB PB QB RB SB TB UB VB WB XB YB ZB aB bB cB 0B dB 1B eB fB gB hB iB jB kB lB mB nB oB m pB qB rB sB tB P Q R S T U V W X Y Z a b c d e f g h i j n o p q r s t u v w x y z I uB 3B 4B","2":"0 1 2 3 4 5 6 7 J K E F G A B C L M H N D O k l"},E:{"1":"G A B C L M H D RC 6B vB wB 7B SC TC 8B 9B xB AC yB BC CC DC EC FC GC UC","2":"0 J K E F NC 5B OC PC QC"},F:{"1":"1 2 3 4 5 6 7 8 9 H N D O k l AB BB CB DB EB FB GB HB IB JB KB LB MB NB OB PB QB RB SB TB UB VB WB XB YB ZB aB bB cB dB eB fB gB hB iB jB kB lB mB nB oB m pB qB rB sB tB P Q R 2B S T U V W X Y Z a b c d e f g h i j wB","2":"G B C VC WC XC YC vB HC ZC"},G:{"1":"D fC gC hC iC jC kC lC mC nC oC pC qC rC sC tC 8B 9B xB AC yB BC CC DC EC FC GC","2":"F 5B aC IC bC cC dC eC"},H:{"1":"uC"},I:{"1":"I zC 0C","2":"zB J vC wC xC yC IC"},J:{"2":"E A"},K:{"1":"m","2":"A B C vB HC wB"},L:{"1":"I"},M:{"1":"uB"},N:{"2":"A B"},O:{"1":"xB"},P:{"1":"J k l 1C 2C 3C 4C 5C 6B 6C 7C 8C 9C AD yB BD CD DD"},Q:{"1":"7B"},R:{"1":"ED"},S:{"1":"FD GD"}},B:4,C:"CSS Feature Queries"}});var Tb=x((B$,Ob)=>{u();function _b(r){return r[r.length-1]}var Eb={parse(r){let e=[""],t=[e];for(let i of r){if(i==="("){e=[""],_b(t).push(e),t.push(e);continue}if(i===")"){t.pop(),e=_b(t),e.push("");continue}e[e.length-1]+=i}return t[0]},stringify(r){let e="";for(let t of r){if(typeof t=="object"){e+=`(${Eb.stringify(t)})`;continue}e+=t}return e}};Ob.exports=Eb});var qb=x((F$,Db)=>{u();var tT=Cb(),{feature:rT}=(Ms(),Ls),{parse:iT}=$e(),nT=Nt(),du=Tb(),sT=He(),aT=_e(),Rb=rT(tT),Pb=[];for(let r in Rb.stats){let e=Rb.stats[r];for(let t in e){let i=e[t];/y/.test(i)&&Pb.push(r+" "+t)}}var Ib=class{constructor(e,t){this.Prefixes=e,this.all=t}prefixer(){if(this.prefixerCache)return this.prefixerCache;let e=this.all.browsers.selected.filter(i=>Pb.includes(i)),t=new nT(this.all.browsers.data,e,this.all.options);return this.prefixerCache=new this.Prefixes(this.all.data,t,this.all.options),this.prefixerCache}parse(e){let t=e.split(":"),i=t[0],n=t[1];return n||(n=""),[i.trim(),n.trim()]}virtual(e){let[t,i]=this.parse(e),n=iT("a{}").first;return n.append({prop:t,value:i,raws:{before:""}}),n}prefixed(e){let t=this.virtual(e);if(this.disabled(t.first))return t.nodes;let i={warn:()=>null},n=this.prefixer().add[t.first.prop];n&&n.process&&n.process(t.first,i);for(let s of t.nodes){for(let a of this.prefixer().values("add",t.first.prop))a.process(s);sT.save(this.all,s)}return t.nodes}isNot(e){return typeof e=="string"&&/not\s*/i.test(e)}isOr(e){return typeof e=="string"&&/\s*or\s*/i.test(e)}isProp(e){return typeof e=="object"&&e.length===1&&typeof e[0]=="string"}isHack(e,t){return!new RegExp(`(\\(|\\s)${aT.escapeRegexp(t)}:`).test(e)}toRemove(e,t){let[i,n]=this.parse(e),s=this.all.unprefixed(i),a=this.all.cleaner();if(a.remove[i]&&a.remove[i].remove&&!this.isHack(t,s))return!0;for(let o of a.values("remove",s))if(o.check(n))return!0;return!1}remove(e,t){let i=0;for(;i<e.length;){if(!this.isNot(e[i-1])&&this.isProp(e[i])&&this.isOr(e[i+1])){if(this.toRemove(e[i][0],t)){e.splice(i,2);continue}i+=2;continue}typeof e[i]=="object"&&(e[i]=this.remove(e[i],t)),i+=1}return e}cleanBrackets(e){return e.map(t=>typeof t!="object"?t:t.length===1&&typeof t[0]=="object"?this.cleanBrackets(t[0]):this.cleanBrackets(t))}convert(e){let t=[""];for(let i of e)t.push([`${i.prop}: ${i.value}`]),t.push(" or ");return t[t.length-1]="",t}n Line truncated
Replace Autoprefixer \`browsers\` option to Browserslist config.
Use \`browserslist\` key in \`package.json\` or \`.browserslistrc\` file.
Using \`browsers\` option can cause errors. Browserslist config can
be used for Babel, Autoprefixer, postcss-normalize and other tools.
If you really need to use option, rename it to \`overrideBrowserslist\`.
Learn more at:
https://github.com/browserslist/browserslist#readme
https://twitter.com/browserslist
`;function aP(r){return Object.prototype.toString.apply(r)==="[object Object]"}var df=new Map;function oP(r,e){e.browsers.selected.length!==0&&(e.add.selectors.length>0||Object.keys(e.add).length>2||r.warn(`Autoprefixer target browsers do not need any prefixes.You do not need Autoprefixer anymore.
Check your Browserslist config to be sure that your targets are set up correctly.
Learn more at:
https://github.com/postcss/autoprefixer#readme
https://github.com/browserslist/browserslist#readme
`))}Cv.exports=Lr;function Lr(...r){let e;if(r.length===1&&aP(r[0])?(e=r[0],r=void 0):r.length===0||r.length===1&&!r[0]?r=void 0:r.length<=2&&(Array.isArray(r[0])||!r[0])?(e=r[1],r=r[0]):typeof r[r.length-1]=="object"&&(e=r.pop()),e||(e={}),e.browser)throw new Error("Change `browser` option to `overrideBrowserslist` in Autoprefixer");if(e.browserslist)throw new Error("Change `browserslist` option to `overrideBrowserslist` in Autoprefixer");e.overrideBrowserslist?r=e.overrideBrowserslist:e.browsers&&(typeof console!="undefined"&&console.warn&&(pf.red?console.warn(pf.red(Av.replace(/`[^`]+`/g,n=>pf.yellow(n.slice(1,-1))))):console.warn(Av)),r=e.browsers);let t={ignoreUnknownVersions:e.ignoreUnknownVersions,stats:e.stats,env:e.env};function i(n){let s=Sv,a=new rP(s.browsers,r,n,t),o=a.selected.join(", ")+JSON.stringify(e);return df.has(o)||df.set(o,new iP(s.prefixes,a,e)),df.get(o)}return{postcssPlugin:"autoprefixer",prepare(n){let s=i({from:n.opts.from,env:e.env});return{OnceExit(a){oP(n,s),e.remove!==!1&&s.processor.remove(a,n),e.add!==!1&&s.processor.add(a,n)}}},info(n){return n=n||{},n.from=n.from||m.cwd(),sP(i(n))},options:e,browsers:r}}Lr.postcss=!0;Lr.data=Sv;Lr.defaults=eP.defaults;Lr.info=()=>Lr().info()});var Ov=x((ZL,Ev)=>{u();Ev.exports={aqua:/#00ffff(ff)?(?!\w)|#0ff(f)?(?!\w)/gi,azure:/#f0ffff(ff)?(?!\w)/gi,beige:/#f5f5dc(ff)?(?!\w)/gi,bisque:/#ffe4c4(ff)?(?!\w)/gi,black:/#000000(ff)?(?!\w)|#000(f)?(?!\w)/gi,blue:/#0000ff(ff)?(?!\w)|#00f(f)?(?!\w)/gi,brown:/#a52a2a(ff)?(?!\w)/gi,coral:/#ff7f50(ff)?(?!\w)/gi,cornsilk:/#fff8dc(ff)?(?!\w)/gi,crimson:/#dc143c(ff)?(?!\w)/gi,cyan:/#00ffff(ff)?(?!\w)|#0ff(f)?(?!\w)/gi,darkblue:/#00008b(ff)?(?!\w)/gi,darkcyan:/#008b8b(ff)?(?!\w)/gi,darkgrey:/#a9a9a9(ff)?(?!\w)/gi,darkred:/#8b0000(ff)?(?!\w)/gi,deeppink:/#ff1493(ff)?(?!\w)/gi,dimgrey:/#696969(ff)?(?!\w)/gi,gold:/#ffd700(ff)?(?!\w)/gi,green:/#008000(ff)?(?!\w)/gi,grey:/#808080(ff)?(?!\w)/gi,honeydew:/#f0fff0(ff)?(?!\w)/gi,hotpink:/#ff69b4(ff)?(?!\w)/gi,indigo:/#4b0082(ff)?(?!\w)/gi,ivory:/#fffff0(ff)?(?!\w)/gi,khaki:/#f0e68c(ff)?(?!\w)/gi,lavender:/#e6e6fa(ff)?(?!\w)/gi,lime:/#00ff00(ff)?(?!\w)|#0f0(f)?(?!\w)/gi,linen:/#faf0e6(ff)?(?!\w)/gi,maroon:/#800000(ff)?(?!\w)/gi,moccasin:/#ffe4b5(ff)?(?!\w)/gi,navy:/#000080(ff)?(?!\w)/gi,oldlace:/#fdf5e6(ff)?(?!\w)/gi,olive:/#808000(ff)?(?!\w)/gi,orange:/#ffa500(ff)?(?!\w)/gi,orchid:/#da70d6(ff)?(?!\w)/gi,peru:/#cd853f(ff)?(?!\w)/gi,pink:/#ffc0cb(ff)?(?!\w)/gi,plum:/#dda0dd(ff)?(?!\w)/gi,purple:/#800080(ff)?(?!\w)/gi,red:/#ff0000(ff)?(?!\w)|#f00(f)?(?!\w)/gi,salmon:/#fa8072(ff)?(?!\w)/gi,seagreen:/#2e8b57(ff)?(?!\w)/gi,seashell:/#fff5ee(ff)?(?!\w)/gi,sienna:/#a0522d(ff)?(?!\w)/gi,silver:/#c0c0c0(ff)?(?!\w)/gi,skyblue:/#87ceeb(ff)?(?!\w)/gi,snow:/#fffafa(ff)?(?!\w)/gi,tan:/#d2b48c(ff)?(?!\w)/gi,teal:/#008080(ff)?(?!\w)/gi,thistle:/#d8bfd8(ff)?(?!\w)/gi,tomato:/#ff6347(ff)?(?!\w)/gi,violet:/#ee82ee(ff)?(?!\w)/gi,wheat:/#f5deb3(ff)?(?!\w)/gi,white:/#ffffff(ff)?(?!\w)|#fff(f)?(?!\w)/gi}});var Rv=x((JL,Tv)=>{u();var hf=Ov(),mf={whitespace:/\s+/g,urlHexPairs:/%[\dA-F]{2}/g,quotes:/"/g};function lP(r){return r.trim().replace(mf.whitespace," ")}function uP(r){return encodeURIComponent(r).replace(mf.urlHexPairs,cP)}function fP(r){return Object.keys(hf).forEach(function(e){hf[e].test(r)&&(r=r.replace(hf[e],e))}),r}function cP(r){switch(r){case"%20":return" ";case"%3D":return"=";case"%3A":return":";case"%2F":return"/";default:return r.toLowerCase()}}function gf(r){if(typeof r!="string")throw new TypeError("Expected a string, but received "+typeof r);r.charCodeAt(0)===65279&&(r=r.slice(1));var e=fP(lP(r)).replace(mf.quotes,"'");return"data:image/svg+xml,"+uP(e)}gf.toSrcset=function(e){return gf(e).replace(/ /g,"%20")};Tv.exports=gf});var yf={};Ge(yf,{default:()=>pP});var Pv,pP,bf=P(()=>{u();tn();Pv=pe(on()),pP=At(Pv.default.theme)});var Lv=x((tM,$v)=>{u();var Vs=Rv(),dP=(ys(),gs).default,Iv=(bf(),yf).default,jt=(Zi(),Xs).default,[hP,{lineHeight:mP}]=Iv.fontSize.base,{spacing:vt,borderWidth:Dv,borderRadius:qv}=Iv;function Xt(r,e){return r.replace("<alpha-value>",`var(${e}, 1)`)}var gP=dP.withOptions(function(r={strategy:void 0}){return function({addBase:e,addComponents:t,theme:i}){function n(l,c){let f=i(l);return!f||f.includes("var(")?c:f.replace("<alpha-value>","1")}let s=r.strategy===void 0?["base","class"]:[r.strategy],a=[{base:["[type='text']","input:where(:not([type]))","[type='email']","[type='url']","[type='password']","[type='number']","[type='date']","[type='datetime-local']","[type='month']","[type='search']","[type='tel']","[type='time']","[type='week']","[multiple]","textarea","select"],class:[".form-input",".form-textarea",".form-select",".form-multiselect"],styles:{appearance:"none","background-color":"#fff","border-color":Xt(i("colors.gray.500",jt.gray[500]),"--tw-border-opacity"),"border-width":Dv.DEFAULT,"border-radius":qv.none,"padding-top":vt[2],"padding-right":vt[3],"padding-bottom":vt[2],"padding-left":vt[3],"font-size":hP,"line-height":mP,"--tw-shadow":"0Line truncated
/*!
* fill-range <https://github.com/jonschlinkert/fill-range>
*
* Copyright (c) 2014-present, Jon Schlinkert.
* Licensed under the MIT License.
*/
/*!
* is-number <https://github.com/jonschlinkert/is-number>
*
* Copyright (c) 2014-present, Jon Schlinkert.
* Released under the MIT License.
*/
/*!
* to-regex-range <https://github.com/micromatch/to-regex-range>
*
* Copyright (c) 2015-present, Jon Schlinkert.
* Released under the MIT License.
*/
/*! https://mths.be/cssesc v3.0.0 by @mathias */
+179
View File
@@ -0,0 +1,179 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pixa - URL Generator</title>
<script src="/static/tailwind.js"></script>
</head>
<body class="bg-gray-100 min-h-screen">
<div class="max-w-2xl mx-auto py-8 px-4">
<div class="flex justify-between items-center mb-8">
<h1 class="text-2xl font-bold text-gray-800">Pixa URL Generator</h1>
<a href="/logout" class="text-sm text-gray-600 hover:text-gray-800 underline">
Logout
</a>
</div>
{{if .GeneratedURL}}
<div class="bg-green-50 border border-green-200 rounded-lg p-4 mb-6">
<h2 class="text-sm font-medium text-green-800 mb-2">Generated URL</h2>
<div class="flex gap-2">
<input
type="text"
readonly
value="{{.GeneratedURL}}"
id="generated-url"
class="flex-1 px-3 py-2 bg-white border border-green-300 rounded-md text-sm font-mono"
onclick="this.select()"
>
<button
onclick="navigator.clipboard.writeText(document.getElementById('generated-url').value)"
class="px-3 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 text-sm"
>
Copy
</button>
</div>
<p class="text-xs text-green-600 mt-2">
Expires: {{.ExpiresAt}}
</p>
</div>
{{end}}
{{if .Error}}
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-6">
{{.Error}}
</div>
{{end}}
<form method="POST" action="/generate" class="bg-white rounded-lg shadow-md p-6 space-y-4">
<div>
<label for="url" class="block text-sm font-medium text-gray-700 mb-1">
Source URL
</label>
<input
type="url"
id="url"
name="url"
required
placeholder="https://example.com/image.jpg"
value="{{.FormURL}}"
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label for="width" class="block text-sm font-medium text-gray-700 mb-1">
Width
</label>
<input
type="number"
id="width"
name="width"
min="0"
max="10000"
value="{{if .FormWidth}}{{.FormWidth}}{{else}}0{{end}}"
placeholder="0 = original"
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
</div>
<div>
<label for="height" class="block text-sm font-medium text-gray-700 mb-1">
Height
</label>
<input
type="number"
id="height"
name="height"
min="0"
max="10000"
value="{{if .FormHeight}}{{.FormHeight}}{{else}}0{{end}}"
placeholder="0 = original"
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label for="format" class="block text-sm font-medium text-gray-700 mb-1">
Format
</label>
<select
id="format"
name="format"
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="orig" {{if eq .FormFormat "orig"}}selected{{end}}>Original</option>
<option value="webp" {{if eq .FormFormat "webp"}}selected{{end}}>WebP</option>
<option value="jpeg" {{if eq .FormFormat "jpeg"}}selected{{end}}>JPEG</option>
<option value="png" {{if eq .FormFormat "png"}}selected{{end}}>PNG</option>
<option value="avif" {{if eq .FormFormat "avif"}}selected{{end}}>AVIF</option>
</select>
</div>
<div>
<label for="quality" class="block text-sm font-medium text-gray-700 mb-1">
Quality
</label>
<input
type="number"
id="quality"
name="quality"
min="1"
max="100"
value="{{if .FormQuality}}{{.FormQuality}}{{else}}85{{end}}"
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label for="fit" class="block text-sm font-medium text-gray-700 mb-1">
Fit Mode
</label>
<select
id="fit"
name="fit"
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="cover" {{if eq .FormFit "cover"}}selected{{end}}>Cover</option>
<option value="contain" {{if eq .FormFit "contain"}}selected{{end}}>Contain</option>
<option value="fill" {{if eq .FormFit "fill"}}selected{{end}}>Fill</option>
<option value="inside" {{if eq .FormFit "inside"}}selected{{end}}>Inside</option>
<option value="outside" {{if eq .FormFit "outside"}}selected{{end}}>Outside</option>
</select>
</div>
<div>
<label for="ttl" class="block text-sm font-medium text-gray-700 mb-1">
Expires In
</label>
<select
id="ttl"
name="ttl"
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="3600" {{if eq .FormTTL "3600"}}selected{{end}}>1 hour</option>
<option value="86400" {{if eq .FormTTL "86400"}}selected{{end}}>1 day</option>
<option value="604800" {{if eq .FormTTL "604800"}}selected{{end}}>1 week</option>
<option value="2592000" {{if or (eq .FormTTL "2592000") (eq .FormTTL "")}}selected{{end}}>30 days</option>
<option value="31536000" {{if eq .FormTTL "31536000"}}selected{{end}}>1 year</option>
</select>
</div>
</div>
<button
type="submit"
class="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors"
>
Generate Encrypted URL
</button>
</form>
<p class="text-xs text-gray-500 mt-4 text-center">
Generated URLs are encrypted and cannot be modified. They will expire at the specified time.
</p>
</div>
</body>
</html>
+44
View File
@@ -0,0 +1,44 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pixa - Login</title>
<script src="/static/tailwind.js"></script>
</head>
<body class="bg-gray-100 min-h-screen flex items-center justify-center">
<div class="bg-white p-8 rounded-lg shadow-md w-full max-w-md">
<h1 class="text-2xl font-bold text-gray-800 mb-6 text-center">Pixa Image Proxy</h1>
{{if .Error}}
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
{{.Error}}
</div>
{{end}}
<form method="POST" action="/" class="space-y-4">
<div>
<label for="key" class="block text-sm font-medium text-gray-700 mb-1">
Signing Key
</label>
<input
type="password"
id="key"
name="key"
required
autocomplete="current-password"
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="Enter your signing key"
>
</div>
<button
type="submit"
class="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors"
>
Login
</button>
</form>
</div>
</body>
</html>
+32
View File
@@ -0,0 +1,32 @@
// Package templates provides embedded HTML templates for the web UI.
package templates
import (
"embed"
"html/template"
"io"
"sync"
)
//go:embed *.html
var files embed.FS
//nolint:gochecknoglobals // intentional lazy initialization cache
var (
parsed *template.Template
parseOne sync.Once
)
// Get returns the parsed templates, parsing them on first call.
func Get() *template.Template {
parseOne.Do(func() {
parsed = template.Must(template.ParseFS(files, "*.html"))
})
return parsed
}
// Render renders a template by name to the given writer.
func Render(w io.Writer, name string, data any) error {
return Get().ExecuteTemplate(w, name, data)
}