Add basic webserver skeleton with healthcheck

This commit is contained in:
2026-01-08 02:20:23 -08:00
parent 38faf56be0
commit 516853626d
14 changed files with 1129 additions and 0 deletions

128
internal/config/config.go Normal file
View 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
}

View 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
}

View 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
}

View 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)
}

View 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)
}
}

View 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")
}
}

View 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
}

View 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
View 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,
)
}

View 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
View 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
View 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
View 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
}