8663beeb96
- Cap list API limit at 200 to prevent unbounded queries - Sanitize markdown output with DOMPurify to prevent XSS - Add v4 migration with indexes on deleted_at and modified_at - Fix v2 migration swallowed ALTER TABLE errors - Tighten ~/.nib directory permissions to 0o700
427 lines
10 KiB
Go
427 lines
10 KiB
Go
package api
|
|
|
|
import (
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/lerko/nib/internal/db"
|
|
)
|
|
|
|
type CreateEntityRequest struct {
|
|
Body string `json:"body"`
|
|
Title *string `json:"title"`
|
|
Description *string `json:"description"`
|
|
Glyph *string `json:"glyph"`
|
|
TimeAnchor *string `json:"time_anchor"`
|
|
Tags []string `json:"tags"`
|
|
Pinned *bool `json:"pinned"`
|
|
CardType *string `json:"card_type"`
|
|
CardData *string `json:"card_data"`
|
|
}
|
|
|
|
type UpdateEntityRequest struct {
|
|
Body *string `json:"body"`
|
|
Title *string `json:"title"`
|
|
Description *string `json:"description"`
|
|
Glyph *string `json:"glyph"`
|
|
TimeAnchor *string `json:"time_anchor"`
|
|
Tags *[]string `json:"tags"`
|
|
Pinned *bool `json:"pinned"`
|
|
CardType *string `json:"card_type"`
|
|
CardData *string `json:"card_data"`
|
|
}
|
|
|
|
type PromoteRequest struct {
|
|
CardType string `json:"card_type"`
|
|
CardData *string `json:"card_data"`
|
|
}
|
|
|
|
func listEntities(store *db.Store) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
p := db.DefaultListParams()
|
|
|
|
if tag := r.URL.Query().Get("tag"); tag != "" {
|
|
p.Tag = &tag
|
|
}
|
|
if date := r.URL.Query().Get("date"); date != "" {
|
|
if _, err := time.Parse("2006-01-02", date); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_input", "bad date format, use YYYY-MM-DD")
|
|
return
|
|
}
|
|
p.Date = &date
|
|
}
|
|
if from := r.URL.Query().Get("from"); from != "" {
|
|
if _, err := time.Parse("2006-01-02", from); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_input", "bad from format, use YYYY-MM-DD")
|
|
return
|
|
}
|
|
p.From = &from
|
|
}
|
|
if to := r.URL.Query().Get("to"); to != "" {
|
|
if _, err := time.Parse("2006-01-02", to); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_input", "bad to format, use YYYY-MM-DD")
|
|
return
|
|
}
|
|
p.To = &to
|
|
}
|
|
if r.URL.Query().Get("cards_only") == "true" {
|
|
p.CardsOnly = true
|
|
}
|
|
if r.URL.Query().Get("include_deleted") == "true" {
|
|
p.IncludeDeleted = true
|
|
}
|
|
if sort := r.URL.Query().Get("sort"); sort != "" {
|
|
if sort != "created" && sort != "use_count" {
|
|
writeError(w, http.StatusBadRequest, "invalid_input", "sort must be 'created' or 'use_count'")
|
|
return
|
|
}
|
|
p.Sort = sort
|
|
}
|
|
if order := r.URL.Query().Get("order"); order != "" {
|
|
if order != "asc" && order != "desc" {
|
|
writeError(w, http.StatusBadRequest, "invalid_input", "order must be 'asc' or 'desc'")
|
|
return
|
|
}
|
|
p.Order = order
|
|
}
|
|
if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
|
|
limit, err := strconv.Atoi(limitStr)
|
|
if err != nil || limit < 1 {
|
|
writeError(w, http.StatusBadRequest, "invalid_input", "limit must be a positive integer")
|
|
return
|
|
}
|
|
if limit > 200 {
|
|
limit = 200
|
|
}
|
|
p.Limit = limit
|
|
}
|
|
if offsetStr := r.URL.Query().Get("offset"); offsetStr != "" {
|
|
offset, err := strconv.Atoi(offsetStr)
|
|
if err != nil || offset < 0 {
|
|
writeError(w, http.StatusBadRequest, "invalid_input", "offset must be a non-negative integer")
|
|
return
|
|
}
|
|
p.Offset = offset
|
|
}
|
|
if p.Limit <= 0 {
|
|
p.Limit = 50
|
|
}
|
|
|
|
total, err := store.Count(p)
|
|
if err != nil {
|
|
writeInternalError(w, err)
|
|
return
|
|
}
|
|
|
|
entities, err := store.List(p)
|
|
if err != nil {
|
|
writeInternalError(w, err)
|
|
return
|
|
}
|
|
|
|
items := make([]EntityResponse, len(entities))
|
|
for i, e := range entities {
|
|
items[i] = entityToResponse(e)
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"data": items,
|
|
"total": total,
|
|
"limit": p.Limit,
|
|
"offset": p.Offset,
|
|
})
|
|
}
|
|
}
|
|
|
|
func createEntity(store *db.Store) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
var req CreateEntityRequest
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
|
|
if req.Body == "" && req.Title == nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_input", "body or title is required")
|
|
return
|
|
}
|
|
|
|
glyph := db.GlyphNote
|
|
if req.Glyph != nil {
|
|
if !db.ValidGlyph(*req.Glyph) {
|
|
writeError(w, http.StatusBadRequest, "invalid_input", "invalid glyph value")
|
|
return
|
|
}
|
|
glyph = db.Glyph(*req.Glyph)
|
|
}
|
|
|
|
e := &db.Entity{
|
|
Body: req.Body,
|
|
Title: req.Title,
|
|
Description: req.Description,
|
|
Glyph: glyph,
|
|
TimeAnchor: req.TimeAnchor,
|
|
Tags: req.Tags,
|
|
}
|
|
if req.Pinned != nil && *req.Pinned {
|
|
e.Pinned = true
|
|
}
|
|
|
|
if req.CardType != nil {
|
|
if !db.ValidCardType(*req.CardType) {
|
|
writeError(w, http.StatusBadRequest, "invalid_type", "invalid card_type value")
|
|
return
|
|
}
|
|
ct := db.CardType(*req.CardType)
|
|
e.CardType = &ct
|
|
e.CardData = req.CardData
|
|
}
|
|
|
|
if err := store.Create(e); err != nil {
|
|
if err == db.ErrInvalidCardData {
|
|
writeError(w, http.StatusBadRequest, "invalid_card_data", "card_data must be valid JSON")
|
|
return
|
|
}
|
|
writeInternalError(w, err)
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusCreated, entityToResponse(e))
|
|
}
|
|
}
|
|
|
|
func getEntity(store *db.Store) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
e, err := store.Get(id)
|
|
if err != nil {
|
|
if err == db.ErrNotFound {
|
|
writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id)
|
|
return
|
|
}
|
|
writeInternalError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, entityToResponse(e))
|
|
}
|
|
}
|
|
|
|
func updateEntity(store *db.Store) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
|
|
var req UpdateEntityRequest
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
|
|
u := &db.EntityUpdate{}
|
|
u.Body = req.Body
|
|
u.Title = req.Title
|
|
u.Description = req.Description
|
|
u.Tags = req.Tags
|
|
u.Pinned = req.Pinned
|
|
u.CardData = req.CardData
|
|
|
|
if req.Glyph != nil {
|
|
if !db.ValidGlyph(*req.Glyph) {
|
|
writeError(w, http.StatusBadRequest, "invalid_input", "invalid glyph value")
|
|
return
|
|
}
|
|
g := db.Glyph(*req.Glyph)
|
|
u.Glyph = &g
|
|
}
|
|
if req.TimeAnchor != nil {
|
|
u.TimeAnchor = req.TimeAnchor
|
|
}
|
|
if req.CardType != nil {
|
|
if !db.ValidCardType(*req.CardType) {
|
|
writeError(w, http.StatusBadRequest, "invalid_type", "invalid card_type value")
|
|
return
|
|
}
|
|
ct := db.CardType(*req.CardType)
|
|
u.CardType = &ct
|
|
}
|
|
|
|
if err := store.Update(id, u); err != nil {
|
|
if err == db.ErrNotFound {
|
|
writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id)
|
|
return
|
|
}
|
|
if err == db.ErrInvalidCardData {
|
|
writeError(w, http.StatusBadRequest, "invalid_card_data", "card_data must be valid JSON")
|
|
return
|
|
}
|
|
writeInternalError(w, err)
|
|
return
|
|
}
|
|
|
|
e, err := store.Get(id)
|
|
if err != nil {
|
|
writeInternalError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, entityToResponse(e))
|
|
}
|
|
}
|
|
|
|
type DeleteResponse struct {
|
|
Result string `json:"result"`
|
|
}
|
|
|
|
func deleteEntity(store *db.Store) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
result, err := store.SoftDelete(id)
|
|
if err != nil {
|
|
if err == db.ErrNotFound {
|
|
writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id)
|
|
return
|
|
}
|
|
writeInternalError(w, err)
|
|
return
|
|
}
|
|
label := "soft"
|
|
if result == db.DeletedHard {
|
|
label = "hard"
|
|
}
|
|
writeJSON(w, http.StatusOK, DeleteResponse{Result: label})
|
|
}
|
|
}
|
|
|
|
func promoteEntity(store *db.Store) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
|
|
var req PromoteRequest
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
|
|
if req.CardType == "" {
|
|
writeError(w, http.StatusBadRequest, "invalid_input", "card_type is required")
|
|
return
|
|
}
|
|
if !db.ValidCardType(req.CardType) {
|
|
writeError(w, http.StatusBadRequest, "invalid_type", "invalid card_type value")
|
|
return
|
|
}
|
|
|
|
if err := store.Promote(id, db.CardType(req.CardType), req.CardData); err != nil {
|
|
if err == db.ErrNotFound {
|
|
writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id)
|
|
return
|
|
}
|
|
if err == db.ErrAlreadyPromoted {
|
|
writeError(w, http.StatusBadRequest, "invalid_promote", "entity is already crystallized")
|
|
return
|
|
}
|
|
if err == db.ErrInvalidCardData {
|
|
writeError(w, http.StatusBadRequest, "invalid_card_data", "card_data must be valid JSON")
|
|
return
|
|
}
|
|
writeInternalError(w, err)
|
|
return
|
|
}
|
|
|
|
e, err := store.Get(id)
|
|
if err != nil {
|
|
writeInternalError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, entityToResponse(e))
|
|
}
|
|
}
|
|
|
|
func demoteEntity(store *db.Store) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
|
|
if err := store.Demote(id); err != nil {
|
|
if err == db.ErrNotFound {
|
|
writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id)
|
|
return
|
|
}
|
|
if err == db.ErrAlreadyFluid {
|
|
writeError(w, http.StatusBadRequest, "invalid_demote", "entity is already fluid")
|
|
return
|
|
}
|
|
writeInternalError(w, err)
|
|
return
|
|
}
|
|
|
|
e, err := store.Get(id)
|
|
if err != nil {
|
|
writeInternalError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, entityToResponse(e))
|
|
}
|
|
}
|
|
|
|
type AbsorbRequest struct {
|
|
SourceID string `json:"source_id"`
|
|
}
|
|
|
|
func absorbEntity(store *db.Store) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
|
|
var req AbsorbRequest
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
|
|
if req.SourceID == "" {
|
|
writeError(w, http.StatusBadRequest, "invalid_input", "source_id is required")
|
|
return
|
|
}
|
|
if req.SourceID == id {
|
|
writeError(w, http.StatusBadRequest, "invalid_input", "target and source must be different entities")
|
|
return
|
|
}
|
|
|
|
if err := store.Absorb(id, req.SourceID); err != nil {
|
|
if err == db.ErrNotFound {
|
|
writeError(w, http.StatusNotFound, "not_found", "target or source entity not found")
|
|
return
|
|
}
|
|
if err == db.ErrTargetCrystallized {
|
|
writeError(w, http.StatusBadRequest, "invalid_absorb", "target is crystallized — demote first")
|
|
return
|
|
}
|
|
writeInternalError(w, err)
|
|
return
|
|
}
|
|
|
|
e, err := store.Get(id)
|
|
if err != nil {
|
|
writeInternalError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, entityToResponse(e))
|
|
}
|
|
}
|
|
|
|
func useEntity(store *db.Store) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
|
|
if err := store.IncrementUse(id); err != nil {
|
|
if err == db.ErrNotFound {
|
|
writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id)
|
|
return
|
|
}
|
|
writeInternalError(w, err)
|
|
return
|
|
}
|
|
|
|
e, err := store.Get(id)
|
|
if err != nil {
|
|
writeInternalError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, entityToResponse(e))
|
|
}
|
|
}
|