From 516853626dde5f290f4d7ad4870b78dc8256d620 Mon Sep 17 00:00:00 2001 From: sneak Date: Thu, 8 Jan 2026 02:20:23 -0800 Subject: [PATCH] Add basic webserver skeleton with healthcheck --- cmd/pixad/main.go | 39 ++++++ internal/config/config.go | 128 ++++++++++++++++++ internal/database/database.go | 113 ++++++++++++++++ internal/globals/globals.go | 31 +++++ internal/handlers/handlers.go | 60 +++++++++ internal/handlers/healthcheck.go | 12 ++ internal/handlers/image.go | 28 ++++ internal/healthcheck/healthcheck.go | 83 ++++++++++++ internal/imgcache/imgcache.go | 195 ++++++++++++++++++++++++++++ internal/logger/logger.go | 77 +++++++++++ internal/middleware/middleware.go | 132 +++++++++++++++++++ internal/server/http.go | 33 +++++ internal/server/routes.go | 53 ++++++++ internal/server/server.go | 145 +++++++++++++++++++++ 14 files changed, 1129 insertions(+) create mode 100644 cmd/pixad/main.go create mode 100644 internal/config/config.go create mode 100644 internal/database/database.go create mode 100644 internal/globals/globals.go create mode 100644 internal/handlers/handlers.go create mode 100644 internal/handlers/healthcheck.go create mode 100644 internal/handlers/image.go create mode 100644 internal/healthcheck/healthcheck.go create mode 100644 internal/imgcache/imgcache.go create mode 100644 internal/logger/logger.go create mode 100644 internal/middleware/middleware.go create mode 100644 internal/server/http.go create mode 100644 internal/server/routes.go create mode 100644 internal/server/server.go diff --git a/cmd/pixad/main.go b/cmd/pixad/main.go new file mode 100644 index 0000000..3dc7703 --- /dev/null +++ b/cmd/pixad/main.go @@ -0,0 +1,39 @@ +package main + +import ( + "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/handlers" + "sneak.berlin/go/pixa/internal/healthcheck" + "sneak.berlin/go/pixa/internal/logger" + "sneak.berlin/go/pixa/internal/middleware" + "sneak.berlin/go/pixa/internal/server" +) + +var ( + Appname string = "pixad" //nolint:gochecknoglobals // set by ldflags + Version string //nolint:gochecknoglobals // set by ldflags + Buildarch string //nolint:gochecknoglobals // set by ldflags +) + +func main() { + globals.Appname = Appname + globals.Version = Version + globals.Buildarch = Buildarch + + fx.New( + fx.Provide( + config.New, + database.New, + globals.New, + handlers.New, + logger.New, + server.New, + middleware.New, + healthcheck.New, + ), + fx.Invoke(func(*server.Server) {}), + ).Run() +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..e9e82aa --- /dev/null +++ b/internal/config/config.go @@ -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 +} diff --git a/internal/database/database.go b/internal/database/database.go new file mode 100644 index 0000000..f894c3f --- /dev/null +++ b/internal/database/database.go @@ -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 +} diff --git a/internal/globals/globals.go b/internal/globals/globals.go new file mode 100644 index 0000000..2f74c5c --- /dev/null +++ b/internal/globals/globals.go @@ -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 +} diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go new file mode 100644 index 0000000..5a35830 --- /dev/null +++ b/internal/handlers/handlers.go @@ -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) +} diff --git a/internal/handlers/healthcheck.go b/internal/handlers/healthcheck.go new file mode 100644 index 0000000..4f7e8db --- /dev/null +++ b/internal/handlers/healthcheck.go @@ -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) + } +} diff --git a/internal/handlers/image.go b/internal/handlers/image.go new file mode 100644 index 0000000..251fb34 --- /dev/null +++ b/internal/handlers/image.go @@ -0,0 +1,28 @@ +package handlers + +import ( + "net/http" +) + +// HandleImage handles the main image proxy route: +// /v1/image///x. +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") + } +} diff --git a/internal/healthcheck/healthcheck.go b/internal/healthcheck/healthcheck.go new file mode 100644 index 0000000..219b6dc --- /dev/null +++ b/internal/healthcheck/healthcheck.go @@ -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 +} diff --git a/internal/imgcache/imgcache.go b/internal/imgcache/imgcache.go new file mode 100644 index 0000000..3227967 --- /dev/null +++ b/internal/imgcache/imgcache.go @@ -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) +} diff --git a/internal/logger/logger.go b/internal/logger/logger.go new file mode 100644 index 0000000..cf164e3 --- /dev/null +++ b/internal/logger/logger.go @@ -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, + ) +} diff --git a/internal/middleware/middleware.go b/internal/middleware/middleware.go new file mode 100644 index 0000000..ec83c01 --- /dev/null +++ b/internal/middleware/middleware.go @@ -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, + }, + }, + ) +} diff --git a/internal/server/http.go b/internal/server/http.go new file mode 100644 index 0000000..f644fe8 --- /dev/null +++ b/internal/server/http.go @@ -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) +} diff --git a/internal/server/routes.go b/internal/server/routes.go new file mode 100644 index 0000000..208cb84 --- /dev/null +++ b/internal/server/routes.go @@ -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///x. + 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)) + }) + } +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..6ea108c --- /dev/null +++ b/internal/server/server.go @@ -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 +}