diff --git a/cmd/favoritter/main.go b/cmd/favoritter/main.go index 5ffe4fe..d194a18 100644 --- a/cmd/favoritter/main.go +++ b/cmd/favoritter/main.go @@ -16,6 +16,7 @@ import ( "kode.naiv.no/olemd/favoritter/internal/config" "kode.naiv.no/olemd/favoritter/internal/database" "kode.naiv.no/olemd/favoritter/internal/handler" + "kode.naiv.no/olemd/favoritter/internal/handler/api" "kode.naiv.no/olemd/favoritter/internal/middleware" "kode.naiv.no/olemd/favoritter/internal/render" "kode.naiv.no/olemd/favoritter/internal/store" @@ -104,8 +105,18 @@ func main() { Renderer: renderer, }) + // Register JSON API routes on the same mux. + apiHandler := api.New(api.Deps{ + Config: cfg, + Users: users, + Sessions: sessions, + Faves: faves, + Tags: tags, + }) + // Build the middleware chain. mux := h.Routes() + apiHandler.Routes(mux) chain := middleware.Chain( mux, middleware.Recovery, diff --git a/internal/handler/api/api.go b/internal/handler/api/api.go new file mode 100644 index 0000000..0937641 --- /dev/null +++ b/internal/handler/api/api.go @@ -0,0 +1,459 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +// Package api provides JSON REST API handlers under /api/v1/. +package api + +import ( + "encoding/json" + "errors" + "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"` + 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.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"` + 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 + } + + h.deps.Faves.Update(id, req.Description, req.URL, fave.ImagePath, req.Privacy) + if req.Tags != nil { + h.deps.Tags.SetFaveTags(id, req.Tags) + } + + 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 + } + + h.deps.Faves.Delete(id) + 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, 10000, 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()) + + var faves []struct { + Description string `json:"description"` + URL string `json:"url"` + 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, "", 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, + "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 +}