Implement Phase 6: REST API with chi router
- Login endpoint (password → bearer token + session cookie) - Auth middleware (bearer header or session cookie) - Notebook list endpoint (authenticated) - Page SVG/JPG rendering endpoints (authenticated) - Notebook PDF download endpoint (authenticated) - Share link endpoints: view, page SVG, page JPG, PDF (no auth) - Route registration with chi groups Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
1
go.mod
1
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
|
||||
|
||||
2
go.sum
2
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=
|
||||
|
||||
55
internal/server/auth_handler.go
Normal file
55
internal/server/auth_handler.go
Normal file
@@ -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})
|
||||
}
|
||||
}
|
||||
54
internal/server/middleware.go
Normal file
54
internal/server/middleware.go
Normal file
@@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
298
internal/server/notebooks_handler.go
Normal file
298
internal/server/notebooks_handler.go
Normal file
@@ -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
|
||||
}
|
||||
28
internal/server/routes.go
Normal file
28
internal/server/routes.go
Normal file
@@ -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))
|
||||
})
|
||||
}
|
||||
44
internal/server/server.go
Normal file
44
internal/server/server.go
Normal file
@@ -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("", "")
|
||||
}
|
||||
Reference in New Issue
Block a user