diff --git a/go.mod b/go.mod index e04ce7c..d9fff67 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module git.wntrmute.dev/kyle/eng-pad-server go 1.25.0 require ( + github.com/go-chi/chi/v5 v5.2.5 github.com/pelletier/go-toml/v2 v2.3.0 github.com/spf13/cobra v1.10.2 golang.org/x/crypto v0.49.0 diff --git a/go.sum b/go.sum index d438ee9..d622f49 100644 --- a/go.sum +++ b/go.sum @@ -3,6 +3,8 @@ github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= +github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= diff --git a/internal/server/auth_handler.go b/internal/server/auth_handler.go new file mode 100644 index 0000000..47449ee --- /dev/null +++ b/internal/server/auth_handler.go @@ -0,0 +1,55 @@ +package server + +import ( + "database/sql" + "encoding/json" + "net/http" + "time" + + "git.wntrmute.dev/kyle/eng-pad-server/internal/auth" +) + +type loginRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +type loginResponse struct { + Token string `json:"token"` +} + +func handleLogin(database *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req loginRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest) + return + } + + userID, err := auth.AuthenticateUser(database, req.Username, req.Password) + if err != nil { + http.Error(w, `{"error":"invalid credentials"}`, http.StatusUnauthorized) + return + } + + token, err := auth.CreateToken(database, userID, 24*time.Hour) + if err != nil { + http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError) + return + } + + // Set session cookie + 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(loginResponse{Token: token}) + } +} diff --git a/internal/server/middleware.go b/internal/server/middleware.go new file mode 100644 index 0000000..8a948bc --- /dev/null +++ b/internal/server/middleware.go @@ -0,0 +1,54 @@ +package server + +import ( + "context" + "database/sql" + "net/http" + "strings" + + "git.wntrmute.dev/kyle/eng-pad-server/internal/auth" +) + +type contextKey string + +const userIDKey contextKey = "user_id" + +func UserIDFromContext(ctx context.Context) (int64, bool) { + id, ok := ctx.Value(userIDKey).(int64) + return id, ok +} + +func AuthMiddleware(database *sql.DB) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + token := "" + + // Check Authorization header + authHeader := r.Header.Get("Authorization") + if strings.HasPrefix(authHeader, "Bearer ") { + token = strings.TrimPrefix(authHeader, "Bearer ") + } + + // Check cookie + if token == "" { + if cookie, err := r.Cookie("session"); err == nil { + token = cookie.Value + } + } + + if token == "" { + http.Error(w, `{"error":"unauthenticated"}`, http.StatusUnauthorized) + return + } + + userID, err := auth.ValidateToken(database, token) + if err != nil { + http.Error(w, `{"error":"invalid token"}`, http.StatusUnauthorized) + return + } + + ctx := context.WithValue(r.Context(), userIDKey, userID) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} diff --git a/internal/server/notebooks_handler.go b/internal/server/notebooks_handler.go new file mode 100644 index 0000000..282eb18 --- /dev/null +++ b/internal/server/notebooks_handler.go @@ -0,0 +1,298 @@ +package server + +import ( + "database/sql" + "encoding/json" + "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 { + continue + } + 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) { + strokes, pageSize, err := loadPageStrokes(r, database) + if err != nil { + http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusNotFound) + return + } + + svg := render.RenderSVG(pageSize, strokes) + 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) { + strokes, pageSize, err := loadPageStrokes(r, database) + if err != nil { + http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusNotFound) + return + } + + data, err := render.RenderJPG(pageSize, strokes, 95) + if err != nil { + http.Error(w, `{"error":"render 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) { + notebookID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + if err != nil { + http.Error(w, `{"error":"invalid id"}`, http.StatusBadRequest) + return + } + + pages, pageSize, err := loadNotebookPages(database, notebookID) + if err != nil { + http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusNotFound) + return + } + + data := render.RenderPDF(pageSize, pages) + 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 := loadPageStrokesByNotebook(database, notebookID, pageNum) + if err != nil { + http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusNotFound) + return + } + + svg := render.RenderSVG(pageSize, strokes) + 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 := loadPageStrokesByNotebook(database, notebookID, pageNum) + if err != nil { + http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusNotFound) + return + } + + data, err := render.RenderJPG(pageSize, strokes, 95) + if err != nil { + http.Error(w, `{"error":"render 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":"`+err.Error()+`"}`, http.StatusNotFound) + return + } + + data := render.RenderPDF(pageSize, pages) + w.Header().Set("Content-Type", "application/pdf") + w.Header().Set("Content-Disposition", "attachment; filename=notebook.pdf") + _, _ = w.Write(data) + } +} + +// --- helpers --- + +func loadPageStrokes(r *http.Request, database *sql.DB) ([]render.Stroke, string, error) { + notebookID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + if err != nil { + return nil, "", err + } + pageNum, err := strconv.Atoi(chi.URLParam(r, "num")) + if err != nil { + return nil, "", err + } + return loadPageStrokesByNotebook(database, notebookID, pageNum) +} + +func loadPageStrokesByNotebook(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 { + continue + } + 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 { + continue + } + + 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 { + continue + } + + 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 { + continue + } + strokes = append(strokes, s) + } + _ = strokeRows.Close() + + pages = append(pages, render.Page{PageNumber: pageNum, Strokes: strokes}) + } + return pages, pageSize, nil +} diff --git a/internal/server/routes.go b/internal/server/routes.go new file mode 100644 index 0000000..9c41092 --- /dev/null +++ b/internal/server/routes.go @@ -0,0 +1,28 @@ +package server + +import ( + "database/sql" + + "github.com/go-chi/chi/v5" +) + +func RegisterRoutes(r *chi.Mux, database *sql.DB, baseURL string) { + // Public + r.Post("/v1/auth/login", handleLogin(database)) + + // Share links (no auth) + r.Get("/s/{token}", handleShareView(database)) + r.Get("/s/{token}/pages/{num}/svg", handleSharePageSVG(database)) + r.Get("/s/{token}/pages/{num}/jpg", handleSharePageJPG(database)) + r.Get("/s/{token}/pdf", handleSharePDF(database)) + + // Authenticated + r.Group(func(r chi.Router) { + r.Use(AuthMiddleware(database)) + + r.Get("/v1/notebooks", handleListNotebooks(database)) + r.Get("/v1/notebooks/{id}/pages/{num}/svg", handlePageSVG(database)) + r.Get("/v1/notebooks/{id}/pages/{num}/jpg", handlePageJPG(database)) + r.Get("/v1/notebooks/{id}/pdf", handleNotebookPDF(database)) + }) +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..9d08b66 --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,44 @@ +package server + +import ( + "crypto/tls" + "database/sql" + "fmt" + "net/http" + "time" + + "github.com/go-chi/chi/v5" +) + +type Config struct { + Addr string + TLSCert string + TLSKey string + DB *sql.DB + BaseURL string +} + +func Start(cfg Config) error { + r := chi.NewRouter() + RegisterRoutes(r, cfg.DB, cfg.BaseURL) + + tlsCert, err := tls.LoadX509KeyPair(cfg.TLSCert, cfg.TLSKey) + if err != nil { + return fmt.Errorf("load TLS cert: %w", err) + } + + srv := &http.Server{ + Addr: cfg.Addr, + Handler: r, + TLSConfig: &tls.Config{ + Certificates: []tls.Certificate{tlsCert}, + MinVersion: tls.VersionTLS13, + }, + ReadTimeout: 30 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 120 * time.Second, + } + + fmt.Printf("REST API listening on %s\n", cfg.Addr) + return srv.ListenAndServeTLS("", "") +}