Skip to content
Merged

Docs #30

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions cmd/docs/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// Copyright (C) 2026 Tobias Gleiter
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package main

import (
"context"
"fmt"
"log"
"log/slog"
"os/signal"
"syscall"

htmlpkg "github.com/bit8bytes/gearberg/internal/html"
)

type application struct {
logger *slog.Logger
options *options
html *htmlpkg.HTML
}

func main() {
if err := run(); err != nil {
log.Fatalf("error: %v", err)
}
}

func run() error {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()

options, err := parseOptions()
if err != nil {
return fmt.Errorf("parse options: %w", err)
}

if options.Version {
fmt.Printf("%s\n", revision)
return nil
}

log := setupLogger(options.LogLevel.Level())

base, cache, err := parseTemplates()
if err != nil {
return fmt.Errorf("load templates: %w", err)
}

app := &application{
logger: log,
options: options,
html: htmlpkg.New(log, base, cache, revision),
}

return app.serve(ctx)
}
147 changes: 147 additions & 0 deletions cmd/docs/middleware.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
// Copyright (C) 2026 Tobias Gleiter
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package main

import (
"errors"
"fmt"
"log/slog"
"net/http"
"runtime/debug"
"strings"

"github.com/bit8bytes/gearberg/internal/nonce"
"github.com/bit8bytes/gearberg/internal/trace"
"github.com/bit8bytes/gearberg/pkg/tokens"
)

func withTrace(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := trace.NewContext(r.Context(), tokens.Generate().Hex())
next.ServeHTTP(w, r.WithContext(ctx))
})
}

func withNonce(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := nonce.NewContext(r.Context(), tokens.Generate().Hex())
next.ServeHTTP(w, r.WithContext(ctx))
})
}

type requestLogger struct {
logger *slog.Logger
}

func newRequestLogger(logger *slog.Logger) *requestLogger {
return &requestLogger{logger: logger}
}

// logRequest is middleware that logs each inbound request. Requests to /s/ (static assets)
// are skipped to avoid flooding logs with high-frequency, low-signal noise.
func (rl *requestLogger) handler(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/static/") {
next.ServeHTTP(w, r)
return
}

rl.logger.InfoContext(r.Context(), "request",
slog.String("addr", r.RemoteAddr),
slog.String("method", r.Method),
slog.String("path", r.URL.Path),
slog.Any("queries", r.URL.Query()),
)

next.ServeHTTP(w, r)
})
}

type panicRecoverer struct {
logger *slog.Logger
}

func newPanicRecoverer(logger *slog.Logger) *panicRecoverer {
return &panicRecoverer{logger: logger}
}

// recoverPanic catches any panics that occur in downstream handlers, closes the
// connection to prevent the client from hanging on a half-written response.
// Without this, an unrecovered panic would crash the goroutine and leave the request
// silently unanswered.
func (pr *panicRecoverer) handler(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
w.Header().Set("Connection", "close")
stack := debug.Stack()

var panicErr error
switch v := err.(type) {
case error:
panicErr = v
case string:
panicErr = errors.New(v)
default:
panicErr = fmt.Errorf("panic: %v", err)
}

traceID := trace.From(r.Context())

pr.logger.Error("recover from panic",
slog.String("type", "panic"),
slog.String("error", panicErr.Error()),
slog.String("type", fmt.Sprintf("%T", err)),
slog.String("stack", string(stack)),
slog.String("method", r.Method),
slog.String("url", r.URL.String()),
slog.String("trace_id", traceID.String()),
slog.String("remote_addr", r.RemoteAddr),
slog.String("user_agent", r.UserAgent()),
)
}
}()

next.ServeHTTP(w, r)
})
}

// withSecurityHeaders sets HTTP response headers that harden the app against
// common browser-based attacks. Must be applied to all routes.
func withSecurityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Referrer-Policy", "origin-when-cross-origin")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("X-XSS-Protection", "0")
w.Header().Set("Strict-Transport-Security", "max-age=31536000;")

nonceID := nonce.From(r.Context())
w.Header().Set("Content-Security-Policy", fmt.Sprintf(
"default-src 'self'; "+
"img-src 'self' data:;"+
"style-src 'nonce-%s'; "+
"script-src 'nonce-%s' 'strict-dynamic'; "+
"font-src 'self'; "+
"connect-src 'self'; "+
"form-action 'self'; "+
"frame-ancestors 'none'; "+
"object-src 'none'; "+
"base-uri 'self';",
nonceID, nonceID.String(),
))

next.ServeHTTP(w, r)
})
}
100 changes: 100 additions & 0 deletions cmd/docs/options.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
// Copyright (C) 2026 Tobias Gleiter
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package main

import (
"flag"
"fmt"
"log/slog"
"slices"
)

type options struct {
Version bool
LogLevel logLevel
Port int
TLSMode string
}

func parseOptions() (*options, error) {
cfg := &options{LogLevel: logLevel{level: slog.LevelError}}
flag.Var(&cfg.LogLevel, "log-level", "log level (debug|info|warn|error)")
flag.BoolVar(&cfg.Version, "version", false, "print version and exit")
flag.IntVar(&cfg.Port, "port", 8080, "port to listen on")
flag.StringVar(&cfg.TLSMode, "tls-mode", "off", "TLS mode (off|local)")
flag.Parse()

modes := []string{"off"}
if !slices.Contains(modes, cfg.TLSMode) {
return nil, fmt.Errorf("tls-mode must be one of: %v", modes)
}
if cfg.TLSMode == "off" {
if err := validatePort(cfg.Port); err != nil {
return nil, fmt.Errorf("invalid port: %w", err)
}
}

return cfg, nil
}

func validatePort(port int) error {
if port < 0 || port > 65535 {
return fmt.Errorf("port is not in valid range of 0-65535")
}
return nil
}

// logLevel wraps slog.Level for flag parsing.
type logLevel struct {
level slog.Level
}

// String returns the log level as a string.
func (l logLevel) String() string {
switch l.level {
case slog.LevelDebug:
return "debug"
case slog.LevelInfo:
return "info"
case slog.LevelWarn:
return "warn"
case slog.LevelError:
return "error"
default:
return "info"
}
}

// Set validates and sets the log level value.
func (l *logLevel) Set(value string) error {
switch value {
case "debug":
l.level = slog.LevelDebug
case "info":
l.level = slog.LevelInfo
case "warn":
l.level = slog.LevelWarn
case "error":
l.level = slog.LevelError
default:
return fmt.Errorf("log-level must be one of: debug, info, warn, error")
}
return nil
}

// Level returns the slog.Level value.
func (l logLevel) Level() slog.Level {
return l.level
}
31 changes: 31 additions & 0 deletions cmd/docs/revision.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// Copyright (C) 2026 Tobias Gleiter
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package main

import (
"fmt"
"time"
)

// revision is set at build time via -ldflags "-X main.revision=<value>".
// In Nix builds this is the flake revision; in local builds it defaults to
// "dev-<timestamp>" so that each process start busts the asset cache.
var revision = "dev"

func init() {
if revision == "dev" {
revision = fmt.Sprintf("dev-%d", time.Now().UnixMilli())
}
}
Loading