Phase 2: SQLite persistence, admin setup-link, login/sessions

Implements the secure single-admin panel entry (spec 7.6).

- internal/store: modernc.org/sqlite (pure Go, static build), WAL +
  foreign keys, embedded PRAGMA user_version migrations; schema 0001
  covers admin/settings/domains/applications/send_log/rate_limits (spec 9).
- Setup secret-link (spec 7.6.1): 128-bit crypto/rand token, printed to
  log + /data/setup-token (0600), 10-min TTL with regeneration, per-IP
  rate limit, subtle.ConstantTimeCompare, failures don't invalidate,
  one-time admin form, permanent invalidation once admin exists (/setup 404).
- bcrypt admin password; server-side username/password validation.
- Login + in-memory sessions, crypto-random token, cookie
  HttpOnly/Secure/SameSite (Secure toggleable for dev HTTP), login
  rate limit, auth middleware.
- html/template base layout + setup/login/dashboard, vendored htmx 2.0.4.
- build/entrypoint.sh: fix bind-mounted /data ownership as root before
  supervisord drops to the unprivileged panel user (found via container test).

Verified on selfpost.example.com: go vet/build/test/gofmt clean; e2e curl
of setup+login flows; docker build + run with -v ./data:/data creates the
DB and 0600 token owned by panel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-11 21:24:09 +03:00
parent 85f3374ce1
commit d72a383904
25 changed files with 1248 additions and 50 deletions
+113
View File
@@ -0,0 +1,113 @@
package web
import (
"errors"
"net/http"
"strings"
"codeberg.org/mix/selfpost/internal/store"
"golang.org/x/crypto/bcrypt"
)
// sessionCookie is the name of the panel session cookie.
const sessionCookie = "selfpost_session"
// handleLogin serves the login form (GET) and authenticates (POST). Until an
// administrator exists there is nobody to log in, so it points at setup.
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
exists, err := s.store.AdminExists()
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if !exists {
// No admin yet: login is meaningless. Send a clear message rather than
// a failing form.
s.render(w, http.StatusOK, "login", map[string]any{
"Title": "SelfPost — Sign in",
"SetupHint": true,
})
return
}
switch r.Method {
case http.MethodGet:
s.renderLogin(w, http.StatusOK, "")
case http.MethodPost:
s.submitLogin(w, r)
default:
w.Header().Set("Allow", "GET, POST")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (s *Server) renderLogin(w http.ResponseWriter, status int, formErr string) {
s.render(w, status, "login", map[string]any{
"Title": "SelfPost — Sign in",
"Error": formErr,
})
}
func (s *Server) submitLogin(w http.ResponseWriter, r *http.Request) {
// Brute-force throttle by client IP (spec 7.6.5).
if !s.loginLimiter.Allow(clientIP(r)) {
s.renderLogin(w, http.StatusTooManyRequests, "Too many attempts. Please wait and try again.")
return
}
if err := r.ParseForm(); err != nil {
s.renderLogin(w, http.StatusBadRequest, "Invalid form submission.")
return
}
username := strings.TrimSpace(r.PostFormValue("username"))
password := r.PostFormValue("password")
admin, err := s.store.GetAdmin()
if err != nil {
if !errors.Is(err, store.ErrNoAdmin) {
logf("panel: login: get admin failed: %v", err)
}
s.renderLogin(w, http.StatusUnauthorized, "Invalid username or password.")
return
}
// Always run bcrypt so timing does not distinguish "wrong user" from
// "wrong password", and compare the username too.
pwErr := bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(password))
if username != admin.Username || pwErr != nil {
s.renderLogin(w, http.StatusUnauthorized, "Invalid username or password.")
return
}
token := s.sessions.Create(admin.Username)
http.SetCookie(w, &http.Cookie{
Name: sessionCookie,
Value: token,
Path: "/",
HttpOnly: true,
Secure: s.cfg.CookieSecure,
SameSite: http.SameSiteLaxMode,
})
http.Redirect(w, r, "/", http.StatusSeeOther)
}
// handleLogout destroys the session and clears the cookie.
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.Header().Set("Allow", "POST")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if c, err := r.Cookie(sessionCookie); err == nil {
s.sessions.Destroy(c.Value)
}
http.SetCookie(w, &http.Cookie{
Name: sessionCookie,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: s.cfg.CookieSecure,
SameSite: http.SameSiteLaxMode,
})
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
+98
View File
@@ -0,0 +1,98 @@
package web
import (
"net/http"
"strings"
"golang.org/x/crypto/bcrypt"
)
// handleSetup serves the one-time administrator creation flow at
// /setup/<token> (spec 7.6.1). Once an administrator exists the whole route
// returns 404; an invalid or expired token is indistinguishable from a missing
// page, also 404.
func (s *Server) handleSetup(w http.ResponseWriter, r *http.Request) {
// Route-specific rate limit, separate from login (spec 7.6.1).
if !s.setupLimiter.Allow(clientIP(r)) {
http.Error(w, "too many requests", http.StatusTooManyRequests)
return
}
token := strings.TrimPrefix(r.URL.Path, "/setup/")
// Reject nested/garbage paths outright.
if token == "" || strings.Contains(token, "/") {
http.NotFound(w, r)
return
}
if !s.setup.validate(token) {
http.NotFound(w, r)
return
}
switch r.Method {
case http.MethodGet:
s.renderSetupForm(w, http.StatusOK, token, "")
case http.MethodPost:
s.submitSetup(w, r, token)
default:
w.Header().Set("Allow", "GET, POST")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (s *Server) renderSetupForm(w http.ResponseWriter, status int, token, formErr string) {
s.render(w, status, "setup", map[string]any{
"Title": "SelfPost — Create administrator",
"Token": token,
"Error": formErr,
})
}
func (s *Server) submitSetup(w http.ResponseWriter, r *http.Request, token string) {
if err := r.ParseForm(); err != nil {
s.renderSetupForm(w, http.StatusBadRequest, token, "Invalid form submission.")
return
}
username := strings.TrimSpace(r.PostFormValue("username"))
password := r.PostFormValue("password")
confirm := r.PostFormValue("password_confirm")
if err := validateUsername(username); err != nil {
s.renderSetupForm(w, http.StatusBadRequest, token, err.Error())
return
}
if password != confirm {
s.renderSetupForm(w, http.StatusBadRequest, token, "Passwords do not match.")
return
}
if err := validateAdminPassword(password); err != nil {
s.renderSetupForm(w, http.StatusBadRequest, token, err.Error())
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
logf("panel: setup: hashing password failed: %v", err)
s.renderSetupForm(w, http.StatusInternalServerError, token, "Internal error. Please try again.")
return
}
if err := s.store.CreateAdmin(username, string(hash)); err != nil {
// A concurrent submission may have already created the admin; the
// id=1 / non-empty-table guard makes this the second writer. Treat it
// as "setup already done" rather than an error.
if exists, _ := s.store.AdminExists(); exists {
s.setup.complete()
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
logf("panel: setup: create admin failed: %v", err)
s.renderSetupForm(w, http.StatusInternalServerError, token, "Internal error. Please try again.")
return
}
// Setup is now permanently complete: burn the token (spec 7.6.1).
s.setup.complete()
logf("panel: administrator %q created; setup link is now disabled", username)
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
+51
View File
@@ -0,0 +1,51 @@
package web
import (
"context"
"net/http"
)
type ctxKey int
const usernameKey ctxKey = 0
// requireAuth wraps a handler so only requests with a valid session cookie
// reach it; everyone else is redirected to the login page. The authenticated
// username is stashed in the request context for downstream handlers.
func (s *Server) requireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
c, err := r.Cookie(sessionCookie)
if err != nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
username, ok := s.sessions.Lookup(c.Value)
if !ok {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
ctx := context.WithValue(r.Context(), usernameKey, username)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// currentUser returns the authenticated username from the request context.
func currentUser(r *http.Request) string {
if v, ok := r.Context().Value(usernameKey).(string); ok {
return v
}
return ""
}
// handleDashboard is the authenticated landing page. Phase 2 shows a minimal
// shell; domains, applications and the send log arrive in later phases.
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
s.render(w, http.StatusOK, "dashboard", map[string]any{
"Title": "SelfPost",
"User": currentUser(r),
})
}
+62
View File
@@ -0,0 +1,62 @@
package web
import (
"sync"
"time"
)
// rateLimiter is a simple fixed-window per-key counter used to throttle the
// setup and login routes (spec 7.6.1, 7.6.5). Keys are client IPs. It is not a
// precise sliding window — a coarse backstop against brute-force and log noise
// is all these routes need.
type rateLimiter struct {
max int
window time.Duration
mu sync.Mutex
buckets map[string]*rlBucket
}
type rlBucket struct {
count int
windowEnds time.Time
}
func newRateLimiter(max int, window time.Duration) *rateLimiter {
return &rateLimiter{
max: max,
window: window,
buckets: make(map[string]*rlBucket),
}
}
// Allow records an attempt for key and reports whether it is within the limit.
// The current window is reset lazily once it elapses.
func (r *rateLimiter) Allow(key string) bool {
now := time.Now()
r.mu.Lock()
defer r.mu.Unlock()
b := r.buckets[key]
if b == nil || now.After(b.windowEnds) {
r.buckets[key] = &rlBucket{count: 1, windowEnds: now.Add(r.window)}
r.sweep(now)
return true
}
if b.count >= r.max {
return false
}
b.count++
return true
}
// sweep drops expired buckets so the map cannot grow without bound. Called
// under the lock while a window is being reset, which is often enough given the
// low request volume of these routes.
func (r *rateLimiter) sweep(now time.Time) {
for k, b := range r.buckets {
if now.After(b.windowEnds) {
delete(r.buckets, k)
}
}
}
+62
View File
@@ -0,0 +1,62 @@
package web
import (
"sync"
"time"
)
// sessionTTL bounds how long a login lasts before re-authentication is needed.
const sessionTTL = 12 * time.Hour
// sessionStore keeps active sessions in memory. Sessions are deliberately not
// persisted (spec 9 lists what must survive restart; sessions are not on it):
// a restart simply logs the admin out, which is acceptable and avoids storing
// bearer tokens on disk. Tokens are crypto-random (spec 7.6.6).
type sessionStore struct {
mu sync.Mutex
sessions map[string]session
}
type session struct {
username string
expiresAt time.Time
}
func newSessionStore() *sessionStore {
return &sessionStore{sessions: make(map[string]session)}
}
// Create issues a new session for username and returns its token.
func (s *sessionStore) Create(username string) string {
token := randomToken(32)
s.mu.Lock()
defer s.mu.Unlock()
s.sessions[token] = session{username: username, expiresAt: time.Now().Add(sessionTTL)}
return token
}
// Lookup returns the session username for a token if it exists and is unexpired.
func (s *sessionStore) Lookup(token string) (string, bool) {
if token == "" {
return "", false
}
now := time.Now()
s.mu.Lock()
defer s.mu.Unlock()
sess, ok := s.sessions[token]
if !ok {
return "", false
}
if now.After(sess.expiresAt) {
delete(s.sessions, token)
return "", false
}
return sess.username, true
}
// Destroy invalidates a session token (logout).
func (s *sessionStore) Destroy(token string) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.sessions, token)
}
+136
View File
@@ -0,0 +1,136 @@
package web
import (
"crypto/subtle"
"fmt"
"os"
"sync"
"time"
"codeberg.org/mix/selfpost/internal/store"
)
// setupTokenTTL is the lifetime of a setup token (spec 7.6.1). After it
// elapses the token is regenerated and re-announced on the next /setup hit.
const setupTokenTTL = 10 * time.Minute
// setupManager owns the one-time administrator setup token. The token itself is
// ephemeral (regenerated on restart or expiry) and lives only in memory; the
// persistent "setup complete" fact is the presence of the admin row in the
// store, so once that exists the token is gone for good (spec 7.6.1).
type setupManager struct {
store *store.Store
hostname string
tokenPath string
mu sync.Mutex
token string
expiresAt time.Time
}
func newSetupManager(st *store.Store, hostname, tokenPath string) *setupManager {
return &setupManager{store: st, hostname: hostname, tokenPath: tokenPath}
}
// bootstrap runs once at startup. If setup is already complete it clears any
// stale token file; otherwise it mints and announces the first token.
func (m *setupManager) bootstrap() error {
done, err := m.store.AdminExists()
if err != nil {
return err
}
if done {
m.clearTokenFile()
return nil
}
m.mu.Lock()
defer m.mu.Unlock()
m.regenerateLocked()
return nil
}
// activeToken returns the current valid setup token, regenerating and
// re-announcing it if none exists or it has expired. It returns ("", false)
// once setup is complete — callers must treat that as "route gone" (404).
func (m *setupManager) activeToken() (string, bool) {
done, err := m.store.AdminExists()
if err != nil {
logf("panel: setup: admin check failed: %v", err)
return "", false
}
if done {
return "", false
}
m.mu.Lock()
defer m.mu.Unlock()
if m.token == "" || time.Now().After(m.expiresAt) {
m.regenerateLocked()
}
return m.token, true
}
// validate reports whether provided matches the active token, using a
// constant-time comparison to avoid leaking a correct prefix via timing
// (spec 7.6.1). A mismatch does NOT regenerate or invalidate the token: failed
// attempts must not let an attacker DoS a legitimate setup (spec 7.6.1).
func (m *setupManager) validate(provided string) bool {
token, ok := m.activeToken()
if !ok {
return false
}
return subtle.ConstantTimeCompare([]byte(provided), []byte(token)) == 1
}
// complete marks setup as finished: the admin row now exists, so drop the
// in-memory token and remove the on-disk copy.
func (m *setupManager) complete() {
m.mu.Lock()
m.token = ""
m.expiresAt = time.Time{}
m.mu.Unlock()
m.clearTokenFile()
}
// regenerateLocked mints a fresh token, announces it and mirrors it to disk.
// Caller holds m.mu.
func (m *setupManager) regenerateLocked() {
m.token = randomToken(16) // 128 bits of entropy (spec 7.6.1)
m.expiresAt = time.Now().Add(setupTokenTTL)
m.announce(m.token)
}
// announce prints the setup link to the container log and writes it to the
// token file so it can be read either way (spec 7.6.1).
func (m *setupManager) announce(token string) {
url := m.setupURL(token)
logf("panel: ==================================================================")
logf("panel: SelfPost first-run setup — open this one-time link within %s:", setupTokenTTL)
logf("panel: %s", url)
logf("panel: (also written to %s)", m.tokenPath)
logf("panel: ==================================================================")
if m.tokenPath == "" {
return
}
// 0600: the token is a bearer secret for creating the admin.
if err := os.WriteFile(m.tokenPath, []byte(url+"\n"), 0o600); err != nil {
logf("panel: setup: could not write token file %s: %v", m.tokenPath, err)
}
}
func (m *setupManager) setupURL(token string) string {
host := m.hostname
if host == "" {
host = "localhost"
}
return fmt.Sprintf("https://%s/setup/%s", host, token)
}
func (m *setupManager) clearTokenFile() {
if m.tokenPath == "" {
return
}
if err := os.Remove(m.tokenPath); err != nil && !os.IsNotExist(err) {
logf("panel: setup: could not remove token file %s: %v", m.tokenPath, err)
}
}
File diff suppressed because one or more lines are too long
+54
View File
@@ -0,0 +1,54 @@
package web
import (
"bytes"
"fmt"
"html/template"
"net/http"
)
// templates holds the parsed page templates. Each page is parsed together with
// the shared base layout so {{ template "base" . }} works. Rendering goes
// through html/template, which auto-escapes all interpolated data (spec 7.6.7).
type templates struct {
pages map[string]*template.Template
}
// pageFiles maps a logical page name to its template file. Every page composes
// with layout.html.
var pageFiles = map[string]string{
"setup": "templates/setup.html",
"login": "templates/login.html",
"dashboard": "templates/dashboard.html",
}
func loadTemplates() (*templates, error) {
t := &templates{pages: make(map[string]*template.Template)}
for name, file := range pageFiles {
tmpl, err := template.New("layout.html").ParseFS(assetsFS, "templates/layout.html", file)
if err != nil {
return nil, fmt.Errorf("parse template %s: %w", name, err)
}
t.pages[name] = tmpl
}
return t, nil
}
// render writes a page using the base layout. Rendering to a buffer first means
// a template error yields a clean 500 instead of a half-written page.
func (s *Server) render(w http.ResponseWriter, status int, page string, data any) {
tmpl, ok := s.tmpl.pages[page]
if !ok {
http.Error(w, "template not found", http.StatusInternalServerError)
return
}
var buf bytes.Buffer
if err := tmpl.ExecuteTemplate(&buf, "layout.html", data); err != nil {
logf("panel: render %s: %v", page, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
_, _ = buf.WriteTo(w)
}
+15
View File
@@ -0,0 +1,15 @@
{{define "content"}}
<div class="topbar">
<h1>SelfPost</h1>
<div class="muted">
{{.User}} &middot;
<form class="inline" method="post" action="/logout">
<button type="submit">Sign out</button>
</form>
</div>
</div>
<div class="card">
<p>You are signed in. Domains, applications and the send log arrive in the
next phases.</p>
</div>
{{end}}
+50
View File
@@ -0,0 +1,50 @@
{{define "layout.html"}}<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}}</title>
<script src="/static/htmx.min.js" defer></script>
<style>
:root { color-scheme: light dark; }
* { box-sizing: border-box; }
body {
font: 15px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
margin: 0; padding: 2rem 1rem; background: #f6f7f9; color: #1b1f24;
}
@media (prefers-color-scheme: dark) {
body { background: #14171a; color: #e6e8eb; }
.card { background: #1d2125 !important; border-color: #2b3138 !important; }
input { background: #14171a !important; color: inherit !important; border-color: #2b3138 !important; }
}
main { max-width: 42rem; margin: 0 auto; }
h1 { font-size: 1.4rem; margin: 0 0 1rem; }
.card {
background: #fff; border: 1px solid #e2e5e9; border-radius: 10px;
padding: 1.5rem; margin: 0 auto;
}
.card.narrow { max-width: 24rem; }
label { display: block; font-weight: 600; margin: 0.9rem 0 0.3rem; }
input {
width: 100%; padding: 0.55rem 0.7rem; font-size: 1rem;
border: 1px solid #cfd4da; border-radius: 6px; background: #fff;
}
button {
margin-top: 1.2rem; padding: 0.6rem 1.1rem; font-size: 1rem; font-weight: 600;
color: #fff; background: #2563eb; border: 0; border-radius: 6px; cursor: pointer;
}
button:hover { background: #1d4ed8; }
.error { color: #b42318; margin: 0.6rem 0 0; font-weight: 600; }
.muted { color: #6b7280; }
.topbar { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 1.2rem; }
form.inline { display: inline; margin: 0; }
form.inline button { background: none; color: #2563eb; padding: 0; margin: 0; font-weight: 600; }
form.inline button:hover { text-decoration: underline; background: none; }
</style>
</head>
<body>
<main>
{{template "content" .}}
</main>
</body>
</html>{{end}}
+20
View File
@@ -0,0 +1,20 @@
{{define "content"}}
<h1>Sign in</h1>
<div class="card narrow">
{{if .SetupHint}}
<p class="muted">No administrator has been created yet. Open the one-time
setup link printed in the container log to get started.</p>
{{else}}
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
<form method="post" action="/login">
<label for="username">Username</label>
<input id="username" name="username" autocomplete="username" autofocus required>
<label for="password">Password</label>
<input id="password" name="password" type="password" autocomplete="current-password" required>
<button type="submit">Sign in</button>
</form>
{{end}}
</div>
{{end}}
+20
View File
@@ -0,0 +1,20 @@
{{define "content"}}
<h1>Create administrator</h1>
<div class="card narrow">
<p class="muted">This one-time link creates the single panel administrator.
After you submit, the link stops working for good.</p>
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
<form method="post" action="/setup/{{.Token}}">
<label for="username">Username</label>
<input id="username" name="username" autocomplete="username" autofocus required>
<label for="password">Password</label>
<input id="password" name="password" type="password" autocomplete="new-password" required>
<label for="password_confirm">Confirm password</label>
<input id="password_confirm" name="password_confirm" type="password" autocomplete="new-password" required>
<button type="submit">Create administrator</button>
</form>
</div>
{{end}}
+20
View File
@@ -0,0 +1,20 @@
package web
import (
"crypto/rand"
"encoding/base64"
)
// randomToken returns a URL-safe token with at least nBytes*8 bits of entropy
// drawn from crypto/rand. Setup and session tokens both use this; the setup
// token needs >=128 bits (spec 7.6.1), so callers pass nBytes >= 16.
//
// It panics if the system RNG fails: that is unrecoverable and must never be
// papered over with a weak fallback for a security token.
func randomToken(nBytes int) string {
b := make([]byte, nBytes)
if _, err := rand.Read(b); err != nil {
panic("crypto/rand failed: " + err.Error())
}
return base64.RawURLEncoding.EncodeToString(b)
}
+42
View File
@@ -0,0 +1,42 @@
package web
import (
"fmt"
"unicode"
)
// minAdminPasswordLen is the floor for the administrator password. The panel is
// public (spec 7.6), so this is deliberately not tiny.
const minAdminPasswordLen = 12
const (
minUsernameLen = 3
maxUsernameLen = 64
)
// validateUsername enforces a strict server-side whitelist (spec 7.6.2):
// letters, digits, dot, dash, underscore. Client validation is never trusted.
func validateUsername(u string) error {
if len(u) < minUsernameLen || len(u) > maxUsernameLen {
return fmt.Errorf("username must be %d-%d characters", minUsernameLen, maxUsernameLen)
}
for _, r := range u {
if r > unicode.MaxASCII || (!isASCIILetterOrDigit(r) && r != '.' && r != '-' && r != '_') {
return fmt.Errorf("username may contain only letters, digits, '.', '-' and '_'")
}
}
return nil
}
// validateAdminPassword enforces a minimum length. Composition rules beyond
// length tend to reduce entropy in practice, so length is the sole gate.
func validateAdminPassword(p string) error {
if len(p) < minAdminPasswordLen {
return fmt.Errorf("password must be at least %d characters", minAdminPasswordLen)
}
return nil
}
func isASCIILetterOrDigit(r rune) bool {
return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')
}
+115
View File
@@ -0,0 +1,115 @@
// Package web implements the SelfPost control panel's HTTP surface: the
// one-time administrator setup flow (spec 7.6.1), login/session handling
// (spec 7.6.5-6) and the authenticated shell the later phases build on.
package web
import (
"embed"
"log"
"net"
"net/http"
"time"
"codeberg.org/mix/selfpost/internal/store"
)
//go:embed templates/*.html static/*
var assetsFS embed.FS
// Config holds the panel's HTTP-facing configuration.
type Config struct {
// Hostname is the server's external hostname, used to build the absolute
// setup link shown in the logs (spec 7.6.1, 8: SELFPOST_HOSTNAME).
Hostname string
// CookieSecure sets the Secure attribute on the session cookie. It defaults
// to true (spec 7.6.6); it exists as a knob only so the panel can be tested
// over plain HTTP in development, never for production.
CookieSecure bool
}
// Server is the panel HTTP application.
type Server struct {
store *store.Store
cfg Config
tmpl *templates
sessions *sessionStore
setup *setupManager
loginLimiter *rateLimiter
setupLimiter *rateLimiter
}
// New builds the panel server. setupTokenPath is where the current setup token
// is mirrored on disk (spec 7.6.1).
func New(st *store.Store, cfg Config, setupTokenPath string) (*Server, error) {
tmpl, err := loadTemplates()
if err != nil {
return nil, err
}
s := &Server{
store: st,
cfg: cfg,
tmpl: tmpl,
sessions: newSessionStore(),
// Setup: a handful of attempts per minute per IP is plenty for a
// legitimate admin and blunts automated probing (spec 7.6.1).
setupLimiter: newRateLimiter(10, time.Minute),
// Login: throttle brute-force by IP (spec 7.6.5).
loginLimiter: newRateLimiter(10, 15*time.Minute),
}
s.setup = newSetupManager(st, cfg.Hostname, setupTokenPath)
return s, nil
}
// Start performs first-run bootstrapping: if there is no administrator yet, it
// generates and announces the setup link (spec 7.6.1). Safe to call once at
// server startup.
func (s *Server) Start() error {
return s.setup.bootstrap()
}
// Handler returns the panel's HTTP handler (router).
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
// Health check stays unauthenticated for the container/orchestrator.
mux.HandleFunc("/healthz", handleHealth)
// Vendored static assets (HTMX). Served from the embedded FS.
mux.Handle("/static/", http.FileServer(http.FS(assetsFS)))
// One-time administrator setup (spec 7.6.1).
mux.HandleFunc("/setup/", s.handleSetup)
// Authentication.
mux.HandleFunc("/login", s.handleLogin)
mux.HandleFunc("/logout", s.handleLogout)
// Authenticated panel.
mux.Handle("/", s.requireAuth(http.HandlerFunc(s.handleDashboard)))
return mux
}
func handleHealth(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok\n"))
}
// clientIP extracts the peer IP for rate-limiting. It uses the transport peer
// (RemoteAddr), not client-supplied headers, so it cannot be spoofed; behind a
// reverse proxy this is the proxy address, which is an acceptable backstop for
// a single-admin panel.
func clientIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
// logf is a thin wrapper so handlers log with a consistent prefix.
func logf(format string, args ...any) {
log.Printf(format, args...)
}