Add an optional long-form "notes" text field to each favorite for reviews, thoughts, or extended descriptions. The field is stored in SQLite via a new migration (002_add_fave_notes.sql) and propagated through the entire stack: - Model: Notes field on Fave struct - Store: All SQL queries (Create, GetByID, Update, list methods, scanFaves) updated with notes column - Web handlers: Read/write notes in create, edit, update forms - API handlers: Notes in create, update, get, import request/response - Export: Notes included in both JSON and CSV exports - Import: Notes parsed from both JSON and CSV imports - Feed: Notes used as Atom feed item summary when present - Form template: New textarea between URL and image fields - Detail template: Display notes, enhanced og:description with cascade: notes (truncated) → URL → generic fallback text Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
477 lines
12 KiB
Go
477 lines
12 KiB
Go
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
// Package api provides JSON REST API handlers under /api/v1/.
|
|
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"kode.naiv.no/olemd/favoritter/internal/config"
|
|
"kode.naiv.no/olemd/favoritter/internal/middleware"
|
|
"kode.naiv.no/olemd/favoritter/internal/model"
|
|
"kode.naiv.no/olemd/favoritter/internal/store"
|
|
)
|
|
|
|
// Deps bundles dependencies for API handlers.
|
|
type Deps struct {
|
|
Config *config.Config
|
|
Users *store.UserStore
|
|
Sessions *store.SessionStore
|
|
Faves *store.FaveStore
|
|
Tags *store.TagStore
|
|
}
|
|
|
|
// Handler holds API handler methods.
|
|
type Handler struct {
|
|
deps Deps
|
|
}
|
|
|
|
// New creates a new API handler.
|
|
func New(deps Deps) *Handler {
|
|
return &Handler{deps: deps}
|
|
}
|
|
|
|
// Routes registers all API routes on the given mux under /api/v1/.
|
|
func (h *Handler) Routes(mux *http.ServeMux) {
|
|
requireLogin := middleware.RequireLogin("")
|
|
|
|
// Auth.
|
|
mux.HandleFunc("POST /api/v1/auth/login", h.handleLogin)
|
|
mux.Handle("POST /api/v1/auth/logout", requireLogin(http.HandlerFunc(h.handleLogout)))
|
|
|
|
// Faves.
|
|
mux.Handle("GET /api/v1/faves", requireLogin(http.HandlerFunc(h.handleListFaves)))
|
|
mux.Handle("POST /api/v1/faves", requireLogin(http.HandlerFunc(h.handleCreateFave)))
|
|
mux.HandleFunc("GET /api/v1/faves/{id}", h.handleGetFave)
|
|
mux.Handle("PUT /api/v1/faves/{id}", requireLogin(http.HandlerFunc(h.handleUpdateFave)))
|
|
mux.Handle("DELETE /api/v1/faves/{id}", requireLogin(http.HandlerFunc(h.handleDeleteFave)))
|
|
|
|
// Tags.
|
|
mux.HandleFunc("GET /api/v1/tags", h.handleSearchTags)
|
|
|
|
// Users.
|
|
mux.HandleFunc("GET /api/v1/users/{username}", h.handleGetUser)
|
|
mux.HandleFunc("GET /api/v1/users/{username}/faves", h.handleGetUserFaves)
|
|
|
|
// Export/Import.
|
|
mux.Handle("GET /api/v1/export/json", requireLogin(http.HandlerFunc(h.handleExport)))
|
|
mux.Handle("POST /api/v1/import", requireLogin(http.HandlerFunc(h.handleImport)))
|
|
}
|
|
|
|
// --- Auth ---
|
|
|
|
func (h *Handler) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
jsonError(w, "Invalid request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
user, err := h.deps.Users.Authenticate(req.Username, req.Password)
|
|
if err != nil {
|
|
jsonError(w, "Invalid credentials", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
token, err := h.deps.Sessions.Create(user.ID)
|
|
if err != nil {
|
|
slog.Error("api: session create error", "error", err)
|
|
jsonError(w, "Internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
jsonOK(w, map[string]any{
|
|
"token": token,
|
|
"user": userJSON(user),
|
|
})
|
|
}
|
|
|
|
func (h *Handler) handleLogout(w http.ResponseWriter, r *http.Request) {
|
|
cookie, err := r.Cookie(middleware.SessionCookieName)
|
|
if err == nil {
|
|
h.deps.Sessions.Delete(cookie.Value)
|
|
}
|
|
middleware.ClearSessionCookie(w)
|
|
jsonOK(w, map[string]string{"status": "ok"})
|
|
}
|
|
|
|
// --- Faves ---
|
|
|
|
func (h *Handler) handleListFaves(w http.ResponseWriter, r *http.Request) {
|
|
user := middleware.UserFromContext(r.Context())
|
|
page := queryInt(r, "page", 1)
|
|
limit := queryInt(r, "limit", 24)
|
|
if limit > 100 {
|
|
limit = 100
|
|
}
|
|
offset := (page - 1) * limit
|
|
|
|
faves, total, err := h.deps.Faves.ListByUser(user.ID, limit, offset)
|
|
if err != nil {
|
|
slog.Error("api: list faves error", "error", err)
|
|
jsonError(w, "Internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
h.deps.Faves.LoadTags(faves)
|
|
|
|
jsonOK(w, map[string]any{
|
|
"faves": favesJSON(faves),
|
|
"total": total,
|
|
"page": page,
|
|
})
|
|
}
|
|
|
|
func (h *Handler) handleCreateFave(w http.ResponseWriter, r *http.Request) {
|
|
user := middleware.UserFromContext(r.Context())
|
|
|
|
var req struct {
|
|
Description string `json:"description"`
|
|
URL string `json:"url"`
|
|
Notes string `json:"notes"`
|
|
Privacy string `json:"privacy"`
|
|
Tags []string `json:"tags"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
jsonError(w, "Invalid request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if req.Description == "" {
|
|
jsonError(w, "Description is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if req.Privacy != "public" && req.Privacy != "private" {
|
|
req.Privacy = user.DefaultFavePrivacy
|
|
}
|
|
|
|
fave, err := h.deps.Faves.Create(user.ID, req.Description, req.URL, "", req.Notes, req.Privacy)
|
|
if err != nil {
|
|
slog.Error("api: create fave error", "error", err)
|
|
jsonError(w, "Internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if len(req.Tags) > 0 {
|
|
h.deps.Tags.SetFaveTags(fave.ID, req.Tags)
|
|
}
|
|
|
|
tags, _ := h.deps.Tags.ForFave(fave.ID)
|
|
fave.Tags = tags
|
|
|
|
w.WriteHeader(http.StatusCreated)
|
|
jsonOK(w, faveJSON(fave))
|
|
}
|
|
|
|
func (h *Handler) handleGetFave(w http.ResponseWriter, r *http.Request) {
|
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
|
if err != nil {
|
|
jsonError(w, "Invalid ID", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
fave, err := h.deps.Faves.GetByID(id)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrFaveNotFound) {
|
|
jsonError(w, "Not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
jsonError(w, "Internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
user := middleware.UserFromContext(r.Context())
|
|
if fave.Privacy == "private" && (user == nil || user.ID != fave.UserID) {
|
|
jsonError(w, "Not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
tags, _ := h.deps.Tags.ForFave(fave.ID)
|
|
fave.Tags = tags
|
|
jsonOK(w, faveJSON(fave))
|
|
}
|
|
|
|
func (h *Handler) handleUpdateFave(w http.ResponseWriter, r *http.Request) {
|
|
user := middleware.UserFromContext(r.Context())
|
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
|
if err != nil {
|
|
jsonError(w, "Invalid ID", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
fave, err := h.deps.Faves.GetByID(id)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrFaveNotFound) {
|
|
jsonError(w, "Not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
jsonError(w, "Internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if user.ID != fave.UserID {
|
|
jsonError(w, "Forbidden", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
Description string `json:"description"`
|
|
URL string `json:"url"`
|
|
Notes string `json:"notes"`
|
|
Privacy string `json:"privacy"`
|
|
Tags []string `json:"tags"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
jsonError(w, "Invalid request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if req.Description == "" {
|
|
req.Description = fave.Description
|
|
}
|
|
if req.Privacy != "public" && req.Privacy != "private" {
|
|
req.Privacy = fave.Privacy
|
|
}
|
|
|
|
if err := h.deps.Faves.Update(id, req.Description, req.URL, fave.ImagePath, req.Notes, req.Privacy); err != nil {
|
|
slog.Error("api: update fave error", "error", err)
|
|
jsonError(w, "Internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if req.Tags != nil {
|
|
if err := h.deps.Tags.SetFaveTags(id, req.Tags); err != nil {
|
|
slog.Error("api: set tags error", "error", err)
|
|
}
|
|
}
|
|
|
|
updated, _ := h.deps.Faves.GetByID(id)
|
|
tags, _ := h.deps.Tags.ForFave(id)
|
|
updated.Tags = tags
|
|
jsonOK(w, faveJSON(updated))
|
|
}
|
|
|
|
func (h *Handler) handleDeleteFave(w http.ResponseWriter, r *http.Request) {
|
|
user := middleware.UserFromContext(r.Context())
|
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
|
if err != nil {
|
|
jsonError(w, "Invalid ID", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
fave, err := h.deps.Faves.GetByID(id)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrFaveNotFound) {
|
|
jsonError(w, "Not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
jsonError(w, "Internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if user.ID != fave.UserID {
|
|
jsonError(w, "Forbidden", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
if err := h.deps.Faves.Delete(id); err != nil {
|
|
slog.Error("api: delete fave error", "error", err)
|
|
jsonError(w, "Internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// --- Tags ---
|
|
|
|
func (h *Handler) handleSearchTags(w http.ResponseWriter, r *http.Request) {
|
|
q := r.URL.Query().Get("q")
|
|
limit := queryInt(r, "limit", 20)
|
|
if limit > 100 {
|
|
limit = 100
|
|
}
|
|
|
|
tags, err := h.deps.Tags.Search(q, limit)
|
|
if err != nil {
|
|
jsonError(w, "Internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
tagNames := make([]string, len(tags))
|
|
for i, t := range tags {
|
|
tagNames[i] = t.Name
|
|
}
|
|
jsonOK(w, map[string]any{"tags": tagNames})
|
|
}
|
|
|
|
// --- Users ---
|
|
|
|
func (h *Handler) handleGetUser(w http.ResponseWriter, r *http.Request) {
|
|
username := r.PathValue("username")
|
|
user, err := h.deps.Users.GetByUsername(username)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrUserNotFound) {
|
|
jsonError(w, "Not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
jsonError(w, "Internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if user.Disabled {
|
|
jsonError(w, "Not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
jsonOK(w, userJSON(user))
|
|
}
|
|
|
|
func (h *Handler) handleGetUserFaves(w http.ResponseWriter, r *http.Request) {
|
|
username := r.PathValue("username")
|
|
user, err := h.deps.Users.GetByUsername(username)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrUserNotFound) {
|
|
jsonError(w, "Not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
jsonError(w, "Internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
page := queryInt(r, "page", 1)
|
|
limit := queryInt(r, "limit", 24)
|
|
offset := (page - 1) * limit
|
|
|
|
faves, total, err := h.deps.Faves.ListPublicByUser(user.ID, limit, offset)
|
|
if err != nil {
|
|
jsonError(w, "Internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
h.deps.Faves.LoadTags(faves)
|
|
|
|
jsonOK(w, map[string]any{
|
|
"faves": favesJSON(faves),
|
|
"total": total,
|
|
"page": page,
|
|
})
|
|
}
|
|
|
|
// --- Export/Import ---
|
|
|
|
func (h *Handler) handleExport(w http.ResponseWriter, r *http.Request) {
|
|
user := middleware.UserFromContext(r.Context())
|
|
faves, _, err := h.deps.Faves.ListByUser(user.ID, 100000, 0)
|
|
if err != nil {
|
|
jsonError(w, "Internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
h.deps.Faves.LoadTags(faves)
|
|
jsonOK(w, favesJSON(faves))
|
|
}
|
|
|
|
func (h *Handler) handleImport(w http.ResponseWriter, r *http.Request) {
|
|
user := middleware.UserFromContext(r.Context())
|
|
|
|
// Limit request body to prevent memory exhaustion.
|
|
r.Body = io.NopCloser(io.LimitReader(r.Body, h.deps.Config.MaxUploadSize))
|
|
|
|
var faves []struct {
|
|
Description string `json:"description"`
|
|
URL string `json:"url"`
|
|
Notes string `json:"notes"`
|
|
Privacy string `json:"privacy"`
|
|
Tags []string `json:"tags"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&faves); err != nil {
|
|
jsonError(w, "Invalid JSON", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
imported := 0
|
|
for _, f := range faves {
|
|
if f.Description == "" {
|
|
continue
|
|
}
|
|
privacy := f.Privacy
|
|
if privacy != "public" && privacy != "private" {
|
|
privacy = user.DefaultFavePrivacy
|
|
}
|
|
fave, err := h.deps.Faves.Create(user.ID, f.Description, f.URL, "", f.Notes, privacy)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if len(f.Tags) > 0 {
|
|
h.deps.Tags.SetFaveTags(fave.ID, f.Tags)
|
|
}
|
|
imported++
|
|
}
|
|
|
|
jsonOK(w, map[string]any{
|
|
"imported": imported,
|
|
"total": len(faves),
|
|
})
|
|
}
|
|
|
|
// --- JSON helpers ---
|
|
|
|
func jsonOK(w http.ResponseWriter, data any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(data)
|
|
}
|
|
|
|
func jsonError(w http.ResponseWriter, message string, code int) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(code)
|
|
json.NewEncoder(w).Encode(map[string]string{"error": message})
|
|
}
|
|
|
|
func userJSON(u *model.User) map[string]any {
|
|
result := map[string]any{
|
|
"username": u.Username,
|
|
"display_name": u.DisplayNameOrUsername(),
|
|
}
|
|
if u.ProfileVisibility == "public" {
|
|
result["bio"] = u.Bio
|
|
result["avatar_path"] = u.AvatarPath
|
|
result["created_at"] = u.CreatedAt
|
|
}
|
|
return result
|
|
}
|
|
|
|
func faveJSON(f *model.Fave) map[string]any {
|
|
tags := make([]string, len(f.Tags))
|
|
for i, t := range f.Tags {
|
|
tags[i] = t.Name
|
|
}
|
|
return map[string]any{
|
|
"id": f.ID,
|
|
"description": f.Description,
|
|
"url": f.URL,
|
|
"notes": f.Notes,
|
|
"image_path": f.ImagePath,
|
|
"privacy": f.Privacy,
|
|
"tags": tags,
|
|
"username": f.Username,
|
|
"created_at": f.CreatedAt,
|
|
"updated_at": f.UpdatedAt,
|
|
}
|
|
}
|
|
|
|
func favesJSON(faves []*model.Fave) []map[string]any {
|
|
result := make([]map[string]any, len(faves))
|
|
for i, f := range faves {
|
|
result[i] = faveJSON(f)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func queryInt(r *http.Request, key string, fallback int) int {
|
|
v := r.URL.Query().Get(key)
|
|
n, err := strconv.Atoi(v)
|
|
if err != nil || n < 1 {
|
|
return fallback
|
|
}
|
|
return n
|
|
}
|