package server import ( "database/sql" "encoding/json" "fmt" "net/http" "strconv" "git.wntrmute.dev/kyle/eng-pad-server/internal/render" "git.wntrmute.dev/kyle/eng-pad-server/internal/share" "github.com/go-chi/chi/v5" ) type notebookJSON struct { ID int64 `json:"id"` RemoteID int64 `json:"remote_id"` Title string `json:"title"` PageSize string `json:"page_size"` Pages int `json:"pages"` } func handleListNotebooks(database *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { userID, ok := UserIDFromContext(r.Context()) if !ok { http.Error(w, `{"error":"unauthenticated"}`, http.StatusUnauthorized) return } rows, err := database.QueryContext(r.Context(), `SELECT n.id, n.remote_id, n.title, n.page_size, (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, `{"error":"internal error"}`, http.StatusInternalServerError) return } defer func() { _ = rows.Close() }() var notebooks []notebookJSON for rows.Next() { var nb notebookJSON if err := rows.Scan(&nb.ID, &nb.RemoteID, &nb.Title, &nb.PageSize, &nb.Pages); err != nil { http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError) return } notebooks = append(notebooks, nb) } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(notebooks) } } func handlePageSVG(database *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { userID, ok := UserIDFromContext(r.Context()) if !ok { http.Error(w, `{"error":"unauthenticated"}`, http.StatusUnauthorized) return } notebookID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) if err != nil { http.Error(w, `{"error":"invalid id"}`, http.StatusBadRequest) return } pageNum, err := strconv.Atoi(chi.URLParam(r, "num")) if err != nil { http.Error(w, `{"error":"invalid page number"}`, http.StatusBadRequest) return } internalID, err := verifyNotebookOwnership(database, notebookID, userID) if err != nil { http.Error(w, `{"error":"not found"}`, http.StatusNotFound) return } strokes, pageSize, err := loadPageStrokesByNotebookID(database, internalID, pageNum) if err != nil { http.Error(w, `{"error":"not found"}`, http.StatusNotFound) return } svg, err := render.RenderSVG(pageSize, strokes) if err != nil { http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError) return } w.Header().Set("Content-Type", "image/svg+xml") _, _ = w.Write([]byte(svg)) } } func handlePageJPG(database *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { userID, ok := UserIDFromContext(r.Context()) if !ok { http.Error(w, `{"error":"unauthenticated"}`, http.StatusUnauthorized) return } notebookID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) if err != nil { http.Error(w, `{"error":"invalid id"}`, http.StatusBadRequest) return } pageNum, err := strconv.Atoi(chi.URLParam(r, "num")) if err != nil { http.Error(w, `{"error":"invalid page number"}`, http.StatusBadRequest) return } internalID, err := verifyNotebookOwnership(database, notebookID, userID) if err != nil { http.Error(w, `{"error":"not found"}`, http.StatusNotFound) return } strokes, pageSize, err := loadPageStrokesByNotebookID(database, internalID, pageNum) if err != nil { http.Error(w, `{"error":"not found"}`, http.StatusNotFound) return } data, err := render.RenderJPG(pageSize, strokes, 95) if err != nil { http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError) return } w.Header().Set("Content-Type", "image/jpeg") _, _ = w.Write(data) } } func handleNotebookPDF(database *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { userID, ok := UserIDFromContext(r.Context()) if !ok { http.Error(w, `{"error":"unauthenticated"}`, http.StatusUnauthorized) return } notebookID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) if err != nil { http.Error(w, `{"error":"invalid id"}`, http.StatusBadRequest) return } internalID, err := verifyNotebookOwnership(database, notebookID, userID) if err != nil { http.Error(w, `{"error":"not found"}`, http.StatusNotFound) return } pages, pageSize, err := loadNotebookPages(database, internalID) if err != nil { http.Error(w, `{"error":"not found"}`, http.StatusNotFound) return } data, err := render.RenderPDF(pageSize, pages) if err != nil { http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/pdf") w.Header().Set("Content-Disposition", "attachment; filename=notebook.pdf") _, _ = w.Write(data) } } func handleShareView(database *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { token := chi.URLParam(r, "token") _, err := share.ValidateLink(database, token) if err != nil { http.Error(w, `{"error":"link not found or expired"}`, http.StatusGone) return } // For now, return JSON. Web UI will render this in Phase 8. w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"status":"ok"}`)) } } func handleSharePageSVG(database *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { token := chi.URLParam(r, "token") notebookID, err := share.ValidateLink(database, token) if err != nil { http.Error(w, `{"error":"link not found or expired"}`, http.StatusGone) return } pageNum, err := strconv.Atoi(chi.URLParam(r, "num")) if err != nil { http.Error(w, `{"error":"invalid page number"}`, http.StatusBadRequest) return } strokes, pageSize, err := loadPageStrokesByNotebookID(database, notebookID, pageNum) if err != nil { http.Error(w, `{"error":"not found"}`, http.StatusNotFound) return } svg, err := render.RenderSVG(pageSize, strokes) if err != nil { http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError) return } w.Header().Set("Content-Type", "image/svg+xml") _, _ = w.Write([]byte(svg)) } } func handleSharePageJPG(database *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { token := chi.URLParam(r, "token") notebookID, err := share.ValidateLink(database, token) if err != nil { http.Error(w, `{"error":"link not found or expired"}`, http.StatusGone) return } pageNum, err := strconv.Atoi(chi.URLParam(r, "num")) if err != nil { http.Error(w, `{"error":"invalid page number"}`, http.StatusBadRequest) return } strokes, pageSize, err := loadPageStrokesByNotebookID(database, notebookID, pageNum) if err != nil { http.Error(w, `{"error":"not found"}`, http.StatusNotFound) return } data, err := render.RenderJPG(pageSize, strokes, 95) if err != nil { http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError) return } w.Header().Set("Content-Type", "image/jpeg") _, _ = w.Write(data) } } func handleSharePDF(database *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { token := chi.URLParam(r, "token") notebookID, err := share.ValidateLink(database, token) if err != nil { http.Error(w, `{"error":"link not found or expired"}`, http.StatusGone) return } pages, pageSize, err := loadNotebookPages(database, notebookID) if err != nil { http.Error(w, `{"error":"not found"}`, http.StatusNotFound) return } data, err := render.RenderPDF(pageSize, pages) if err != nil { http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/pdf") w.Header().Set("Content-Disposition", "attachment; filename=notebook.pdf") _, _ = w.Write(data) } } // --- helpers --- // verifyNotebookOwnership checks that notebookID belongs to userID and returns // the internal (server-side) notebook ID. func verifyNotebookOwnership(database *sql.DB, notebookID int64, userID int64) (int64, error) { var internalID int64 err := database.QueryRow( "SELECT id FROM notebooks WHERE id = ? AND user_id = ?", notebookID, userID, ).Scan(&internalID) if err != nil { return 0, fmt.Errorf("not found") } return internalID, nil } // loadPageStrokesByNotebookID loads strokes for a page by internal notebook ID. // This is used by both authenticated and share-link endpoints. func loadPageStrokesByNotebookID(database *sql.DB, notebookID int64, pageNum int) ([]render.Stroke, string, error) { var pageSize string err := database.QueryRow("SELECT page_size FROM notebooks WHERE id = ?", notebookID).Scan(&pageSize) if err != nil { return nil, "", err } var pageID int64 err = database.QueryRow( "SELECT id FROM pages WHERE notebook_id = ? AND page_number = ?", notebookID, pageNum, ).Scan(&pageID) if err != nil { return nil, "", err } rows, err := database.Query( "SELECT pen_size, color, style, point_data, stroke_order FROM strokes WHERE page_id = ? ORDER BY stroke_order", pageID, ) if err != nil { return nil, "", err } defer func() { _ = rows.Close() }() var strokes []render.Stroke for rows.Next() { var s render.Stroke if err := rows.Scan(&s.PenSize, &s.Color, &s.Style, &s.PointData, &s.StrokeOrder); err != nil { return nil, "", fmt.Errorf("scan stroke: %w", err) } strokes = append(strokes, s) } return strokes, pageSize, nil } func loadNotebookPages(database *sql.DB, notebookID int64) ([]render.Page, string, error) { var pageSize string err := database.QueryRow("SELECT page_size FROM notebooks WHERE id = ?", notebookID).Scan(&pageSize) if err != nil { return nil, "", err } rows, err := database.Query( "SELECT id, page_number FROM pages WHERE notebook_id = ? ORDER BY page_number", notebookID, ) if err != nil { return nil, "", err } defer func() { _ = rows.Close() }() var pages []render.Page for rows.Next() { var pageID int64 var pageNum int if err := rows.Scan(&pageID, &pageNum); err != nil { return nil, "", fmt.Errorf("scan page: %w", err) } strokeRows, err := database.Query( "SELECT pen_size, color, style, point_data, stroke_order FROM strokes WHERE page_id = ? ORDER BY stroke_order", pageID, ) if err != nil { return nil, "", fmt.Errorf("query strokes: %w", err) } var strokes []render.Stroke for strokeRows.Next() { var s render.Stroke if err := strokeRows.Scan(&s.PenSize, &s.Color, &s.Style, &s.PointData, &s.StrokeOrder); err != nil { _ = strokeRows.Close() return nil, "", fmt.Errorf("scan stroke: %w", err) } strokes = append(strokes, s) } _ = strokeRows.Close() pages = append(pages, render.Page{PageNumber: pageNum, Strokes: strokes}) } return pages, pageSize, nil }