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")) }