diff --git a/internal/webserver/handlers.go b/internal/webserver/handlers.go new file mode 100644 index 0000000..e7a8e42 --- /dev/null +++ b/internal/webserver/handlers.go @@ -0,0 +1,255 @@ +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) + } +} diff --git a/internal/webserver/server.go b/internal/webserver/server.go new file mode 100644 index 0000000..709b781 --- /dev/null +++ b/internal/webserver/server.go @@ -0,0 +1,78 @@ +package webserver + +import ( + "database/sql" + "fmt" + "html/template" + "io/fs" + "net/http" + "time" + + "git.wntrmute.dev/kyle/eng-pad-server/web" + "github.com/go-chi/chi/v5" +) + +type Config struct { + Addr string + DB *sql.DB + BaseURL string +} + +type WebServer struct { + db *sql.DB + baseURL string + tmpl *template.Template +} + +func Start(cfg Config) error { + templateFS, err := fs.Sub(web.Content, "templates") + if err != nil { + return fmt.Errorf("template fs: %w", err) + } + + tmpl, err := template.ParseFS(templateFS, "*.html") + if err != nil { + return fmt.Errorf("parse templates: %w", err) + } + + ws := &WebServer{ + db: cfg.DB, + baseURL: cfg.BaseURL, + tmpl: tmpl, + } + + r := chi.NewRouter() + + // Static files + staticFS, _ := fs.Sub(web.Content, "static") + r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS)))) + + // Public routes + r.Get("/login", ws.handleLoginPage) + r.Post("/login", ws.handleLoginSubmit) + + // Share routes (no auth) + r.Get("/s/{token}", ws.handleShareNotebook) + r.Get("/s/{token}/pages/{num}", ws.handleSharePage) + + // Authenticated routes + r.Group(func(r chi.Router) { + r.Use(ws.authMiddleware) + r.Get("/", http.RedirectHandler("/notebooks", http.StatusFound).ServeHTTP) + r.Get("/notebooks", ws.handleNotebooks) + r.Get("/notebooks/{id}", ws.handleNotebook) + r.Get("/notebooks/{id}/pages/{num}", ws.handlePage) + r.Get("/logout", ws.handleLogout) + }) + + srv := &http.Server{ + Addr: cfg.Addr, + Handler: r, + ReadTimeout: 30 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 120 * time.Second, + } + + fmt.Printf("Web UI listening on %s\n", cfg.Addr) + return srv.ListenAndServe() +} diff --git a/web/embed.go b/web/embed.go new file mode 100644 index 0000000..d6dbf66 --- /dev/null +++ b/web/embed.go @@ -0,0 +1,6 @@ +package web + +import "embed" + +//go:embed templates static +var Content embed.FS diff --git a/web/static/htmx.min.js b/web/static/htmx.min.js new file mode 100644 index 0000000..74956a9 --- /dev/null +++ b/web/static/htmx.min.js @@ -0,0 +1,3 @@ +// htmx placeholder — download from https://unpkg.com/htmx.org +// This is a minimal stub for initial development. +console.log("htmx stub loaded"); diff --git a/web/templates/layout.html b/web/templates/layout.html new file mode 100644 index 0000000..48e7678 --- /dev/null +++ b/web/templates/layout.html @@ -0,0 +1,39 @@ + + + + + + {{block "title" .}}Engineering Pad{{end}} + + + + + +
+ {{block "content" .}}{{end}} +
+ + diff --git a/web/templates/login.html b/web/templates/login.html new file mode 100644 index 0000000..633e211 --- /dev/null +++ b/web/templates/login.html @@ -0,0 +1,16 @@ +{{define "title"}}Login — Engineering Pad{{end}} + +{{define "content"}} +

Login

+
+
+ + +
+
+ + +
+ +
+{{end}} diff --git a/web/templates/notebook.html b/web/templates/notebook.html new file mode 100644 index 0000000..d68576b --- /dev/null +++ b/web/templates/notebook.html @@ -0,0 +1,20 @@ +{{define "title"}}{{.Title}} — Engineering Pad{{end}} + +{{define "content"}} +

{{.Title}}

+
+ Download PDF +
+
+ {{range .Pages}} +
+ +
+ Page {{.Number}} +
+
+
Page {{.Number}}
+
+ {{end}} +
+{{end}} diff --git a/web/templates/notebooks.html b/web/templates/notebooks.html new file mode 100644 index 0000000..f2ca3a1 --- /dev/null +++ b/web/templates/notebooks.html @@ -0,0 +1,17 @@ +{{define "title"}}Notebooks — Engineering Pad{{end}} + +{{define "nav-right"}}Logout{{end}} + +{{define "content"}} +

Notebooks

+{{range .Notebooks}} + +
+ {{.Title}} +
{{.PageSize}} — {{.PageCount}} pages — synced {{.SyncedAt}} +
+
+{{else}} +

No notebooks synced yet.

+{{end}} +{{end}} diff --git a/web/templates/page.html b/web/templates/page.html new file mode 100644 index 0000000..30f0ee6 --- /dev/null +++ b/web/templates/page.html @@ -0,0 +1,13 @@ +{{define "title"}}Page {{.PageNumber}} — {{.NotebookTitle}} — Engineering Pad{{end}} + +{{define "content"}} +

{{.NotebookTitle}} — Page {{.PageNumber}}

+
+ Back to notebook + Download JPG + Download PDF +
+
+ Page {{.PageNumber}} +
+{{end}}