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 = `{{if .Title}}{{.Title}} ยท {{end}}Preface Tools` const loginTemplate = head + `

PREFACE TOOLS

{{with .Error}}

{{.}}

{{end}}

Student Login

Access classroom tools

Instructor Login

Review generated outputs

` const shellTemplate = head + `
PREFACE TOOLS
{{.Content}}
` 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()});`) }