Add basic webserver skeleton with healthcheck
This commit is contained in:
128
internal/config/config.go
Normal file
128
internal/config/config.go
Normal file
@@ -0,0 +1,128 @@
|
||||
// Package config provides application configuration using smartconfig.
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"git.eeqj.de/sneak/smartconfig"
|
||||
"go.uber.org/fx"
|
||||
"sneak.berlin/go/pixa/internal/globals"
|
||||
"sneak.berlin/go/pixa/internal/logger"
|
||||
)
|
||||
|
||||
// ConfigParams defines dependencies for Config.
|
||||
type ConfigParams struct {
|
||||
fx.In
|
||||
Globals *globals.Globals
|
||||
Logger *logger.Logger
|
||||
}
|
||||
|
||||
// Config holds application configuration values.
|
||||
type Config struct {
|
||||
Debug bool
|
||||
MaintenanceMode bool
|
||||
MetricsPassword string
|
||||
MetricsUsername string
|
||||
Port int
|
||||
SentryDSN string
|
||||
StateDir string
|
||||
DBURL string
|
||||
}
|
||||
|
||||
// New creates a new Config instance by loading configuration from file.
|
||||
func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) {
|
||||
log := params.Logger.Get()
|
||||
name := params.Globals.Appname
|
||||
|
||||
var sc *smartconfig.Config
|
||||
var err error
|
||||
|
||||
// Try loading config from standard locations
|
||||
configPaths := []string{
|
||||
fmt.Sprintf("/etc/%s/config.yml", name),
|
||||
fmt.Sprintf("/etc/%s/config.yaml", name),
|
||||
filepath.Join(os.Getenv("HOME"), ".config", name, "config.yml"),
|
||||
filepath.Join(os.Getenv("HOME"), ".config", name, "config.yaml"),
|
||||
"config.yml",
|
||||
"config.yaml",
|
||||
}
|
||||
|
||||
for _, path := range configPaths {
|
||||
if _, statErr := os.Stat(path); statErr == nil {
|
||||
sc, err = smartconfig.NewFromConfigPath(path)
|
||||
if err == nil {
|
||||
log.Info("loaded config file", "path", path)
|
||||
|
||||
break
|
||||
}
|
||||
log.Warn("failed to parse config file", "path", path, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
if sc == nil {
|
||||
log.Info("no config file found, using defaults")
|
||||
}
|
||||
|
||||
c := &Config{
|
||||
Debug: getBool(sc, "debug", false),
|
||||
MaintenanceMode: getBool(sc, "maintenance_mode", false),
|
||||
Port: getInt(sc, "port", 8080),
|
||||
StateDir: getString(sc, "state_dir", "./data"),
|
||||
SentryDSN: getString(sc, "sentry_dsn", ""),
|
||||
MetricsUsername: getString(sc, "metrics.username", ""),
|
||||
MetricsPassword: getString(sc, "metrics.password", ""),
|
||||
}
|
||||
|
||||
// Build DBURL from StateDir if not explicitly set
|
||||
c.DBURL = getString(sc, "db_url", "")
|
||||
if c.DBURL == "" {
|
||||
c.DBURL = fmt.Sprintf("file:%s/state.sqlite3?_journal_mode=WAL", c.StateDir)
|
||||
}
|
||||
|
||||
if c.Debug {
|
||||
params.Logger.EnableDebugLogging()
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func getString(sc *smartconfig.Config, key, defaultVal string) string {
|
||||
if sc == nil {
|
||||
return defaultVal
|
||||
}
|
||||
|
||||
val, err := sc.GetString(key)
|
||||
if err != nil {
|
||||
return defaultVal
|
||||
}
|
||||
|
||||
return val
|
||||
}
|
||||
|
||||
func getInt(sc *smartconfig.Config, key string, defaultVal int) int {
|
||||
if sc == nil {
|
||||
return defaultVal
|
||||
}
|
||||
|
||||
val, err := sc.GetInt(key)
|
||||
if err != nil {
|
||||
return defaultVal
|
||||
}
|
||||
|
||||
return val
|
||||
}
|
||||
|
||||
func getBool(sc *smartconfig.Config, key string, defaultVal bool) bool {
|
||||
if sc == nil {
|
||||
return defaultVal
|
||||
}
|
||||
|
||||
val, err := sc.GetBool(key)
|
||||
if err != nil {
|
||||
return defaultVal
|
||||
}
|
||||
|
||||
return val
|
||||
}
|
||||
113
internal/database/database.go
Normal file
113
internal/database/database.go
Normal file
@@ -0,0 +1,113 @@
|
||||
// Package database provides SQLite database access.
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"log/slog"
|
||||
|
||||
"go.uber.org/fx"
|
||||
"sneak.berlin/go/pixa/internal/config"
|
||||
"sneak.berlin/go/pixa/internal/logger"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// DatabaseParams defines dependencies for Database.
|
||||
type DatabaseParams struct {
|
||||
fx.In
|
||||
Logger *logger.Logger
|
||||
Config *config.Config
|
||||
}
|
||||
|
||||
// Database wraps the SQL database connection.
|
||||
type Database struct {
|
||||
db *sql.DB
|
||||
log *slog.Logger
|
||||
config *config.Config
|
||||
}
|
||||
|
||||
// New creates a new Database instance.
|
||||
func New(lc fx.Lifecycle, params DatabaseParams) (*Database, error) {
|
||||
s := &Database{
|
||||
log: params.Logger.Get(),
|
||||
config: params.Config,
|
||||
}
|
||||
|
||||
s.log.Info("Database instantiated")
|
||||
|
||||
lc.Append(fx.Hook{
|
||||
OnStart: func(ctx context.Context) error {
|
||||
s.log.Info("Database OnStart Hook")
|
||||
|
||||
return s.connect(ctx)
|
||||
},
|
||||
OnStop: func(ctx context.Context) error {
|
||||
s.log.Info("Database OnStop Hook")
|
||||
if s.db != nil {
|
||||
return s.db.Close()
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Database) connect(ctx context.Context) error {
|
||||
dbURL := s.config.DBURL
|
||||
|
||||
s.log.Info("connecting to database", "url", dbURL)
|
||||
|
||||
db, err := sql.Open("sqlite", dbURL)
|
||||
if err != nil {
|
||||
s.log.Error("failed to open database", "error", err)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
if err := db.PingContext(ctx); err != nil {
|
||||
s.log.Error("failed to ping database", "error", err)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
s.db = db
|
||||
s.log.Info("database connected")
|
||||
|
||||
return s.createSchema(ctx)
|
||||
}
|
||||
|
||||
func (s *Database) createSchema(ctx context.Context) error {
|
||||
// FIXME: Add actual schema for cache metadata
|
||||
schema := `
|
||||
CREATE TABLE IF NOT EXISTS cache_metadata (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
source_url TEXT NOT NULL UNIQUE,
|
||||
source_hash TEXT NOT NULL,
|
||||
content_type TEXT,
|
||||
fetched_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at DATETIME,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_cache_source_url ON cache_metadata(source_url);
|
||||
CREATE INDEX IF NOT EXISTS idx_cache_source_hash ON cache_metadata(source_hash);
|
||||
`
|
||||
|
||||
_, err := s.db.ExecContext(ctx, schema)
|
||||
if err != nil {
|
||||
s.log.Error("failed to create schema", "error", err)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
s.log.Info("database schema initialized")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DB returns the underlying sql.DB.
|
||||
func (s *Database) DB() *sql.DB {
|
||||
return s.db
|
||||
}
|
||||
31
internal/globals/globals.go
Normal file
31
internal/globals/globals.go
Normal file
@@ -0,0 +1,31 @@
|
||||
// Package globals provides application-wide constants set at build time.
|
||||
package globals
|
||||
|
||||
import (
|
||||
"go.uber.org/fx"
|
||||
)
|
||||
|
||||
// Build-time variables populated from main() via ldflags.
|
||||
var (
|
||||
Appname string //nolint:gochecknoglobals // set from main
|
||||
Version string //nolint:gochecknoglobals // set from main
|
||||
Buildarch string //nolint:gochecknoglobals // set from main
|
||||
)
|
||||
|
||||
// Globals holds application-wide constants.
|
||||
type Globals struct {
|
||||
Appname string
|
||||
Version string
|
||||
Buildarch string
|
||||
}
|
||||
|
||||
// New creates a new Globals instance from build-time variables.
|
||||
func New(lc fx.Lifecycle) (*Globals, error) {
|
||||
n := &Globals{
|
||||
Appname: Appname,
|
||||
Buildarch: Buildarch,
|
||||
Version: Version,
|
||||
}
|
||||
|
||||
return n, nil
|
||||
}
|
||||
60
internal/handlers/handlers.go
Normal file
60
internal/handlers/handlers.go
Normal file
@@ -0,0 +1,60 @@
|
||||
// Package handlers provides HTTP request handlers.
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"go.uber.org/fx"
|
||||
"sneak.berlin/go/pixa/internal/healthcheck"
|
||||
"sneak.berlin/go/pixa/internal/logger"
|
||||
)
|
||||
|
||||
// HandlersParams defines dependencies for Handlers.
|
||||
type HandlersParams struct {
|
||||
fx.In
|
||||
Logger *logger.Logger
|
||||
Healthcheck *healthcheck.Healthcheck
|
||||
}
|
||||
|
||||
// Handlers provides HTTP request handlers.
|
||||
type Handlers struct {
|
||||
log *slog.Logger
|
||||
hc *healthcheck.Healthcheck
|
||||
}
|
||||
|
||||
// New creates a new Handlers instance.
|
||||
func New(lc fx.Lifecycle, params HandlersParams) (*Handlers, error) {
|
||||
s := &Handlers{
|
||||
log: params.Logger.Get(),
|
||||
hc: params.Healthcheck,
|
||||
}
|
||||
|
||||
lc.Append(fx.Hook{
|
||||
OnStart: func(ctx context.Context) error {
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Handlers) respondJSON(w http.ResponseWriter, data interface{}, status int) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if data != nil {
|
||||
err := json.NewEncoder(w).Encode(data)
|
||||
if err != nil {
|
||||
s.log.Error("json encode error", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Handlers) respondError(w http.ResponseWriter, code string, message string, status int) {
|
||||
s.respondJSON(w, map[string]string{
|
||||
"error": code,
|
||||
"message": message,
|
||||
}, status)
|
||||
}
|
||||
12
internal/handlers/healthcheck.go
Normal file
12
internal/handlers/healthcheck.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func (s *Handlers) HandleHealthCheck() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, _ *http.Request) {
|
||||
resp := s.hc.Healthcheck()
|
||||
s.respondJSON(w, resp, http.StatusOK)
|
||||
}
|
||||
}
|
||||
28
internal/handlers/image.go
Normal file
28
internal/handlers/image.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// HandleImage handles the main image proxy route:
|
||||
// /v1/image/<host>/<path>/<width>x<height>.<format>
|
||||
func (s *Handlers) HandleImage() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, req *http.Request) {
|
||||
// FIXME: Implement image proxy handler
|
||||
// - Parse URL to extract host, path, size, format
|
||||
// - Validate signature and expiration
|
||||
// - Check source host whitelist
|
||||
// - Fetch from upstream (with SSRF protection)
|
||||
// - Process image (resize, convert format)
|
||||
// - Cache and serve result
|
||||
panic("unimplemented")
|
||||
}
|
||||
}
|
||||
|
||||
// HandleRobotsTxt serves robots.txt to prevent search engine crawling
|
||||
func (s *Handlers) HandleRobotsTxt() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, req *http.Request) {
|
||||
// FIXME: Implement robots.txt handler
|
||||
panic("unimplemented")
|
||||
}
|
||||
}
|
||||
83
internal/healthcheck/healthcheck.go
Normal file
83
internal/healthcheck/healthcheck.go
Normal file
@@ -0,0 +1,83 @@
|
||||
// Package healthcheck provides health check functionality.
|
||||
package healthcheck
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"go.uber.org/fx"
|
||||
"sneak.berlin/go/pixa/internal/config"
|
||||
"sneak.berlin/go/pixa/internal/database"
|
||||
"sneak.berlin/go/pixa/internal/globals"
|
||||
"sneak.berlin/go/pixa/internal/logger"
|
||||
)
|
||||
|
||||
// HealthcheckParams defines dependencies for Healthcheck.
|
||||
type HealthcheckParams struct {
|
||||
fx.In
|
||||
Globals *globals.Globals
|
||||
Config *config.Config
|
||||
Logger *logger.Logger
|
||||
Database *database.Database
|
||||
}
|
||||
|
||||
// Healthcheck provides health check information.
|
||||
type Healthcheck struct {
|
||||
StartupTime time.Time
|
||||
log *slog.Logger
|
||||
globals *globals.Globals
|
||||
config *config.Config
|
||||
}
|
||||
|
||||
// New creates a new Healthcheck instance.
|
||||
func New(lc fx.Lifecycle, params HealthcheckParams) (*Healthcheck, error) {
|
||||
s := &Healthcheck{
|
||||
log: params.Logger.Get(),
|
||||
globals: params.Globals,
|
||||
config: params.Config,
|
||||
}
|
||||
|
||||
lc.Append(fx.Hook{
|
||||
OnStart: func(ctx context.Context) error {
|
||||
s.StartupTime = time.Now()
|
||||
|
||||
return nil
|
||||
},
|
||||
OnStop: func(ctx context.Context) error {
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// HealthcheckResponse is the JSON response for health checks.
|
||||
type HealthcheckResponse struct {
|
||||
Status string `json:"status"`
|
||||
Now string `json:"now"`
|
||||
UptimeSeconds int64 `json:"uptime_seconds"`
|
||||
UptimeHuman string `json:"uptime_human"`
|
||||
Version string `json:"version"`
|
||||
Appname string `json:"appname"`
|
||||
Maintenance bool `json:"maintenance_mode"`
|
||||
}
|
||||
|
||||
func (s *Healthcheck) uptime() time.Duration {
|
||||
return time.Since(s.StartupTime)
|
||||
}
|
||||
|
||||
// Healthcheck returns the current health status.
|
||||
func (s *Healthcheck) Healthcheck() *HealthcheckResponse {
|
||||
resp := &HealthcheckResponse{
|
||||
Status: "ok",
|
||||
Now: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
UptimeSeconds: int64(s.uptime().Seconds()),
|
||||
UptimeHuman: s.uptime().String(),
|
||||
Appname: s.globals.Appname,
|
||||
Version: s.globals.Version,
|
||||
Maintenance: s.config.MaintenanceMode,
|
||||
}
|
||||
|
||||
return resp
|
||||
}
|
||||
195
internal/imgcache/imgcache.go
Normal file
195
internal/imgcache/imgcache.go
Normal file
@@ -0,0 +1,195 @@
|
||||
package imgcache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ImageFormat represents supported output image formats
|
||||
type ImageFormat string
|
||||
|
||||
const (
|
||||
FormatOriginal ImageFormat = "orig"
|
||||
FormatJPEG ImageFormat = "jpeg"
|
||||
FormatPNG ImageFormat = "png"
|
||||
FormatWebP ImageFormat = "webp"
|
||||
FormatAVIF ImageFormat = "avif"
|
||||
FormatGIF ImageFormat = "gif"
|
||||
)
|
||||
|
||||
// Size represents requested image dimensions
|
||||
type Size struct {
|
||||
Width int
|
||||
Height int
|
||||
}
|
||||
|
||||
// OriginalSize returns true if this represents "keep original size"
|
||||
func (s Size) OriginalSize() bool {
|
||||
return s.Width == 0 && s.Height == 0
|
||||
}
|
||||
|
||||
// FitMode represents how to fit image into requested dimensions
|
||||
type FitMode string
|
||||
|
||||
const (
|
||||
FitCover FitMode = "cover"
|
||||
FitContain FitMode = "contain"
|
||||
FitFill FitMode = "fill"
|
||||
FitInside FitMode = "inside"
|
||||
FitOutside FitMode = "outside"
|
||||
)
|
||||
|
||||
// ImageRequest represents a request for a processed image
|
||||
type ImageRequest struct {
|
||||
// SourceHost is the origin host (e.g., "cdn.example.com")
|
||||
SourceHost string
|
||||
// SourcePath is the path on the origin (e.g., "/photos/cat.jpg")
|
||||
SourcePath string
|
||||
// SourceQuery is the optional query string for the origin URL
|
||||
SourceQuery string
|
||||
// Size is the requested output dimensions
|
||||
Size Size
|
||||
// Format is the requested output format
|
||||
Format ImageFormat
|
||||
// Quality is the output quality (1-100) for lossy formats
|
||||
Quality int
|
||||
// FitMode is how to fit the image into requested dimensions
|
||||
FitMode FitMode
|
||||
// Signature is the HMAC signature for non-whitelisted hosts
|
||||
Signature string
|
||||
// Expires is the signature expiration timestamp
|
||||
Expires time.Time
|
||||
}
|
||||
|
||||
// SourceURL returns the full upstream URL to fetch
|
||||
func (r *ImageRequest) SourceURL() string {
|
||||
url := "https://" + r.SourceHost + r.SourcePath
|
||||
if r.SourceQuery != "" {
|
||||
url += "?" + r.SourceQuery
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
// ImageResponse represents a processed image ready to serve
|
||||
type ImageResponse struct {
|
||||
// Content is the image data reader
|
||||
Content io.ReadCloser
|
||||
// ContentLength is the size in bytes (-1 if unknown)
|
||||
ContentLength int64
|
||||
// ContentType is the MIME type of the response
|
||||
ContentType string
|
||||
// ETag is the entity tag for caching
|
||||
ETag string
|
||||
// LastModified is when the content was last modified
|
||||
LastModified time.Time
|
||||
// CacheStatus indicates HIT, MISS, or STALE
|
||||
CacheStatus CacheStatus
|
||||
}
|
||||
|
||||
// CacheStatus indicates whether the response was served from cache
|
||||
type CacheStatus string
|
||||
|
||||
const (
|
||||
CacheHit CacheStatus = "HIT"
|
||||
CacheMiss CacheStatus = "MISS"
|
||||
CacheStale CacheStatus = "STALE"
|
||||
)
|
||||
|
||||
// ImageCache is the main interface for the image caching proxy
|
||||
type ImageCache interface {
|
||||
// Get retrieves a processed image, fetching and processing if necessary
|
||||
Get(ctx context.Context, req *ImageRequest) (*ImageResponse, error)
|
||||
|
||||
// Warm pre-fetches and caches an image without returning it
|
||||
Warm(ctx context.Context, req *ImageRequest) error
|
||||
|
||||
// Purge removes a cached image
|
||||
Purge(ctx context.Context, req *ImageRequest) error
|
||||
|
||||
// Stats returns cache statistics
|
||||
Stats(ctx context.Context) (*CacheStats, error)
|
||||
}
|
||||
|
||||
// CacheStats contains cache statistics
|
||||
type CacheStats struct {
|
||||
// TotalItems is the number of cached items
|
||||
TotalItems int64
|
||||
// TotalSizeBytes is the total size of cached content
|
||||
TotalSizeBytes int64
|
||||
// HitCount is the number of cache hits
|
||||
HitCount int64
|
||||
// MissCount is the number of cache misses
|
||||
MissCount int64
|
||||
// HitRate is HitCount / (HitCount + MissCount)
|
||||
HitRate float64
|
||||
}
|
||||
|
||||
// SignatureValidator validates request signatures
|
||||
type SignatureValidator interface {
|
||||
// Validate checks if the signature is valid for the request
|
||||
Validate(req *ImageRequest) error
|
||||
// Generate creates a signature for a request
|
||||
Generate(req *ImageRequest) string
|
||||
}
|
||||
|
||||
// Whitelist checks if a URL is whitelisted (no signature required)
|
||||
type Whitelist interface {
|
||||
// IsWhitelisted returns true if the URL doesn't require a signature
|
||||
IsWhitelisted(u *url.URL) bool
|
||||
}
|
||||
|
||||
// Fetcher fetches images from upstream origins
|
||||
type Fetcher interface {
|
||||
// Fetch retrieves an image from the origin
|
||||
Fetch(ctx context.Context, url string) (*FetchResult, error)
|
||||
}
|
||||
|
||||
// FetchResult contains the result of fetching from upstream
|
||||
type FetchResult struct {
|
||||
// Content is the raw image data
|
||||
Content io.ReadCloser
|
||||
// ContentLength is the size in bytes (-1 if unknown)
|
||||
ContentLength int64
|
||||
// ContentType is the MIME type from upstream
|
||||
ContentType string
|
||||
// Headers contains all response headers from upstream
|
||||
Headers map[string][]string
|
||||
}
|
||||
|
||||
// Processor handles image transformation (resize, format conversion)
|
||||
type Processor interface {
|
||||
// Process transforms an image according to the request
|
||||
Process(ctx context.Context, input io.Reader, req *ImageRequest) (*ProcessResult, error)
|
||||
// SupportedInputFormats returns MIME types this processor can read
|
||||
SupportedInputFormats() []string
|
||||
// SupportedOutputFormats returns formats this processor can write
|
||||
SupportedOutputFormats() []ImageFormat
|
||||
}
|
||||
|
||||
// ProcessResult contains the result of image processing
|
||||
type ProcessResult struct {
|
||||
// Content is the processed image data
|
||||
Content io.ReadCloser
|
||||
// ContentLength is the size in bytes
|
||||
ContentLength int64
|
||||
// ContentType is the MIME type of the output
|
||||
ContentType string
|
||||
// Width is the output image width
|
||||
Width int
|
||||
// Height is the output image height
|
||||
Height int
|
||||
}
|
||||
|
||||
// Storage handles persistent storage of cached content
|
||||
type Storage interface {
|
||||
// Store saves content and returns its hash
|
||||
Store(ctx context.Context, content io.Reader) (hash string, err error)
|
||||
// Load retrieves content by hash
|
||||
Load(ctx context.Context, hash string) (io.ReadCloser, error)
|
||||
// Delete removes content by hash
|
||||
Delete(ctx context.Context, hash string) error
|
||||
// Exists checks if content exists
|
||||
Exists(ctx context.Context, hash string) (bool, error)
|
||||
}
|
||||
77
internal/logger/logger.go
Normal file
77
internal/logger/logger.go
Normal file
@@ -0,0 +1,77 @@
|
||||
// Package logger provides structured logging using slog.
|
||||
package logger
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
|
||||
"go.uber.org/fx"
|
||||
"sneak.berlin/go/pixa/internal/globals"
|
||||
)
|
||||
|
||||
// LoggerParams defines dependencies for Logger.
|
||||
type LoggerParams struct {
|
||||
fx.In
|
||||
Globals *globals.Globals
|
||||
}
|
||||
|
||||
// Logger wraps slog with application-specific functionality.
|
||||
type Logger struct {
|
||||
log *slog.Logger
|
||||
level *slog.LevelVar
|
||||
globals *globals.Globals
|
||||
}
|
||||
|
||||
// New creates a new Logger instance.
|
||||
func New(lc fx.Lifecycle, params LoggerParams) (*Logger, error) {
|
||||
l := &Logger{
|
||||
level: new(slog.LevelVar),
|
||||
globals: params.Globals,
|
||||
}
|
||||
l.level.Set(slog.LevelInfo)
|
||||
|
||||
// TTY detection for dev vs prod output
|
||||
tty := false
|
||||
if fileInfo, _ := os.Stdout.Stat(); (fileInfo.Mode() & os.ModeCharDevice) != 0 {
|
||||
tty = true
|
||||
}
|
||||
|
||||
var handler slog.Handler
|
||||
if tty {
|
||||
// Text output for development
|
||||
handler = slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
|
||||
Level: l.level,
|
||||
AddSource: true,
|
||||
})
|
||||
} else {
|
||||
// JSON output for production
|
||||
handler = slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
|
||||
Level: l.level,
|
||||
AddSource: true,
|
||||
})
|
||||
}
|
||||
|
||||
l.log = slog.New(handler)
|
||||
|
||||
return l, nil
|
||||
}
|
||||
|
||||
// EnableDebugLogging sets the log level to debug.
|
||||
func (l *Logger) EnableDebugLogging() {
|
||||
l.level.Set(slog.LevelDebug)
|
||||
l.log.Debug("debug logging enabled", "debug", true)
|
||||
}
|
||||
|
||||
// Get returns the underlying slog.Logger.
|
||||
func (l *Logger) Get() *slog.Logger {
|
||||
return l.log
|
||||
}
|
||||
|
||||
// Identify logs application startup information.
|
||||
func (l *Logger) Identify() {
|
||||
l.log.Info("starting",
|
||||
"appname", l.globals.Appname,
|
||||
"version", l.globals.Version,
|
||||
"buildarch", l.globals.Buildarch,
|
||||
)
|
||||
}
|
||||
132
internal/middleware/middleware.go
Normal file
132
internal/middleware/middleware.go
Normal file
@@ -0,0 +1,132 @@
|
||||
// Package middleware provides HTTP middleware functions.
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
basicauth "github.com/99designs/basicauth-go"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/go-chi/cors"
|
||||
metrics "github.com/slok/go-http-metrics/metrics/prometheus"
|
||||
ghmm "github.com/slok/go-http-metrics/middleware"
|
||||
"github.com/slok/go-http-metrics/middleware/std"
|
||||
"go.uber.org/fx"
|
||||
"sneak.berlin/go/pixa/internal/config"
|
||||
"sneak.berlin/go/pixa/internal/logger"
|
||||
)
|
||||
|
||||
// MiddlewareParams defines dependencies for Middleware.
|
||||
type MiddlewareParams struct {
|
||||
fx.In
|
||||
Logger *logger.Logger
|
||||
Config *config.Config
|
||||
}
|
||||
|
||||
// Middleware provides HTTP middleware functions.
|
||||
type Middleware struct {
|
||||
log *slog.Logger
|
||||
config *config.Config
|
||||
}
|
||||
|
||||
// New creates a new Middleware instance.
|
||||
func New(lc fx.Lifecycle, params MiddlewareParams) (*Middleware, error) {
|
||||
s := &Middleware{
|
||||
log: params.Logger.Get(),
|
||||
config: params.Config,
|
||||
}
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func ipFromHostPort(hp string) string {
|
||||
h, _, err := net.SplitHostPort(hp)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
if len(h) > 0 && h[0] == '[' {
|
||||
return h[1 : len(h)-1]
|
||||
}
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
type loggingResponseWriter struct {
|
||||
http.ResponseWriter
|
||||
statusCode int
|
||||
}
|
||||
|
||||
func newLoggingResponseWriter(w http.ResponseWriter) *loggingResponseWriter {
|
||||
return &loggingResponseWriter{w, http.StatusOK}
|
||||
}
|
||||
|
||||
func (lrw *loggingResponseWriter) WriteHeader(code int) {
|
||||
lrw.statusCode = code
|
||||
lrw.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
// Logging returns a logging middleware.
|
||||
func (s *Middleware) Logging() func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
lrw := newLoggingResponseWriter(w)
|
||||
ctx := r.Context()
|
||||
defer func() {
|
||||
latency := time.Since(start)
|
||||
reqID, _ := ctx.Value(middleware.RequestIDKey).(string)
|
||||
s.log.InfoContext(ctx, "request",
|
||||
"request_start", start,
|
||||
"method", r.Method,
|
||||
"url", r.URL.String(),
|
||||
"useragent", r.UserAgent(),
|
||||
"request_id", reqID,
|
||||
"referer", r.Referer(),
|
||||
"proto", r.Proto,
|
||||
"remoteIP", ipFromHostPort(r.RemoteAddr),
|
||||
"status", lrw.statusCode,
|
||||
"latency_ms", latency.Milliseconds(),
|
||||
)
|
||||
}()
|
||||
|
||||
next.ServeHTTP(lrw, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// CORS returns a CORS middleware.
|
||||
func (s *Middleware) CORS() func(http.Handler) http.Handler {
|
||||
return cors.Handler(cors.Options{
|
||||
AllowedOrigins: []string{"*"},
|
||||
AllowedMethods: []string{"GET", "HEAD", "OPTIONS"},
|
||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type"},
|
||||
ExposedHeaders: []string{"Link"},
|
||||
AllowCredentials: false,
|
||||
MaxAge: 86400,
|
||||
})
|
||||
}
|
||||
|
||||
// Metrics returns a Prometheus metrics middleware.
|
||||
func (s *Middleware) Metrics() func(http.Handler) http.Handler {
|
||||
mdlw := ghmm.New(ghmm.Config{
|
||||
Recorder: metrics.NewRecorder(metrics.Config{}),
|
||||
})
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return std.Handler("", mdlw, next)
|
||||
}
|
||||
}
|
||||
|
||||
// MetricsAuth returns a basic auth middleware for the metrics endpoint.
|
||||
func (s *Middleware) MetricsAuth() func(http.Handler) http.Handler {
|
||||
return basicauth.New(
|
||||
"metrics",
|
||||
map[string][]string{
|
||||
s.config.MetricsUsername: {
|
||||
s.config.MetricsPassword,
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
33
internal/server/http.go
Normal file
33
internal/server/http.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (s *Server) serveUntilShutdown() {
|
||||
listenAddr := fmt.Sprintf(":%d", s.config.Port)
|
||||
s.httpServer = &http.Server{
|
||||
Addr: listenAddr,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 60 * time.Second,
|
||||
MaxHeaderBytes: 1 << 13, // 8KB
|
||||
Handler: s,
|
||||
}
|
||||
|
||||
s.SetupRoutes()
|
||||
|
||||
s.log.Info("http begin listen", "listenaddr", listenAddr)
|
||||
if err := s.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
s.log.Error("listen error", "error", err)
|
||||
if s.cancelFunc != nil {
|
||||
s.cancelFunc()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ServeHTTP implements http.Handler.
|
||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
s.router.ServeHTTP(w, r)
|
||||
}
|
||||
53
internal/server/routes.go
Normal file
53
internal/server/routes.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
sentryhttp "github.com/getsentry/sentry-go/http"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
)
|
||||
|
||||
// SetupRoutes configures all HTTP routes.
|
||||
func (s *Server) SetupRoutes() {
|
||||
s.router = chi.NewRouter()
|
||||
|
||||
s.router.Use(middleware.Recoverer)
|
||||
s.router.Use(middleware.RequestID)
|
||||
s.router.Use(s.mw.Logging())
|
||||
|
||||
// Add metrics middleware only if credentials are configured
|
||||
if s.config.MetricsUsername != "" {
|
||||
s.router.Use(s.mw.Metrics())
|
||||
}
|
||||
|
||||
s.router.Use(s.mw.CORS())
|
||||
s.router.Use(middleware.Timeout(60 * time.Second))
|
||||
|
||||
if s.sentryEnabled {
|
||||
sentryHandler := sentryhttp.New(sentryhttp.Options{
|
||||
Repanic: true,
|
||||
})
|
||||
s.router.Use(sentryHandler.Handle)
|
||||
}
|
||||
|
||||
// Health check endpoint
|
||||
s.router.Get("/.well-known/healthcheck.json", s.h.HandleHealthCheck())
|
||||
|
||||
// Robots.txt
|
||||
s.router.Get("/robots.txt", s.h.HandleRobotsTxt())
|
||||
|
||||
// Main image proxy route
|
||||
// /v1/image/<host>/<path>/<width>x<height>.<format>
|
||||
s.router.Get("/v1/image/*", s.h.HandleImage())
|
||||
|
||||
// Metrics endpoint with auth
|
||||
if s.config.MetricsUsername != "" {
|
||||
s.router.Group(func(r chi.Router) {
|
||||
r.Use(s.mw.MetricsAuth())
|
||||
r.Get("/metrics", http.HandlerFunc(promhttp.Handler().ServeHTTP))
|
||||
})
|
||||
}
|
||||
}
|
||||
145
internal/server/server.go
Normal file
145
internal/server/server.go
Normal file
@@ -0,0 +1,145 @@
|
||||
// Package server provides the HTTP server and lifecycle management.
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/getsentry/sentry-go"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"go.uber.org/fx"
|
||||
"sneak.berlin/go/pixa/internal/config"
|
||||
"sneak.berlin/go/pixa/internal/globals"
|
||||
"sneak.berlin/go/pixa/internal/handlers"
|
||||
"sneak.berlin/go/pixa/internal/logger"
|
||||
"sneak.berlin/go/pixa/internal/middleware"
|
||||
)
|
||||
|
||||
// ServerParams defines dependencies for Server.
|
||||
type ServerParams struct {
|
||||
fx.In
|
||||
Logger *logger.Logger
|
||||
Globals *globals.Globals
|
||||
Config *config.Config
|
||||
Middleware *middleware.Middleware
|
||||
Handlers *handlers.Handlers
|
||||
}
|
||||
|
||||
// Server is the main HTTP server.
|
||||
type Server struct {
|
||||
log *slog.Logger
|
||||
config *config.Config
|
||||
globals *globals.Globals
|
||||
mw *middleware.Middleware
|
||||
h *handlers.Handlers
|
||||
startupTime time.Time
|
||||
exitCode int
|
||||
sentryEnabled bool
|
||||
ctx context.Context
|
||||
cancelFunc context.CancelFunc
|
||||
httpServer *http.Server
|
||||
router *chi.Mux
|
||||
}
|
||||
|
||||
// New creates a new Server instance.
|
||||
func New(lc fx.Lifecycle, params ServerParams) (*Server, error) {
|
||||
s := &Server{
|
||||
log: params.Logger.Get(),
|
||||
config: params.Config,
|
||||
globals: params.Globals,
|
||||
mw: params.Middleware,
|
||||
h: params.Handlers,
|
||||
}
|
||||
|
||||
lc.Append(fx.Hook{
|
||||
OnStart: func(ctx context.Context) error {
|
||||
s.startupTime = time.Now()
|
||||
go s.Run()
|
||||
|
||||
return nil
|
||||
},
|
||||
OnStop: func(ctx context.Context) error {
|
||||
if s.cancelFunc != nil {
|
||||
s.cancelFunc()
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Run starts the server.
|
||||
func (s *Server) Run() {
|
||||
s.enableSentry()
|
||||
s.serve()
|
||||
}
|
||||
|
||||
func (s *Server) enableSentry() {
|
||||
s.sentryEnabled = false
|
||||
|
||||
if s.config.SentryDSN == "" {
|
||||
return
|
||||
}
|
||||
|
||||
err := sentry.Init(sentry.ClientOptions{
|
||||
Dsn: s.config.SentryDSN,
|
||||
Release: fmt.Sprintf("%s-%s", s.globals.Appname, s.globals.Version),
|
||||
})
|
||||
if err != nil {
|
||||
s.log.Error("sentry init failure", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
s.log.Info("sentry error reporting activated")
|
||||
s.sentryEnabled = true
|
||||
}
|
||||
|
||||
func (s *Server) serve() int {
|
||||
s.ctx, s.cancelFunc = context.WithCancel(context.Background())
|
||||
|
||||
go func() {
|
||||
c := make(chan os.Signal, 1)
|
||||
signal.Ignore(syscall.SIGPIPE)
|
||||
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
|
||||
sig := <-c
|
||||
s.log.Info("signal received", "signal", sig)
|
||||
if s.cancelFunc != nil {
|
||||
s.cancelFunc()
|
||||
}
|
||||
}()
|
||||
|
||||
go s.serveUntilShutdown()
|
||||
|
||||
<-s.ctx.Done()
|
||||
s.cleanShutdown()
|
||||
|
||||
return s.exitCode
|
||||
}
|
||||
|
||||
func (s *Server) cleanShutdown() {
|
||||
s.exitCode = 0
|
||||
ctxShutdown, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer shutdownCancel()
|
||||
|
||||
if s.httpServer != nil {
|
||||
if err := s.httpServer.Shutdown(ctxShutdown); err != nil {
|
||||
s.log.Error("server clean shutdown failed", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
if s.sentryEnabled {
|
||||
sentry.Flush(2 * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
// MaintenanceMode returns whether maintenance mode is enabled.
|
||||
func (s *Server) MaintenanceMode() bool {
|
||||
return s.config.MaintenanceMode
|
||||
}
|
||||
Reference in New Issue
Block a user