214 lines
5.8 KiB
Go
214 lines
5.8 KiB
Go
|
|
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"))
|
||
|
|
}
|