// 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 }