diff --git a/cmd/docs/main.go b/cmd/docs/main.go new file mode 100644 index 0000000..d1c0c47 --- /dev/null +++ b/cmd/docs/main.go @@ -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 . +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) +} diff --git a/cmd/docs/middleware.go b/cmd/docs/middleware.go new file mode 100644 index 0000000..a626471 --- /dev/null +++ b/cmd/docs/middleware.go @@ -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 . +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) + }) +} diff --git a/cmd/docs/options.go b/cmd/docs/options.go new file mode 100644 index 0000000..8e44c32 --- /dev/null +++ b/cmd/docs/options.go @@ -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 . +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 +} diff --git a/cmd/docs/revision.go b/cmd/docs/revision.go new file mode 100644 index 0000000..a61607b --- /dev/null +++ b/cmd/docs/revision.go @@ -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 . +package main + +import ( + "fmt" + "time" +) + +// revision is set at build time via -ldflags "-X main.revision=". +// In Nix builds this is the flake revision; in local builds it defaults to +// "dev-" 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()) + } +} diff --git a/cmd/docs/routes.go b/cmd/docs/routes.go new file mode 100644 index 0000000..ee4675d --- /dev/null +++ b/cmd/docs/routes.go @@ -0,0 +1,60 @@ +// 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 . +package main + +import ( + "fmt" + "net/http" + "time" + + "github.com/bit8bytes/gearberg/internal/assets" + "github.com/bit8bytes/gearberg/internal/httperr" + "github.com/bit8bytes/gearberg/internal/templates/pages" +) + +func (app *application) routes() http.Handler { + mux := http.NewServeMux() + + mux.Handle("GET /dist/", assets.ServeStaticFiles()) + mux.Handle("GET /favicon.ico", http.RedirectHandler("/dist/images/favicon.ico", http.StatusMovedPermanently)) + mux.HandleFunc("GET /robots.txt", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Header().Set("Cache-Control", "public, max-age=86400") + _, _ = fmt.Fprint(w, "User-agent: *\nAllow: /\n") + }) + + mux.HandleFunc("/", app.html.Handle(app.getDocs)) + mux.Handle("GET /imprint", http.RedirectHandler("https://www.gearberg.org/imprint", http.StatusSeeOther)) + mux.Handle("GET /privacy", http.RedirectHandler("https://www.gearberg.org/privacy", http.StatusSeeOther)) + + logRequest := newRequestLogger(app.logger) + recoverPanic := newPanicRecoverer(app.logger) + + return withTrace( + withNonce( + recoverPanic.handler( + logRequest.handler( + withSecurityHeaders(mux))))) +} + +func (app *application) getDocs(w http.ResponseWriter, r *http.Request) *httperr.Error { + data := app.html.TemplateData(r) + data.Data = struct { + Year int + }{ + Year: time.Now().Year(), + } + return app.html.Render(w, r, http.StatusOK, pages.Docs, data) +} diff --git a/cmd/docs/server.go b/cmd/docs/server.go new file mode 100644 index 0000000..475177d --- /dev/null +++ b/cmd/docs/server.go @@ -0,0 +1,75 @@ +// 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 . +package main + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net" + "net/http" + "time" + + "golang.org/x/sync/errgroup" +) + +func (app *application) serve(ctx context.Context) error { + return app.serveUnsecure(ctx) +} + +func (app *application) newServer(ctx context.Context) *http.Server { + return &http.Server{ + Addr: fmt.Sprintf(":%d", app.options.Port), + Handler: http.TimeoutHandler(app.routes(), time.Second*20, ""), + MaxHeaderBytes: 524_288, + IdleTimeout: time.Minute, + BaseContext: func(_ net.Listener) context.Context { return ctx }, + ReadHeaderTimeout: 2 * time.Second, + ReadTimeout: 30 * time.Second, + WriteTimeout: 15 * time.Second, + ErrorLog: slog.NewLogLogger(app.logger.Handler(), slog.LevelError), + } +} + +func (app *application) serveUnsecure(ctx context.Context) error { + srv := app.newServer(ctx) + + app.logger.Info("starting server", "addr", srv.Addr, "revision", revision) + + g, ctx := errgroup.WithContext(ctx) + + g.Go(func() error { + if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) { + return fmt.Errorf("listen and serve: %w", err) + } + return nil + }) + + g.Go(func() error { + <-ctx.Done() + + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + app.logger.Info("shutting down server...") + return srv.Shutdown(shutdownCtx) + }) + + if err := g.Wait(); err != nil { + return fmt.Errorf("serve: %w", err) + } + return nil +} diff --git a/cmd/docs/setup.go b/cmd/docs/setup.go new file mode 100644 index 0000000..6313808 --- /dev/null +++ b/cmd/docs/setup.go @@ -0,0 +1,90 @@ +// 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 . +package main + +import ( + "fmt" + "html/template" + "io/fs" + "log" + "log/slog" + "path/filepath" + + "github.com/bit8bytes/gearberg/internal/templates" + "github.com/bit8bytes/gearberg/internal/templates/pages" +) + +func setupLogger(l slog.Level) *slog.Logger { + level := new(slog.LevelVar) + level.Set(l) + + opts := &slog.HandlerOptions{ + AddSource: false, + Level: level, + ReplaceAttr: includeSourceFile, + } + + return slog.New(slog.NewJSONHandler(log.Writer(), opts)) +} + +func includeSourceFile(_ []string, a slog.Attr) slog.Attr { + if a.Key == slog.SourceKey { + source := a.Value.Any().(*slog.Source) + source.File = filepath.Base(source.File) + } + return a +} + +func templateFuncs() template.FuncMap { + return template.FuncMap{} +} + +func parseTemplates() (*template.Template, map[string]*template.Template, error) { + base, err := template.New("root").Funcs(templateFuncs()).ParseFS(templates.EmbedFS, "layouts/docs.tmpl", "components/*.tmpl") + if err != nil { + return nil, nil, fmt.Errorf("base template: %w", err) + } + + docsPages := []pages.Page{pages.Docs, pages.Error} + tmpls := make(map[string]*template.Template, len(docsPages)) + for _, page := range docsPages { + t, err := pageTemplate(templates.EmbedFS, base, page) + if err != nil { + return nil, nil, fmt.Errorf("page template %s: %w", page.File, err) + } + tmpls[page.File] = t + } + return base, tmpls, nil +} + +func pageTemplate(fsys fs.FS, base *template.Template, page pages.Page) (*template.Template, error) { + t := template.Must(base.Clone()) + + patterns := []string{page.Layout.File, page.File} + if page.Layout.Partials != "" { + patterns = append(patterns, page.Layout.Partials) + } + + t, err := t.ParseFS(fsys, patterns...) + if err != nil { + return nil, fmt.Errorf("parse: %w", err) + } + + if t.Lookup("page") == nil { + return nil, fmt.Errorf("page block not defined: %s", page.File) + } + + return t, nil +} diff --git a/cmd/web/main.go b/cmd/web/main.go index 6efced6..44d1af7 100644 --- a/cmd/web/main.go +++ b/cmd/web/main.go @@ -60,8 +60,8 @@ func run() error { switch cmd { case "serve": return runServe(args) - case "verify": - return runVerify(args) + case "check": + return runCheck(args) case "version": fmt.Printf("%s\n", revision) return nil @@ -72,7 +72,7 @@ func run() error { } func printUsage() { - fmt.Fprintf(os.Stderr, "usage: gearberg [flags]\n\nCommands:\n serve start the web server\n verify check configuration and database connectivity\n\nRun 'gearberg -help' for command flags.\n") + fmt.Fprintf(os.Stderr, "usage: gearberg [flags]\n\nCommands:\n serve start the web server\n check check configuration and database connectivity\n\nRun 'gearberg -help' for command flags.\n") } // runServe glues together the internal and external packages to start the web server. @@ -144,7 +144,7 @@ func runServe(args []string) error { return app.serve(ctx) } -func runVerify(args []string) error { +func runCheck(args []string) error { ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() diff --git a/cmd/web/options.go b/cmd/web/options.go index b323bc2..bcd489c 100644 --- a/cmd/web/options.go +++ b/cmd/web/options.go @@ -28,12 +28,12 @@ type options struct { Port int TLSMode string TLSCertPath string - TLSKeyPath string + TLSKeyPath string // conditional SECRET BaseURL string - DbDsn string // SECRET - StorageDSN string - SMTP SMTP // has SECRET - OIDCProviders OIDCProviderMap + DbDsn string // SECRET + StorageDSN string // conditional SECRET + SMTP SMTP // has SECRET + OIDCProviders OIDCProviderMap // has SECRET MaxOrgs int MaxOrgCategories int MaxOrgManufacturers int @@ -51,7 +51,7 @@ func parseOptions(args []string) (*options, error) { fs.StringVar(&cfg.BaseURL, "base-url", "", "base URL for link generation (e.g. https://example.com)") fs.StringVar(&cfg.TLSMode, "tls-mode", "off", "TLS mode (off|local)") fs.StringVar(&cfg.TLSCertPath, "tls-cert-path", "", "Path to TLS certificate file (required with -tls-mode=local)") - fs.StringVar(&cfg.TLSKeyPath, "tls-key-path", "", "Path to TLS key file (required with -tls-mode=local)") + fs.StringVar(&cfg.TLSKeyPath, "tls-key-path", envOr("TLS_KEY_PATH", ""), "Path to TLS key file (required with -tls-mode=local)") fs.IntVar(&cfg.MaxOrgs, "max-orgs", 1, "maximum number of orgs allowed") fs.IntVar(&cfg.MaxOrgCategories, "max-categories", 25, "maximum number of equipment categories per org") fs.IntVar(&cfg.MaxOrgManufacturers, "max-manufacturers", 100, "maximum number of manufacturers per org") diff --git a/cmd/www/main.go b/cmd/www/main.go index cbd8f91..a8887c0 100644 --- a/cmd/www/main.go +++ b/cmd/www/main.go @@ -57,7 +57,7 @@ func run() error { } func printUsage() { - fmt.Fprintf(os.Stderr, "usage: gearberg [flags]\n\nCommands:\n serve start the web server\n verify check configuration and database connectivity\n\nRun 'gearberg -help' for command flags.\n") + fmt.Fprintf(os.Stderr, "usage: gearberg [flags]\n\nCommands:\n serve start the web server\n\nRun 'gearberg -help' for command flags.\n") } func runServe(args []string) error { diff --git a/cmd/www/setup.go b/cmd/www/setup.go index a181905..0ee01ca 100644 --- a/cmd/www/setup.go +++ b/cmd/www/setup.go @@ -75,7 +75,7 @@ func parseTemplates() (*template.Template, map[string]*template.Template, error) return nil, nil, fmt.Errorf("base template: %w", err) } - allPages := pages.All + allPages := []pages.Page{pages.Landing, pages.Error} tmpls := make(map[string]*template.Template, len(allPages)) for _, page := range allPages { t, err := pageTemplate(templates.EmbedFS, base, page) diff --git a/flake.nix b/flake.nix index ab93065..fd3e997 100644 --- a/flake.nix +++ b/flake.nix @@ -34,6 +34,10 @@ buildpkgs = nixpkgs-unstable.legacyPackages.${system}; version = self.rev or self.dirtyRev or "dev"; }; + docs = import ./nixos/docs.nix { + buildpkgs = nixpkgs-unstable.legacyPackages.${system}; + version = self.rev or self.dirtyRev or "dev"; + }; } // nixpkgs.lib.optionalAttrs (system == "x86_64-linux") { docker = import ./nixos/web.docker.nix { diff --git a/internal/assets/dist/index.css b/internal/assets/dist/index.css index 565e4f1..070996c 100644 --- a/internal/assets/dist/index.css +++ b/internal/assets/dist/index.css @@ -23,6 +23,7 @@ --container-md: 28rem; --container-xl: 36rem; --container-3xl: 48rem; + --container-4xl: 56rem; --container-5xl: 64rem; --container-6xl: 72rem; --container-7xl: 80rem; @@ -46,6 +47,7 @@ --text-6xl--line-height: 1; --text-7xl: 4.5rem; --text-7xl--line-height: 1; + --font-weight-light: 300; --font-weight-medium: 500; --font-weight-semibold: 600; --font-weight-bold: 700; @@ -6760,6 +6762,9 @@ .w-56 { width: calc(var(--spacing) * 56); } + .w-64 { + width: calc(var(--spacing) * 64); + } .w-80 { width: calc(var(--spacing) * 80); } @@ -6775,6 +6780,9 @@ .max-w-3xl { max-width: var(--container-3xl); } + .max-w-4xl { + max-width: var(--container-4xl); + } .max-w-5xl { max-width: var(--container-5xl); } @@ -7736,6 +7744,9 @@ .bg-gray-950 { background-color: var(--color-gray-950); } + .bg-info { + background-color: var(--color-info); + } .bg-neutral { background-color: var(--color-neutral); } @@ -7760,6 +7771,9 @@ .bg-transparent { background-color: transparent; } + .bg-warning { + background-color: var(--color-warning); + } .divider-accent { @layer daisyui.l1.l2 { &:before, &:after { @@ -8222,6 +8236,9 @@ .py-8 { padding-block: calc(var(--spacing) * 8); } + .py-10 { + padding-block: calc(var(--spacing) * 10); + } .py-12 { padding-block: calc(var(--spacing) * 12); } @@ -8511,6 +8528,10 @@ --tw-font-weight: var(--font-weight-bold); font-weight: var(--font-weight-bold); } + .font-light { + --tw-font-weight: var(--font-weight-light); + font-weight: var(--font-weight-light); + } .font-medium { --tw-font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium); @@ -8952,6 +8973,12 @@ .text-base-100 { color: var(--color-base-100); } + .text-base-100\/50 { + color: var(--color-base-100); + @supports (color: color-mix(in lab, red, red)) { + color: color-mix(in oklab, var(--color-base-100) 50%, transparent); + } + } .text-base-content { color: var(--color-base-content); } @@ -9000,6 +9027,12 @@ .text-gray-800 { color: var(--color-gray-800); } + .text-info { + color: var(--color-info); + } + .text-info-content { + color: var(--color-info-content); + } .text-neutral { color: var(--color-neutral); } @@ -9033,6 +9066,9 @@ .text-warning { color: var(--color-warning); } + .text-warning-content { + color: var(--color-warning-content); + } .text-white { color: var(--color-white); } diff --git a/internal/templates/components/branding.tmpl b/internal/templates/components/branding.tmpl index 02438e6..8c71c93 100644 --- a/internal/templates/components/branding.tmpl +++ b/internal/templates/components/branding.tmpl @@ -1,3 +1,7 @@ {{ define "logo" }} Gearberg +{{ end }} + +{{ define "github-icon" }} + {{ end }} \ No newline at end of file diff --git a/internal/templates/components/icons.tmpl b/internal/templates/components/icons.tmpl index fe8271e..9fe38c2 100644 --- a/internal/templates/components/icons.tmpl +++ b/internal/templates/components/icons.tmpl @@ -171,4 +171,8 @@ {{ define "localization-icon" }} +{{ end }} + +{{ define "docs-icon" }} + {{ end }} \ No newline at end of file diff --git a/internal/templates/layouts/docs.tmpl b/internal/templates/layouts/docs.tmpl new file mode 100644 index 0000000..5420068 --- /dev/null +++ b/internal/templates/layouts/docs.tmpl @@ -0,0 +1,39 @@ +{{ define "root" }} + + + + + + {{ block "title" . }}Docs{{ end }} - Gearberg + + + {{ template "styles" . }} + {{ block "seo" . }}{{ end }} + + + {{ template "docs-header" . }} +
+ +
+
+ {{ template "page" . }} +
+
+
+ + +
+
+ {{ template "docs-footer" . }} + + +{{ end }} diff --git a/internal/templates/layouts/layouts.go b/internal/templates/layouts/layouts.go index dd000aa..c839669 100644 --- a/internal/templates/layouts/layouts.go +++ b/internal/templates/layouts/layouts.go @@ -23,10 +23,13 @@ type Layout struct { Partials string } -var ( - // Landing renders the public landing page with a simple full-bleed layout. - Landing = Layout{File: "layouts/landing.tmpl", Partials: "partials/*.tmpl"} +// Landing renders the public landing page with a simple full-bleed layout. +var Landing = Layout{File: "layouts/landing.tmpl", Partials: "partials/*.tmpl"} + +// Docs renders the public docs page with a simple full-bleed layout. +var Docs = Layout{File: "layouts/docs.tmpl", Partials: "partials/docs-*.tmpl"} +var ( // Center renders the full HTML shell with a simple centered body. Center = Layout{File: "layouts/center.tmpl"} diff --git a/internal/templates/pages/docs.tmpl b/internal/templates/pages/docs.tmpl new file mode 100644 index 0000000..6c80ae1 --- /dev/null +++ b/internal/templates/pages/docs.tmpl @@ -0,0 +1,350 @@ +{{ define "title" }}Documentation{{ end }} +{{ define "description" }}Gearberg documentation — quick start, configuration, deployment, and reference.{{ end }} + +{{ define "page" }} + +
+

Getting Started

+ +
+

Prerequisites

+

+ These steps require Docker. + Open a terminal and verify it is installed by running: +

+
+
docker --version
+
+

+ If Docker is not installed, you will see something like docker: command not found. In that case, install Docker first and come back here. +

+
+ +
+

Quick Start

+

Pull the image:

+
+
docker pull ghcr.io/bit8bytes/gearberg
+
+

Try starting the server but it will fail with a clear error:

+
+
docker run -p 8080:8080 ghcr.io/bit8bytes/gearberg serve
+
error: -base-url is required (e.g. -base-url https://gearberg.example.com)
+
+

+ The app will not start without a base URL. + The base URL is used e.g. for password reset links or OIDC callbacks. + Gearberg reports all configuration errors on startup so you can fix them before the server starts. +

+

+ Add the -base-url flag to start the server: +

+
+
docker run -p 8080:8080 ghcr.io/bit8bytes/gearberg serve \
+
  -base-url https://gearberg.example.com
+
+

+ Now, the server is running and you can access the web interface at + http://localhost:8080. + You can stop the server by pressing Ctrl + C in the terminal. +

+

+ Visit http://localhost:8080/signup + to create your first account. The first user to sign up becomes the org admin. +

+

+ The previous code does not persist data across restarts, so any changes you make will be lost when the container is stopped or removed. + To avoid this, mount a named volume so data persists across restarts: +

+
+
docker run -p 8080:8080 -v gearberg-data:/data \
+
  ghcr.io/bit8bytes/gearberg serve \
+
  -base-url https://gearberg.example.com
+
+

Gearberg is up and running and ready to use for testing. For actual deployment, see Docker Compose.

+
+
+ +
+

Configuration

+ +
+

Overview

+

+ Gearberg is configured via command-line flags and environment variables. Flags take precedence over env vars. + All configuration errors are reported on startup and the server will not start with invalid or missing required values. +

+

+ To check your configuration without starting the server, run: +

+
+
docker run -p 8080:8080 ghcr.io/bit8bytes/gearberg check \
+
  -base-url https://gearberg.example.com
+
+ +

Flags

+

Use flags for non-sensitive settings (e.g. log-level):

+
+
docker run -p 8080:8080 ghcr.io/bit8bytes/gearberg serve \
+
  -base-url https://gearberg.example.com \
+
  -log-level info
+
+

Env vars

+

Use environment variables for secrets because flags appear in process listings and shell history:

+
+
docker run -p 8080:8080 \
+
  -e SMTP_PASSWORD=secret \
+
  ghcr.io/bit8bytes/gearberg serve \
+
  -base-url https://gearberg.example.com \
+
  -smtp-host smtp.example.com \
+
  -smtp-username gearberg@example.com \
+
  -smtp-from gearberg@example.com
+
+

+ See All Flags & Env Vars for the complete reference. +

+
+ +
+

OIDC

+ +

+ By default, Gearberg uses email/password login which does not require any configuration. To extend it with SSO, configure one or more OIDC providers. + Currently, only the Authentik provider is supported. +

+ +

Env var format

+
+
OIDC_<NAME>_PROVIDER=name,issuer=URL,client-id=ID,client-secret=SECRET
+
+

+ <NAME> becomes the provider name. + Repeat the variable for multiple providers. Flags take precedence over env vars. + For authentik, the env var would look like this: OIDC_AUTHENTIK_PROVIDER. +

+
+ +
+

SMTP

+

+ Gearberg uses SMTP to send transactional emails such as password reset links and invitations. + SMTP is optional. When -smtp-host is omitted, + emails are silently discarded and no mail is sent. +

+

+ Pass the password via the SMTP_PASSWORD env var and not the flag. +

+

Docker run example

+
+
docker run -p 8080:8080 \
+
  -v gearberg-data:/data \
+
  -e SMTP_PASSWORD=secret \
+
  ghcr.io/bit8bytes/gearberg serve \
+
  -smtp-host smtp.example.com \
+
  -smtp-username gearberg@example.com \
+
  -smtp-from gearberg@example.com
+
+

+ -smtp-port defaults to 587. +

+
+
+ +
+

Deployment

+ +
+

Docker Compose

+

+ The recommended way to run Gearberg in production is via the official Docker image using Docker Compose. +

+

For this, we recommend creating a .env file next to compose.yaml and fill in your secrets:

+
+
printf 'OIDC_AUTHENTIK_PROVIDER=\nSMTP_PASSWORD=\n' > .env
+
+ +

.env

+
+
OIDC_AUTHENTIK_PROVIDER=authentik,issuer=https://auth.example.com/application/o/gearberg/,client-id=<your-client-id>,client-secret=<your-client-secret>
+
SMTP_PASSWORD=<your-smtp-password>
+
+ +

Then create compose.yaml, replacing the highlighted line with your domain:

+

compose.yaml

+
+
services:
+
  gearberg:
+
    image: ghcr.io/bit8bytes/gearberg
+
    command: serve -base-url https://gearberg.example.com
+
    restart: unless-stopped
+
    ports:
+
      - "8080:8080"
+
    volumes:
+
      - gearberg-data:/data
+
    environment:
+
      OIDC_AUTHENTIK_PROVIDER: "${OIDC_AUTHENTIK_PROVIDER}"
+
      SMTP_PASSWORD: "${SMTP_PASSWORD}"
+
    env_file:
+
      - .env
+
+
volumes:
+
  gearberg-data:
+
+ +

Start Gearberg in the background:

+
+
docker compose up -d
+
+ +
+ {{ template "info-icon" . }} +
+

Add a reverse proxy for HTTPS

+

For production, place a reverse proxy in front of Gearberg to terminate TLS. Popular options include + Caddy (automatic HTTPS with zero config), + Traefik (Docker-native with Let's Encrypt), and + nginx (battle-tested, manual cert management). + Point whichever you choose at localhost:8080. +

+
+
+
+
+ +
+

Reference

+ +
+

All Flags & Env Vars

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FlagEnv varDefaultDescription
-base-url—requiredPublic base URL
-db-dsnDB_DSNfile:/data/gearberg.dbDatabase DSN
-storage-dsnSTORAGE_DSNfile:///data/uploadsStorage backend DSN
-port—8080Port to listen on
-log-level—errordebug, info, warn, error
-tls-mode—offoff or local
-tls-cert-path——TLS certificate (required with -tls-mode=local)
-tls-key-pathTLS_KEY_PATH—TLS key (required with -tls-mode=local)
-max-orgs—1Maximum number of orgs
-max-categories—25Equipment categories per org
-max-manufacturers—100Manufacturers per org
-max-locations—100Locations per org
-max-storage-bytes—1073741824 (1 GiB)Storage per org
-oidc-providerOIDC_<NAME>_PROVIDER—OIDC provider (repeatable)
-smtp-host—-SMTP hostname
-smtp-port—587SMTP port
-smtp-username——SMTP username
-smtp-passwordSMTP_PASSWORD—SMTP password
-smtp-from——From address
+
+
+
+ +{{ end }} diff --git a/internal/templates/pages/landing.tmpl b/internal/templates/pages/landing.tmpl index 9778a25..564b0c6 100644 --- a/internal/templates/pages/landing.tmpl +++ b/internal/templates/pages/landing.tmpl @@ -217,13 +217,12 @@ class="absolute top-3 right-3 btn btn-xs btn-neutral" aria-label="Copy command to clipboard" >Copy -
docker run -it -p 8080:8080 nixos/nix \
-  nix --extra-experimental-features "nix-command flakes" \
-  run github:bit8bytes/gearberg -- serve
+
docker run -p 8080:8080 ghcr.io/bit8bytes/gearberg serve \
+
  -base-url http://localhost:8080
diff --git a/internal/templates/pages/pages.go b/internal/templates/pages/pages.go index 2d6639d..d17738d 100644 --- a/internal/templates/pages/pages.go +++ b/internal/templates/pages/pages.go @@ -41,11 +41,14 @@ func newPage(file string, layout layouts.Layout) Page { return p } -// Pages. -var ( - // Landing is the public landing page. - Landing = newPage("pages/landing.tmpl", layouts.Landing) +// Landing is the public landing page. +var Landing = newPage("pages/landing.tmpl", layouts.Landing) + +// Docs is the public docs page. +var Docs = newPage("pages/docs.tmpl", layouts.Docs) +// Web app pages. +var ( // Orgs is the org selector page. Orgs = newPage("pages/orgs/index.tmpl", layouts.Center) // OrgsNew is the create-org form page. diff --git a/internal/templates/partials/docs-footer.tmpl b/internal/templates/partials/docs-footer.tmpl new file mode 100644 index 0000000..86e6423 --- /dev/null +++ b/internal/templates/partials/docs-footer.tmpl @@ -0,0 +1,13 @@ +{{ define "docs-footer" }} + +{{ end }} diff --git a/internal/templates/partials/docs-header.tmpl b/internal/templates/partials/docs-header.tmpl new file mode 100644 index 0000000..43fa4b0 --- /dev/null +++ b/internal/templates/partials/docs-header.tmpl @@ -0,0 +1,17 @@ +{{ define "docs-header" }} + +{{ end }} diff --git a/internal/templates/partials/docs-sidebar.tmpl b/internal/templates/partials/docs-sidebar.tmpl new file mode 100644 index 0000000..567a2b6 --- /dev/null +++ b/internal/templates/partials/docs-sidebar.tmpl @@ -0,0 +1,18 @@ +{{ define "docs-sidebar" }} + +{{ end }} \ No newline at end of file diff --git a/internal/templates/partials/landing-header.tmpl b/internal/templates/partials/landing-header.tmpl index 74bd617..2278903 100644 --- a/internal/templates/partials/landing-header.tmpl +++ b/internal/templates/partials/landing-header.tmpl @@ -5,7 +5,16 @@ diff --git a/make/build.mk b/make/build.mk index 393027b..42adc14 100644 --- a/make/build.mk +++ b/make/build.mk @@ -8,6 +8,11 @@ nix/build/web: nix/build/www: nix build .#www +## nix/build/docs: build the Go binary with Nix +.PHONY: nix/build/docs +nix/build/docs: + nix build .#docs + ## build: build the application .PHONY: build build: diff --git a/make/dev.mk b/make/dev.mk index efa37e2..3a61dc8 100644 --- a/make/dev.mk +++ b/make/dev.mk @@ -1,7 +1,7 @@ -## run/web/verify: check configuration and connectivity -.PHONY: run/web/verify -run/web/verify: - go run -tags sqlite ./cmd/web verify +## run/web/check: check configuration and connectivity +.PHONY: run/web/check +run/web/check: + go run -tags sqlite ./cmd/web check ## run/web: run the web application with live reload .PHONY: run/web @@ -16,6 +16,11 @@ run/web: run/www: reflex -s -r '\.(go|tmpl|js)$$' -- go run -tags sqlite ./cmd/www serve +## run/docs: run the docs application with live reload +.PHONY: run/docs +run/docs: + reflex -s -r '\.(go|tmpl|js)$$' -- go run -tags sqlite ./cmd/docs + ## dev/web: start development server (web + tailwind watch) .PHONY: dev/web dev/web: @@ -26,6 +31,11 @@ dev/web: dev/www: $(MAKE) -j2 tailwind run/www +## dev/docs: start development server (docs + tailwind watch) +.PHONY: dev/docs +dev/docs: + $(MAKE) -j2 tailwind run/docs + ## tailwind: run Tailwind CSS in watch mode .PHONY: tailwind tailwind: diff --git a/nixos/docs.nix b/nixos/docs.nix new file mode 100644 index 0000000..1c91d2a --- /dev/null +++ b/nixos/docs.nix @@ -0,0 +1,42 @@ +{ + buildpkgs, + version, + pname ? "gearberg-docs", +}: +buildpkgs.buildGoModule { + pname = pname; + version = version; + src = ./..; + + vendorHash = "sha256-2euSX31xv24c5w9XcYJmKR2xM0PuegN6obGaNOr1g+E="; + + subPackages = ["cmd/docs"]; + + ldflags = [ + "-s" + "-w" + "-X main.revision=${version}" + ]; + tags = ["sqlite"]; + + nativeBuildInputs = [buildpkgs.tailwindcss_4]; + + preBuild = '' + tailwindcss -i ./internal/assets/css/index.css -o ./internal/assets/dist/index.css --minify + ''; + + env = { + CGO_ENABLED = "0"; + }; + + doCheck = true; + checkFlags = ["-short"]; + + meta = with buildpkgs.lib; { + description = "Documentation page for gearberg.org."; + homepage = "https://gearberg.org"; + maintainers = with maintainers; [ + tobiasgleiter + ]; + }; +} diff --git a/nixos/web.docker.nix b/nixos/web.docker.nix index afb54ad..73ea700 100644 --- a/nixos/web.docker.nix +++ b/nixos/web.docker.nix @@ -27,7 +27,7 @@ in # in the docker run command or in a docker-compose file. Entrypoint = ["${gearberg}/bin/gearberg"]; # Default subcommand. Override by passing a different command to docker run, - # e.g. `docker run ghcr.io/bit8bytes/gearberg verify`. + # e.g. `docker run ghcr.io/bit8bytes/gearberg check`. # See `gearberg --help` for all available subcommands. Cmd = ["serve"]; # curl is pulled in via the Nix closure this is why there is no need to add it to copyToRoot. diff --git a/pkg/mailer/mailer.go b/pkg/mailer/mailer.go index ea7a3d1..1f4e804 100644 --- a/pkg/mailer/mailer.go +++ b/pkg/mailer/mailer.go @@ -4,7 +4,6 @@ package mailer import ( "context" "fmt" - "log/slog" "net" "net/smtp" "strconv" @@ -48,11 +47,10 @@ func (m *SMTP) Mail(_ context.Context, to, subject, body string) error { return nil } -// LogMailer is a no-op mailer that logs emails instead of sending them. +// LogMailer is a no-op mailer that discards emails silently. type LogMailer struct{} -// Mail logs the email details and returns nil. -func (LogMailer) Mail(_ context.Context, to, subject, body string) error { - slog.Info("mailer: email suppressed", "to", to, "subject", subject, "body", body) +// Mail discards the email and returns nil. +func (LogMailer) Mail(_ context.Context, _, _, _ string) error { return nil }