Files
preface-tools/internal/httpserver/server.go
T
2026-07-12 02:56:51 +08:00

227 lines
11 KiB
Go

package httpserver
import (
"crypto/rand"
"encoding/hex"
"fmt"
"html/template"
"log/slog"
"net"
"net/http"
"strings"
"time"
"git.michelsen.id/phill/preface-tools/internal/auth"
"git.michelsen.id/phill/preface-tools/internal/tools"
)
type Server struct {
auth *auth.Service
registry *tools.Registry
log *slog.Logger
handler http.Handler
}
func New(a *auth.Service, r *tools.Registry, log *slog.Logger) *Server {
s := &Server{auth: a, registry: r, log: log}
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("ok\n")) })
mux.HandleFunc("GET /readyz", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("ready\n")) })
mux.HandleFunc("GET /static/app.css", css)
mux.HandleFunc("GET /static/app.js", js)
mux.HandleFunc("GET /login", s.loginPage)
mux.HandleFunc("POST /login/{role}", s.login)
mux.Handle("POST /logout", s.withAuth("", s.csrf(http.HandlerFunc(s.logout))))
mux.HandleFunc("GET /", s.root)
for _, tool := range r.List() {
key := tool.Definition().Key
studentPrefix := "/tools/" + key
suffix := http.StripPrefix(studentPrefix, tool.StudentHandler())
mux.Handle(studentPrefix+"/provider-media/", suffix)
mux.Handle(studentPrefix+"/", s.withAuth(auth.Student, s.csrf(s.shell(tool, suffix, false))))
if it, ok := tool.(tools.InstructorTool); ok {
prefix := "/instructor/tools/" + key
mux.Handle(prefix+"/", s.withAuth(auth.Instructor, s.csrf(s.shell(tool, http.StripPrefix(prefix, it.InstructorHandler()), true))))
}
}
mux.Handle("GET /instructor", s.withAuth(auth.Instructor, http.HandlerFunc(s.instructorRoot)))
s.handler = s.middleware(mux)
return s
}
func (s *Server) Handler() http.Handler { return s.handler }
func (s *Server) root(w http.ResponseWriter, r *http.Request) {
c, err := s.auth.FromRequest(r)
if err != nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
if c.Role == auth.Instructor {
http.Redirect(w, r, "/instructor", 303)
return
}
list := s.registry.List()
if len(list) == 0 {
http.Error(w, "no tools configured", 503)
return
}
http.Redirect(w, r, "/tools/"+list[0].Definition().Key+"/", 303)
}
func (s *Server) loginPage(w http.ResponseWriter, r *http.Request) { render(w, loginTemplate, nil) }
func (s *Server) login(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 4096)
role := auth.Role(r.PathValue("role"))
if role != auth.Student && role != auth.Instructor {
http.NotFound(w, r)
return
}
c, err := s.auth.Authenticate(role, r.FormValue("pin"), clientIP(r)+":"+string(role))
if err != nil {
renderStatus(w, loginTemplate, map[string]any{"Error": "Login was not accepted."}, 401)
return
}
s.auth.SetCookie(w, c)
if role == auth.Instructor {
http.Redirect(w, r, "/instructor", 303)
} else {
http.Redirect(w, r, "/", 303)
}
}
func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
s.auth.ClearCookie(w)
http.Redirect(w, r, "/login", 303)
}
func (s *Server) instructorRoot(w http.ResponseWriter, r *http.Request) {
list := s.registry.List()
for _, t := range list {
if _, ok := t.(tools.InstructorTool); ok {
http.Redirect(w, r, "/instructor/tools/"+t.Definition().Key+"/", 303)
return
}
}
http.Error(w, "no instructor tools", 404)
}
func (s *Server) withAuth(role auth.Role, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
c, err := s.auth.FromRequest(r)
if err != nil {
http.Redirect(w, r, "/login", 303)
return
}
if role != "" && c.Role != role {
http.Error(w, "forbidden", 403)
return
}
next.ServeHTTP(w, auth.WithClaims(r, c))
})
}
func (s *Server) csrf(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet || r.Method == http.MethodHead {
next.ServeHTTP(w, r)
return
}
c, _ := auth.ClaimsFrom(r)
token := r.Header.Get("X-CSRF-Token")
if token == "" {
token = r.FormValue("csrf_token")
}
if token == "" || token != c.CSRFToken {
http.Error(w, "invalid CSRF token", 403)
return
}
if origin := r.Header.Get("Origin"); origin != "" && !strings.HasPrefix(origin, "http://"+r.Host) && !strings.HasPrefix(origin, "https://"+r.Host) {
http.Error(w, "invalid origin", 403)
return
}
next.ServeHTTP(w, r)
})
}
func (s *Server) shell(tool tools.Tool, next http.Handler, instructor bool) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
next.ServeHTTP(w, r)
return
}
rw := &capture{header: http.Header{}}
next.ServeHTTP(rw, r)
if rw.status >= 400 {
copyHeader(w.Header(), rw.header)
w.WriteHeader(rw.status)
w.Write(rw.body)
return
}
c, _ := auth.ClaimsFrom(r)
data := map[string]any{"Title": tool.Definition().Name, "Content": template.HTML(rw.body), "Tools": s.registry.List(), "Selected": tool.Definition().Key, "Instructor": instructor, "CSRF": c.CSRFToken}
render(w, shellTemplate, data)
})
}
type capture struct {
header http.Header
body []byte
status int
}
func (c *capture) Header() http.Header { return c.header }
func (c *capture) WriteHeader(n int) { c.status = n }
func (c *capture) Write(b []byte) (int, error) {
if c.status == 0 {
c.status = 200
}
c.body = append(c.body, b...)
return len(b), nil
}
func copyHeader(dst, src http.Header) {
for k, v := range src {
dst[k] = v
}
}
func (s *Server) middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
b := make([]byte, 8)
rand.Read(b)
rid := hex.EncodeToString(b)
w.Header().Set("X-Request-ID", rid)
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Referrer-Policy", "same-origin")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
w.Header().Set("Content-Security-Policy", "default-src 'self'; style-src 'self' https://cdn.jsdelivr.net; script-src 'self' https://cdn.jsdelivr.net; img-src 'self' data:; media-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'")
defer func() {
if v := recover(); v != nil {
s.log.Error("request panic", "request_id", rid, "error", v)
http.Error(w, "internal server error", 500)
}
s.log.Info("request", "request_id", rid, "method", r.Method, "path", r.URL.Path, "duration", time.Since(start))
}()
next.ServeHTTP(w, r)
})
}
func clientIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err == nil {
return host
}
return r.RemoteAddr
}
func render(w http.ResponseWriter, src string, data any) { renderStatus(w, src, data, 200) }
func renderStatus(w http.ResponseWriter, src string, data any, status int) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
template.Must(template.New("page").Parse(src)).Execute(w, data)
}
const head = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="htmx-config" content='{"responseHandling":[{"code":"204","swap":false},{"code":"[23]..","swap":true},{"code":"[45]..","swap":true,"error":true},{"code":"...","swap":false,"error":true}]}'><title>{{if .Title}}{{.Title}} · {{end}}Preface Tools</title><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/basecoat-css@1.0.2/dist/basecoat.cdn.min.css"><link rel="stylesheet" href="/static/app.css"><script src="https://cdn.jsdelivr.net/npm/basecoat-css@1.0.2/dist/js/all.min.js" defer></script><script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.8/dist/htmx.min.js" defer></script><script src="/static/app.js" defer></script></head><body>`
const loginTemplate = head + `<main class="login"><h1>PREFACE TOOLS</h1>{{with .Error}}<p class="error">{{.}}</p>{{end}}<div class="login-grid"><form class="card" method="post" action="/login/student"><h2>Student Login</h2><p>Access classroom tools</p><input type="password" name="pin" required autocomplete="current-password" aria-label="Student PIN"><button class="btn primary">Log in</button></form><form class="card" method="post" action="/login/instructor"><h2>Instructor Login</h2><p>Review generated outputs</p><input type="password" name="pin" required autocomplete="current-password" aria-label="Instructor PIN"><button class="btn">Log in</button></form></div></main></body></html>`
const shellTemplate = head + `<header><a href="/">PREFACE TOOLS</a><nav>{{if .Instructor}}<span>Instructor</span>{{else}}<select id="tool-selector" aria-label="Choose tool">{{range .Tools}}<option value="/tools/{{.Definition.Key}}/" {{if eq .Definition.Key $.Selected}}selected{{end}}>{{.Definition.Name}}</option>{{end}}</select>{{end}}<form method="post" action="/logout"><input type="hidden" name="csrf_token" value="{{.CSRF}}"><button>Log out</button></form></nav></header><main>{{.Content}}</main></body></html>`
func css(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/css")
fmt.Fprint(w, `body{margin:0;background:#f7f7fa;color:#172033;font-family:ui-sans-serif,system-ui}header{height:64px;background:#fff;border-bottom:1px solid #e5e7eb;display:flex;align-items:center;justify-content:space-between;padding:0 max(1rem,4vw)}header>a{font-weight:800;letter-spacing:.08em}nav{display:flex;gap:1rem;align-items:center}main{max-width:1400px;margin:0 auto;padding:2rem}.card{background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:1.25rem;box-shadow:0 1px 2px #1018280d}.login{max-width:760px;text-align:center;padding-top:12vh}.login-grid{display:grid;grid-template-columns:1fr 1fr;gap:1rem;text-align:left}.tool-grid{display:grid;grid-template-columns:1fr 1.3fr .8fr;gap:1rem}.recent{margin-top:1rem}.preview img,.generation video{width:100%;max-height:420px;object-fit:contain;border-radius:8px}.generation{border-top:1px solid #e5e7eb;padding:1rem 0}.generation>div{display:flex;justify-content:space-between}.muted,small{color:#667085}.error{color:#b42318}.htmx-indicator{display:none}.htmx-request .htmx-indicator,.htmx-request.htmx-indicator{display:inline}textarea,input,select{width:100%;margin:.35rem 0 1rem}.btn{display:inline-flex;margin-top:.5rem}.primary{background:#4f46e5;color:#fff}dialog{max-width:440px;width:calc(100% - 2rem)}@media(max-width:900px){.tool-grid{grid-template-columns:1fr 1fr}.tool-grid>*:last-child{grid-column:1/-1}}@media(max-width:640px){main{padding:1rem}.tool-grid,.login-grid{grid-template-columns:1fr}.tool-grid>*:last-child{grid-column:auto}}`)
}
func js(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/javascript")
fmt.Fprint(w, `document.addEventListener("htmx:afterSwap",()=>window.basecoat?.initAll());document.addEventListener("htmx:historyRestore",()=>window.basecoat?.initAll({force:true}));document.addEventListener("change",e=>{if(e.target.id==="tool-selector")location.href=e.target.value});document.addEventListener("click",e=>{if(e.target.closest("[data-open-approval]"))document.querySelector("#approval-dialog")?.showModal();if(e.target.closest("[data-close-approval]"))document.querySelector("#approval-dialog")?.close()});document.addEventListener("generationQueued",()=>{const d=document.querySelector("#approval-dialog"),p=d?.querySelector('[name="instructor_pin"]');if(p)p.value="";d?.close()});`)
}