diff --git a/internal/webserver/handlers.go b/internal/webserver/handlers.go index 7880d85..195729f 100644 --- a/internal/webserver/handlers.go +++ b/internal/webserver/handlers.go @@ -128,15 +128,21 @@ func (ws *WebServer) handleNotebook(w http.ResponseWriter, r *http.Request) { } pages = append(pages, pageInfo{ Number: num, - SVGLink: fmt.Sprintf("/v1/notebooks/%d/pages/%d/svg", id, 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{ - "Title": title, - "Pages": pages, - "PDFLink": fmt.Sprintf("/v1/notebooks/%d/pdf", id), + "ID": id, + "Title": title, + "Pages": pages, + "PDFLink": fmt.Sprintf("/notebooks/%d/pdf", id), + "ShareLinks": shareLinks, + "BaseURL": ws.baseURL, }) } @@ -155,9 +161,9 @@ func (ws *WebServer) handlePage(w http.ResponseWriter, r *http.Request) { "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), + "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), }) } @@ -202,6 +208,7 @@ func (ws *WebServer) handleShareNotebook(w http.ResponseWriter, r *http.Request) "Title": title, "Pages": pages, "PDFLink": fmt.Sprintf("/s/%s/pdf", token), + "Shared": true, }) } @@ -228,6 +235,28 @@ func (ws *WebServer) handleSharePage(w http.ResponseWriter, r *http.Request) { }) } +// --- 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) 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 diff --git a/internal/webserver/render.go b/internal/webserver/render.go new file mode 100644 index 0000000..0b97950 --- /dev/null +++ b/internal/webserver/render.go @@ -0,0 +1,232 @@ +package webserver + +import ( + "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" +) + +// --- Authenticated render endpoints --- + +func (ws *WebServer) handlePageSVG(w http.ResponseWriter, r *http.Request) { + notebookID, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + pageNum, _ := strconv.Atoi(chi.URLParam(r, "num")) + + strokes, pageSize, err := ws.loadPageStrokes(notebookID, pageNum) + if err != nil { + http.Error(w, "Not found", http.StatusNotFound) + return + } + + svg, err := render.RenderSVG(pageSize, strokes) + if err != nil { + http.Error(w, "Render error", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "image/svg+xml") + _, _ = w.Write([]byte(svg)) +} + +func (ws *WebServer) handlePageJPG(w http.ResponseWriter, r *http.Request) { + notebookID, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + pageNum, _ := strconv.Atoi(chi.URLParam(r, "num")) + + strokes, pageSize, err := ws.loadPageStrokes(notebookID, pageNum) + if err != nil { + http.Error(w, "Not found", http.StatusNotFound) + return + } + + data, err := render.RenderJPG(pageSize, strokes, 95) + if err != nil { + http.Error(w, "Render error", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "image/jpeg") + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=page-%d.jpg", pageNum)) + _, _ = w.Write(data) +} + +func (ws *WebServer) handleNotebookPDF(w http.ResponseWriter, r *http.Request) { + notebookID, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + + pages, pageSize, err := ws.loadNotebookPages(notebookID) + if err != nil { + http.Error(w, "Not found", http.StatusNotFound) + return + } + + data, err := render.RenderPDF(pageSize, pages) + if err != nil { + http.Error(w, "Render error", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/pdf") + w.Header().Set("Content-Disposition", "attachment; filename=notebook.pdf") + _, _ = w.Write(data) +} + +// --- Share render endpoints --- + +func (ws *WebServer) handleSharePageSVG(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 + } + pageNum, _ := strconv.Atoi(chi.URLParam(r, "num")) + + strokes, pageSize, err := ws.loadPageStrokes(notebookID, pageNum) + if err != nil { + http.Error(w, "Not found", http.StatusNotFound) + return + } + + svg, err := render.RenderSVG(pageSize, strokes) + if err != nil { + http.Error(w, "Render error", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "image/svg+xml") + _, _ = w.Write([]byte(svg)) +} + +func (ws *WebServer) handleSharePageJPG(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 + } + pageNum, _ := strconv.Atoi(chi.URLParam(r, "num")) + + strokes, pageSize, err := ws.loadPageStrokes(notebookID, pageNum) + if err != nil { + http.Error(w, "Not found", http.StatusNotFound) + return + } + + data, err := render.RenderJPG(pageSize, strokes, 95) + if err != nil { + http.Error(w, "Render error", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "image/jpeg") + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=page-%d.jpg", pageNum)) + _, _ = w.Write(data) +} + +func (ws *WebServer) handleSharePDF(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 + } + + pages, pageSize, err := ws.loadNotebookPages(notebookID) + if err != nil { + http.Error(w, "Not found", http.StatusNotFound) + return + } + + data, err := render.RenderPDF(pageSize, pages) + if err != nil { + http.Error(w, "Render error", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/pdf") + w.Header().Set("Content-Disposition", "attachment; filename=notebook.pdf") + _, _ = w.Write(data) +} + +// --- DB helpers --- + +func (ws *WebServer) loadPageStrokes(notebookID int64, pageNum int) ([]render.Stroke, string, error) { + var pageSize string + err := ws.db.QueryRow("SELECT page_size FROM notebooks WHERE id = ?", notebookID).Scan(&pageSize) + if err != nil { + return nil, "", err + } + + var pageID int64 + err = ws.db.QueryRow( + "SELECT id FROM pages WHERE notebook_id = ? AND page_number = ?", + notebookID, pageNum, + ).Scan(&pageID) + if err != nil { + return nil, "", err + } + + rows, err := ws.db.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, "", err + } + strokes = append(strokes, s) + } + return strokes, pageSize, nil +} + +func (ws *WebServer) loadNotebookPages(notebookID int64) ([]render.Page, string, error) { + var pageSize string + err := ws.db.QueryRow("SELECT page_size FROM notebooks WHERE id = ?", notebookID).Scan(&pageSize) + if err != nil { + return nil, "", err + } + + rows, err := ws.db.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, "", err + } + + strokeRows, err := ws.db.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 + } + + 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, "", err + } + strokes = append(strokes, s) + } + _ = strokeRows.Close() + + pages = append(pages, render.Page{PageNumber: pageNum, Strokes: strokes}) + } + return pages, pageSize, nil +} diff --git a/internal/webserver/server.go b/internal/webserver/server.go index a51da5a..12b04df 100644 --- a/internal/webserver/server.go +++ b/internal/webserver/server.go @@ -98,6 +98,9 @@ func Start(cfg Config) (*http.Server, error) { // Share routes (no auth) r.Get("/s/{token}", ws.handleShareNotebook) r.Get("/s/{token}/pages/{num}", ws.handleSharePage) + r.Get("/s/{token}/pages/{num}/svg", ws.handleSharePageSVG) + r.Get("/s/{token}/pages/{num}/jpg", ws.handleSharePageJPG) + r.Get("/s/{token}/pdf", ws.handleSharePDF) // Authenticated routes r.Group(func(r chi.Router) { @@ -106,6 +109,11 @@ func Start(cfg Config) (*http.Server, error) { r.Get("/notebooks", ws.handleNotebooks) r.Get("/notebooks/{id}", ws.handleNotebook) r.Get("/notebooks/{id}/pages/{num}", ws.handlePage) + r.Get("/notebooks/{id}/pages/{num}/svg", ws.handlePageSVG) + r.Get("/notebooks/{id}/pages/{num}/jpg", ws.handlePageJPG) + r.Get("/notebooks/{id}/pdf", ws.handleNotebookPDF) + r.Post("/notebooks/{id}/share", ws.handleCreateShare) + r.Post("/notebooks/{id}/share/revoke", ws.handleRevokeShare) r.Get("/logout", ws.handleLogout) // WebAuthn authenticated routes (registration + key management) diff --git a/web/templates/notebook.html b/web/templates/notebook.html index d68576b..088926d 100644 --- a/web/templates/notebook.html +++ b/web/templates/notebook.html @@ -4,7 +4,28 @@
{{.URL}}
+
+