Files
eng-pad-server/internal/webserver/handlers.go
Kyle Isom 5c4575a67f Implement Phase 8: Web UI with htmx templates
- HTML templates: layout, login, notebook list, notebook view, page viewer
- Web server with chi router, embedded templates via //go:embed
- Login/logout flow with session cookies
- Notebook list, page grid with SVG thumbnails, page viewer
- Share link views (same templates, no auth chrome)
- Server command wired to start gRPC + REST + web servers concurrently
- Graceful shutdown on SIGINT/SIGTERM

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 19:59:07 -07:00

256 lines
6.8 KiB
Go

package webserver
import (
"context"
"fmt"
"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"
)
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 {
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 {
continue
}
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 {
continue
}
pages = append(pages, pageInfo{
Number: num,
SVGLink: fmt.Sprintf("/v1/notebooks/%d/pages/%d/svg", id, num),
ViewLink: fmt.Sprintf("/notebooks/%d/pages/%d", id, num),
})
}
ws.render(w, "notebook.html", map[string]any{
"Title": title,
"Pages": pages,
"PDFLink": fmt.Sprintf("/v1/notebooks/%d/pdf", id),
})
}
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("/v1/notebooks/%d/pages/%d/svg", id, num),
"JPGLink": fmt.Sprintf("/v1/notebooks/%d/pages/%d/jpg", id, num),
"PDFLink": fmt.Sprintf("/v1/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 {
continue
}
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),
})
}
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),
})
}
// --- 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) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := ws.tmpl.ExecuteTemplate(w, name, data); err != nil {
http.Error(w, "Template error", http.StatusInternalServerError)
}
}