Add basic webserver skeleton with healthcheck
This commit is contained in:
39
cmd/pixad/main.go
Normal file
39
cmd/pixad/main.go
Normal file
@@ -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()
|
||||||
|
}
|
||||||
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