Prototype commit
Some checks are pending
check / frontend (push) Waiting to run
check / server (push) Waiting to run

This commit is contained in:
Pen Anderson 2026-05-05 09:29:40 -05:00
commit 9884ccd9ed
29 changed files with 3438 additions and 0 deletions

97
server/auth.go Normal file
View file

@ -0,0 +1,97 @@
package main
import (
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/json"
"net/http"
"strings"
"time"
)
const (
cookieName = "docent_session"
cookieMaxAge = 30 * 24 * time.Hour
)
type session struct {
Exp int64 `json:"exp"`
}
func (s *server) signCookieValue() (string, error) {
payload := session{Exp: time.Now().Add(cookieMaxAge).Unix()}
data, err := json.Marshal(payload)
if err != nil {
return "", err
}
enc := base64.RawURLEncoding.EncodeToString(data)
mac := hmac.New(sha256.New, s.secret)
mac.Write([]byte(enc))
sig := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
return enc + "." + sig, nil
}
func (s *server) verifyCookieValue(value string) bool {
parts := strings.SplitN(value, ".", 2)
if len(parts) != 2 {
return false
}
mac := hmac.New(sha256.New, s.secret)
mac.Write([]byte(parts[0]))
expected := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(expected), []byte(parts[1])) {
return false
}
data, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return false
}
var sess session
if err := json.Unmarshal(data, &sess); err != nil {
return false
}
return time.Now().Unix() < sess.Exp
}
func (s *server) setSessionCookie(w http.ResponseWriter) {
value, err := s.signCookieValue()
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
http.SetCookie(w, &http.Cookie{
Name: cookieName,
Value: value,
Path: "/",
MaxAge: int(cookieMaxAge.Seconds()),
HttpOnly: true,
Secure: s.secure,
SameSite: http.SameSiteLaxMode,
})
}
func (s *server) clearSessionCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: cookieName,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: s.secure,
SameSite: http.SameSiteLaxMode,
})
}
func (s *server) isAuthed(r *http.Request) bool {
c, err := r.Cookie(cookieName)
if err != nil {
return false
}
return s.verifyCookieValue(c.Value)
}
func constantTimeEqualString(a, b string) bool {
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
}

3
server/go.mod Normal file
View file

@ -0,0 +1,3 @@
module pentacle.games/docent
go 1.23

213
server/main.go Normal file
View file

