package webserver import ( "context" "encoding/json" "fmt" "log/slog" "net/http" "strconv" "time" "git.wntrmute.dev/kyle/eng-pad-server/internal/auth" "git.wntrmute.dev/kyle/eng-pad-server/internal/share" "github.com/go-chi/chi/v5" "github.com/go-webauthn/webauthn/protocol" ) func (ws *WebServer) handleLoginPage(w http.ResponseWriter, r *http.Request) { ws.render(w, "login.html", nil) } func (ws *WebServer) handleLoginSubmit(w http.ResponseWriter, r *http.Request) { username := r.FormValue("username") password := r.FormValue("password") userID, err := auth.AuthenticateUser(ws.db, username, password) if err != nil { slog.Error("login failed", "username", username, "error", err) ws.render(w, "login.html", map[string]string{"Error": "Invalid credentials"}) return } token, err := auth.CreateToken(ws.db, userID, 24*time.Hour) if err != nil { http.Error(w, "Internal error", http.StatusInternalServerError) return } http.SetCookie(w, &http.Cookie{ Name: "session", Value: token, Path: "/", HttpOnly: true, Secure: true, SameSite: http.SameSiteStrictMode, MaxAge: 86400, }) http.Redirect(w, r, "/notebooks", http.StatusFound) } func (ws *WebServer) handleLogout(w http.ResponseWriter, r *http.Request) { if cookie, err := r.Cookie("session"); err == nil { _ = auth.DeleteToken(ws.db, cookie.Value) } http.SetCookie(w, &http.Cookie{ Name: "session", Value: "", Path: "/", MaxAge: -1, }) http.Redirect(w, r, "/login", http.StatusFound) } func (ws *WebServer) handleNotebooks(w http.ResponseWriter, r *http.Request) { userID := r.Context().Value(userIDKey).(int64) rows, err := ws.db.QueryContext(r.Context(), `SELECT n.id, n.title, n.page_size, n.synced_at, (SELECT COUNT(*) FROM pages WHERE notebook_id = n.id) FROM notebooks n WHERE n.user_id = ? ORDER BY n.synced_at DESC`, userID) if err != nil { http.Error(w, "Internal error", http.StatusInternalServerError) return } defer func() { _ = rows.Close() }() type notebook struct { ID int64 Title string PageSize string SyncedAt string PageCount int } var notebooks []notebook for rows.Next() { var nb notebook var syncedAt int64 if err := rows.Scan(&nb.ID, &nb.Title, &nb.PageSize, &syncedAt, &nb.PageCount); err != nil { http.Error(w, "Internal error", http.StatusInternalServerError) return } nb.SyncedAt = time.UnixMilli(syncedAt).Format("2006-01-02 15:04") notebooks = append(notebooks, nb) } ws.render(w, "notebooks.html", map[string]any{"Notebooks": notebooks}) } func (ws *WebServer) handleNotebook(w http.ResponseWriter, r *http.Request) { id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) var title, pageSize string err := ws.db.QueryRow("SELECT title, page_size FROM notebooks WHERE id = ?", id).Scan(&title, &pageSize) if err != nil { http.Error(w, "Not found", http.StatusNotFound) return } rows, err := ws.db.Query("SELECT page_number FROM pages WHERE notebook_id = ? ORDER BY page_number", id) if err != nil { http.Error(w, "Internal error", http.StatusInternalServerError) return } defer func() { _ = rows.Close() }() type pageInfo struct { Number int SVGLink string ViewLink string } var pages []pageInfo for rows.Next() { var num int if err := rows.Scan(&num); err != nil { http.Error(w, "Internal error", http.StatusInternalServerError) return } pages = append(pages, pageInfo{ Number: num, SVGLink: fmt.Sprintf("/notebooks/%d/pages/%d/svg", id, num), ViewLink: fmt.Sprintf("/notebooks/%d/pages/%d", id, num), }) } // Load share links for this notebook. shareLinks, _ := share.ListLinks(ws.db, id, ws.baseURL) ws.render(w, "notebook.html", map[string]any{ "ID": id, "Title": title, "Pages": pages, "PDFLink": fmt.Sprintf("/notebooks/%d/pdf", id), "ShareLinks": shareLinks, "BaseURL": ws.baseURL, }) } func (ws *WebServer) handlePage(w http.ResponseWriter, r *http.Request) { id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) num, _ := strconv.Atoi(chi.URLParam(r, "num")) var title string err := ws.db.QueryRow("SELECT title FROM notebooks WHERE id = ?", id).Scan(&title) if err != nil { http.Error(w, "Not found", http.StatusNotFound) return } ws.render(w, "page.html", map[string]any{ "NotebookTitle": title, "PageNumber": num, "BackLink": fmt.Sprintf("/notebooks/%d", id), "SVGLink": fmt.Sprintf("/notebooks/%d/pages/%d/svg", id, num), "JPGLink": fmt.Sprintf("/notebooks/%d/pages/%d/jpg", id, num), "PDFLink": fmt.Sprintf("/notebooks/%d/pdf", id), }) } func (ws *WebServer) handleShareNotebook(w http.ResponseWriter, r *http.Request) { token := chi.URLParam(r, "token") notebookID, err := share.ValidateLink(ws.db, token) if err != nil { http.Error(w, "Link not found or expired", http.StatusGone) return } var title string _ = ws.db.QueryRow("SELECT title FROM notebooks WHERE id = ?", notebookID).Scan(&title) rows, err := ws.db.Query("SELECT page_number FROM pages WHERE notebook_id = ? ORDER BY page_number", notebookID) if err != nil { http.Error(w, "Internal error", http.StatusInternalServerError) return } defer func() { _ = rows.Close() }() type pageInfo struct { Number int SVGLink string ViewLink string } var pages []pageInfo for rows.Next() { var num int if err := rows.Scan(&num); err != nil { http.Error(w, "Internal error", http.StatusInternalServerError) return } pages = append(pages, pageInfo{ Number: num, SVGLink: fmt.Sprintf("/s/%s/pages/%d/svg", token, num), ViewLink: fmt.Sprintf("/s/%s/pages/%d", token, num), }) } ws.render(w, "notebook.html", map[string]any{ "Title": title, "Pages": pages, "PDFLink": fmt.Sprintf("/s/%s/pdf", token), "Shared": true, }) } func (ws *WebServer) handleSharePage(w http.ResponseWriter, r *http.Request) { token := chi.URLParam(r, "token") notebookID, err := share.ValidateLink(ws.db, token) if err != nil { http.Error(w, "Link not found or expired", http.StatusGone) return } num, _ := strconv.Atoi(chi.URLParam(r, "num")) var title string _ = ws.db.QueryRow("SELECT title FROM notebooks WHERE id = ?", notebookID).Scan(&title) ws.render(w, "page.html", map[string]any{ "NotebookTitle": title, "PageNumber": num, "BackLink": fmt.Sprintf("/s/%s", token), "SVGLink": fmt.Sprintf("/s/%s/pages/%d/svg", token, num), "JPGLink": fmt.Sprintf("/s/%s/pages/%d/jpg", token, num), "PDFLink": fmt.Sprintf("/s/%s/pdf", token), }) } // --- Share management --- func (ws *WebServer) handleCreateShare(w http.ResponseWriter, r *http.Request) { id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) _, _, err := share.CreateLink(ws.db, id, 0, ws.baseURL) // no expiry if err != nil { slog.Error("create share link", "error", err) http.Error(w, "Failed to create share link", http.StatusInternalServerError) return } http.Redirect(w, r, fmt.Sprintf("/notebooks/%d", id), http.StatusFound) } func (ws *WebServer) handleDeleteNotebook(w http.ResponseWriter, r *http.Request) { userID := r.Context().Value(userIDKey).(int64) id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) // Verify ownership before deleting. var ownerID int64 err := ws.db.QueryRow("SELECT user_id FROM notebooks WHERE id = ?", id).Scan(&ownerID) if err != nil || ownerID != userID { http.Error(w, "Not found", http.StatusNotFound) return } // CASCADE deletes pages, strokes, and share_links. _, _ = ws.db.Exec("DELETE FROM notebooks WHERE id = ?", id) http.Redirect(w, r, "/notebooks", http.StatusFound) } func (ws *WebServer) handleRevokeShare(w http.ResponseWriter, r *http.Request) { id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) token := r.FormValue("token") if token != "" { _ = share.RevokeLink(ws.db, token) } http.Redirect(w, r, fmt.Sprintf("/notebooks/%d", id), http.StatusFound) } // --- auth middleware --- type ctxKey string const userIDKey ctxKey = "user_id" func (ws *WebServer) authMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { cookie, err := r.Cookie("session") if err != nil { http.Redirect(w, r, "/login", http.StatusFound) return } userID, err := auth.ValidateToken(ws.db, cookie.Value) if err != nil { http.Redirect(w, r, "/login", http.StatusFound) return } ctx := r.Context() ctx = context.WithValue(ctx, userIDKey, userID) next.ServeHTTP(w, r.WithContext(ctx)) }) } func (ws *WebServer) render(w http.ResponseWriter, name string, data any) { t, ok := ws.tmpls[name] if !ok { http.Error(w, "Template not found", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := t.ExecuteTemplate(w, "layout.html", data); err != nil { slog.Error("render template", "name", name, "error", err) http.Error(w, "Template error", http.StatusInternalServerError) } } // --- WebAuthn handlers --- func (ws *WebServer) jsonError(w http.ResponseWriter, msg string, code int) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(code) _ = json.NewEncoder(w).Encode(map[string]string{"error": msg}) } func (ws *WebServer) handleWebAuthnRegisterBegin(w http.ResponseWriter, r *http.Request) { if ws.webauthn == nil { ws.jsonError(w, "WebAuthn not configured", http.StatusServiceUnavailable) return } userID := r.Context().Value(userIDKey).(int64) user, err := auth.LoadWebAuthnUser(ws.db, userID) if err != nil { slog.Error("webauthn register begin: load user", "error", err) ws.jsonError(w, "Failed to load user", http.StatusInternalServerError) return } options, session, err := ws.webauthn.BeginRegistration(user) if err != nil { slog.Error("webauthn register begin", "error", err) ws.jsonError(w, "Registration failed", http.StatusInternalServerError) return } sessionKey := fmt.Sprintf("reg:%d", userID) ws.mu.Lock() ws.sessions[sessionKey] = session ws.mu.Unlock() w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(options) } func (ws *WebServer) handleWebAuthnRegisterFinish(w http.ResponseWriter, r *http.Request) { if ws.webauthn == nil { ws.jsonError(w, "WebAuthn not configured", http.StatusServiceUnavailable) return } userID := r.Context().Value(userIDKey).(int64) user, err := auth.LoadWebAuthnUser(ws.db, userID) if err != nil { slog.Error("webauthn register finish: load user", "error", err) ws.jsonError(w, "Failed to load user", http.StatusInternalServerError) return } sessionKey := fmt.Sprintf("reg:%d", userID) ws.mu.Lock() session, ok := ws.sessions[sessionKey] if ok { delete(ws.sessions, sessionKey) } ws.mu.Unlock() if !ok { ws.jsonError(w, "No registration in progress", http.StatusBadRequest) return } parsedResponse, err := protocol.ParseCredentialCreationResponseBody(r.Body) if err != nil { slog.Error("webauthn register finish: parse response", "error", err) ws.jsonError(w, "Invalid response", http.StatusBadRequest) return } credential, err := ws.webauthn.CreateCredential(user, *session, parsedResponse) if err != nil { slog.Error("webauthn register finish: create credential", "error", err) ws.jsonError(w, "Registration failed", http.StatusInternalServerError) return } keyName := r.URL.Query().Get("name") if keyName == "" { keyName = "Security Key" } if err := auth.StoreWebAuthnCredential(ws.db, userID, keyName, credential); err != nil { slog.Error("webauthn register finish: store credential", "error", err) ws.jsonError(w, "Failed to save credential", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) } func (ws *WebServer) handleWebAuthnLoginBegin(w http.ResponseWriter, r *http.Request) { if ws.webauthn == nil { ws.jsonError(w, "WebAuthn not configured", http.StatusServiceUnavailable) return } var req struct { Username string `json:"username"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Username == "" { ws.jsonError(w, "Username is required", http.StatusBadRequest) return } userID, err := auth.LookupUserID(ws.db, req.Username) if err != nil { ws.jsonError(w, "Invalid credentials", http.StatusUnauthorized) return } user, err := auth.LoadWebAuthnUser(ws.db, userID) if err != nil { slog.Error("webauthn login begin: load user", "error", err) ws.jsonError(w, "Invalid credentials", http.StatusUnauthorized) return } if len(user.WebAuthnCredentials()) == 0 { ws.jsonError(w, "No security keys registered", http.StatusBadRequest) return } options, session, err := ws.webauthn.BeginLogin(user) if err != nil { slog.Error("webauthn login begin", "error", err) ws.jsonError(w, "Login failed", http.StatusInternalServerError) return } sessionKey := fmt.Sprintf("login:%s", req.Username) ws.mu.Lock() ws.sessions[sessionKey] = session ws.mu.Unlock() w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(options) } func (ws *WebServer) handleWebAuthnLoginFinish(w http.ResponseWriter, r *http.Request) { if ws.webauthn == nil { ws.jsonError(w, "WebAuthn not configured", http.StatusServiceUnavailable) return } username := r.URL.Query().Get("username") if username == "" { ws.jsonError(w, "Username is required", http.StatusBadRequest) return } userID, err := auth.LookupUserID(ws.db, username) if err != nil { ws.jsonError(w, "Invalid credentials", http.StatusUnauthorized) return } user, err := auth.LoadWebAuthnUser(ws.db, userID) if err != nil { slog.Error("webauthn login finish: load user", "error", err) ws.jsonError(w, "Invalid credentials", http.StatusUnauthorized) return } sessionKey := fmt.Sprintf("login:%s", username) ws.mu.Lock() session, ok := ws.sessions[sessionKey] if ok { delete(ws.sessions, sessionKey) } ws.mu.Unlock() if !ok { ws.jsonError(w, "No login in progress", http.StatusBadRequest) return } parsedResponse, err := protocol.ParseCredentialRequestResponseBody(r.Body) if err != nil { slog.Error("webauthn login finish: parse response", "error", err) ws.jsonError(w, "Invalid response", http.StatusBadRequest) return } credential, err := ws.webauthn.ValidateLogin(user, *session, parsedResponse) if err != nil { slog.Error("webauthn login finish: validate", "error", err) ws.jsonError(w, "Authentication failed", http.StatusUnauthorized) return } // Update sign count to detect cloned authenticators. _ = auth.UpdateWebAuthnSignCount(ws.db, credential.ID, credential.Authenticator.SignCount) // Create session token. token, err := auth.CreateToken(ws.db, userID, 24*time.Hour) if err != nil { slog.Error("webauthn login finish: create token", "error", err) ws.jsonError(w, "Internal error", http.StatusInternalServerError) return } http.SetCookie(w, &http.Cookie{ Name: "session", Value: token, Path: "/", HttpOnly: true, Secure: true, SameSite: http.SameSiteStrictMode, MaxAge: 86400, }) w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) } func (ws *WebServer) handleKeysList(w http.ResponseWriter, r *http.Request) { userID := r.Context().Value(userIDKey).(int64) keys, err := auth.ListWebAuthnCredentials(ws.db, userID) if err != nil { slog.Error("list keys", "error", err) http.Error(w, "Internal error", http.StatusInternalServerError) return } ws.render(w, "keys.html", map[string]any{ "Keys": keys, "WebAuthnEnabled": ws.webauthn != nil, }) } func (ws *WebServer) handleKeyDelete(w http.ResponseWriter, r *http.Request) { userID := r.Context().Value(userIDKey).(int64) credID, err := strconv.ParseInt(r.FormValue("id"), 10, 64) if err != nil { http.Error(w, "Invalid key ID", http.StatusBadRequest) return } if err := auth.DeleteWebAuthnCredential(ws.db, credID, userID); err != nil { slog.Error("delete key", "error", err) http.Error(w, "Internal error", http.StatusInternalServerError) return } http.Redirect(w, r, "/keys", http.StatusFound) }