e9ecc4c1f7
CI / test (pull_request) Successful in 2m13s
Fix goroutine-unsafe ULID entropy by wrapping in LockedMonotonicReader. Move PRAGMA foreign_keys outside transaction in v3 migration where SQLite was silently ignoring it. Escape LIKE wildcards in link resolution to prevent false matches. Add non-localhost binding warning, log writeJSON encoder errors, add ?permanent=true for explicit hard delete, preserve title/description during absorb, use millisecond backup timestamps, add path.Clean to spaHandler. Frontend gains checkedJSON() for resp.ok validation, consistent stopPropagation, and shared renderCardSections() to eliminate duplicate rendering.
441 lines
11 KiB
Go
441 lines
11 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(r.Context(), p)
|
|
if err != nil {
|
|
writeInternalError(w, err)
|
|
return
|
|
}
|
|
|
|
entities, err := store.List(r.Context(), 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(r.Context(), 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(r.Context(), 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(r.Context(), 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(r.Context(), 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")
|
|
|
|
if r.URL.Query().Get("permanent") == "true" {
|
|
if err := store.HardDelete(r.Context(), id); 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, DeleteResponse{Result: "hard"})
|
|
return
|
|
}
|
|
|
|
result, err := store.SoftDelete(r.Context(), 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(r.Context(), 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(r.Context(), 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(r.Context(), 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(r.Context(), 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(r.Context(), 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(r.Context(), 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(r.Context(), 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(r.Context(), id)
|
|
if err != nil {
|
|
writeInternalError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, entityToResponse(e))
|
|
}
|
|
}
|