@ -0,0 +1,213 @@
package main
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"flag"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"time"
)
type server struct {
staticDir string
password string
qrKey string
secret []byte
secure bool
loginLimiter *rateLimiter
}
func main() {
addr := flag.String("addr", ":8181", "listen address")
staticDir := flag.String("static", "../web/build", "static asset directory")
insecure := flag.Bool("insecure", false, "drop Secure flag from cookie (dev only)")
strict := flag.Bool("strict", false, "production mode: refuse to start without DOCENT_COOKIE_SECRET and DOCENT_QR_KEY")
flag.Parse()
password := os.Getenv("DOCENT_PASSWORD")
if password == "" {
log.Fatal("DOCENT_PASSWORD must be set")
}
qrKey := os.Getenv("DOCENT_QR_KEY")
if qrKey == "" {
if *strict {
log.Fatal("DOCENT_QR_KEY must be set in -strict mode")
}
log.Println("warning: DOCENT_QR_KEY not set; QR-code auto-login disabled")
}
cookieSecret := os.Getenv("DOCENT_COOKIE_SECRET")
if *strict && cookieSecret == "" {
log.Fatal("DOCENT_COOKIE_SECRET must be set in -strict mode")
}
secret, err := loadOrGenSecret(cookieSecret)
if err != nil {
log.Fatalf("DOCENT_COOKIE_SECRET: %v", err)
}
abs, err := filepath.Abs(*staticDir)
if err != nil {
log.Fatalf("static dir: %v", err)
}
if _, err := os.Stat(abs); err != nil {
log.Printf("warning: static dir %s not found yet (build the SvelteKit app first)", abs)
}
s := &server{
staticDir: abs,
password: password,
qrKey: qrKey,
secret: secret,
secure: !*insecure,
loginLimiter: newRateLimiter(8, time.Minute),
}
mux := http.NewServeMux()
mux.HandleFunc("POST /api/login", s.handleLogin)
mux.HandleFunc("POST /api/key", s.handleKey)
mux.HandleFunc("POST /api/logout", s.handleLogout)
mux.HandleFunc("GET /api/me", s.handleMe)
mux.Handle("GET /audio/", s.requireAuth(http.HandlerFunc(s.serveStatic)))
mux.Handle("GET /images/", s.requireAuth(http.HandlerFunc(s.serveStatic)))
mux.HandleFunc("GET /", s.serveSPA)
log.Printf("docent listening on %s, serving %s", *addr, abs)
if err := http.ListenAndServe(*addr, mux); err != nil {
log.Fatal(err)
}
}
func loadOrGenSecret(env string) ([]byte, error) {
if env != "" {
b, err := base64.StdEncoding.DecodeString(env)
if err != nil {
return nil, err
}
if len(b) < 32 {
return nil, errShortSecret
}
return b, nil
}
log.Println("warning: DOCENT_COOKIE_SECRET not set, generating ephemeral secret (sessions reset on restart)")
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return nil, err
}
return b, nil
}
var errShortSecret = &cookieError{"DOCENT_COOKIE_SECRET must decode to at least 32 bytes"}
type cookieError struct{ msg string }
func (e *cookieError) Error() string { return e.msg }
func (s *server) handleLogin(w http.ResponseWriter, r *http.Request) {
if !s.loginLimiter.allow(clientIP(r)) {
w.Header().Set("Retry-After", "60")
http.Error(w, "too many attempts", http.StatusTooManyRequests)
return
}
var body struct {
Password string `json:"password"`
}
err := json.NewDecoder(r.Body).Decode(&body)
if err != nil || !constantTimeEqualString(body.Password, s.password) {
time.Sleep(250 * time.Millisecond)
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
s.setSessionCookie(w)
w.WriteHeader(http.StatusNoContent)
}
func (s *server) handleKey(w http.ResponseWriter, r *http.Request) {
if !s.loginLimiter.allow(clientIP(r)) {
w.Header().Set("Retry-After", "60")
http.Error(w, "too many attempts", http.StatusTooManyRequests)
return
}
var body struct {
Key string `json:"key"`
}
err := json.NewDecoder(r.Body).Decode(&body)
// An empty qrKey means the QR auto-login is disabled — never accept any value.
if err != nil || s.qrKey == "" || !constantTimeEqualString(body.Key, s.qrKey) {
time.Sleep(250 * time.Millisecond)
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
s.setSessionCookie(w)
w.WriteHeader(http.StatusNoContent)
}
func (s *server) handleLogout(w http.ResponseWriter, r *http.Request) {
s.clearSessionCookie(w)
w.WriteHeader(http.StatusNoContent)
}
func (s *server) handleMe(w http.ResponseWriter, r *http.Request) {
if !s.isAuthed(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"ok":true}`))
}
func (s *server) requireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !s.isAuthed(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
// serveStatic serves files relative to staticDir, refusing path traversal.
func (s *server) serveStatic(w http.ResponseWriter, r *http.Request) {
clean := filepath.Clean(r.URL.Path)
if strings.Contains(clean, "..") {
http.Error(w, "not found", http.StatusNotFound)
return
}
full := filepath.Join(s.staticDir, clean)
if !strings.HasPrefix(full, s.staticDir) {
http.Error(w, "not found", http.StatusNotFound)
return
}
info, err := os.Stat(full)
if err != nil || info.IsDir() {
http.Error(w, "not found", http.StatusNotFound)
return
}
http.ServeFile(w, r, full)
}
// serveSPA serves files from staticDir, falling back to index.html for client routes.
func (s *server) serveSPA(w http.ResponseWriter, r *http.Request) {
clean := filepath.Clean(r.URL.Path)
if clean == "/" {
clean = "/index.html"
}
full := filepath.Join(s.staticDir, clean)
if !strings.HasPrefix(full, s.staticDir) {
http.Error(w, "not found", http.StatusNotFound)
return
}
if info, err := os.Stat(full); err == nil && !info.IsDir() {
http.ServeFile(w, r, full)
return
}
// Unknown path → SPA fallback.
w.Header().Set("Cache-Control", "no-cache")
http.ServeFile(w, r, filepath.Join(s.staticDir, "index.html"))
}

80
server/ratelimit.go Normal file
View file

@ -0,0 +1,80 @@
package main
import (
"net"
"net/http"
"strings"
"sync"
"time"
)
type rateLimiter struct {
mu sync.Mutex
visitors map[string]*visitor
rate int
window time.Duration
}
type visitor struct {
count int
reset time.Time
}
func newRateLimiter(rate int, window time.Duration) *rateLimiter {
rl := &rateLimiter{
visitors: make(map[string]*visitor),
rate: rate,
window: window,
}
go rl.cleanup()
return rl
}
func (rl *rateLimiter) allow(key string) bool {
rl.mu.Lock()
defer rl.mu.Unlock()
now := time.Now()
v, ok := rl.visitors[key]
if !ok || now.After(v.reset) {
rl.visitors[key] = &visitor{count: 1, reset: now.Add(rl.window)}
return true
}
if v.count >= rl.rate {
return false
}
v.count++
return true
}
func (rl *rateLimiter) cleanup() {
for {
time.Sleep(5 * time.Minute)
rl.mu.Lock()
now := time.Now()
for k, v := range rl.visitors {
if now.After(v.reset) {
delete(rl.visitors, k)
}
}
rl.mu.Unlock()
}
}
// clientIP extracts the client address from X-Forwarded-For (set by nginx),
// falling back to X-Real-IP and finally RemoteAddr.
func clientIP(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
if comma := strings.Index(xff, ","); comma > 0 {
return strings.TrimSpace(xff[:comma])
}
return strings.TrimSpace(xff)
}
if rip := r.Header.Get("X-Real-IP"); rip != "" {
return rip
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}