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.
99 lines
2.6 KiB
Go
99 lines
2.6 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"log"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/lerko/nib/internal/db"
|
|
)
|
|
|
|
const maxBodySize = 1 << 20 // 1 MB
|
|
|
|
type ErrorResponse struct {
|
|
Error string `json:"error"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
type EntityResponse struct {
|
|
ID string `json:"id"`
|
|
CreatedAt string `json:"created_at"`
|
|
ModifiedAt string `json:"modified_at"`
|
|
Body string `json:"body"`
|
|
Title *string `json:"title"`
|
|
Description *string `json:"description"`
|
|
Glyph string `json:"glyph"`
|
|
TimeAnchor *string `json:"time_anchor"`
|
|
CompletedAt *string `json:"completed_at"`
|
|
Pinned bool `json:"pinned"`
|
|
DeletedAt *string `json:"deleted_at"`
|
|
Tags []string `json:"tags"`
|
|
CardType *string `json:"card_type"`
|
|
CardData *string `json:"card_data"`
|
|
UseCount int `json:"use_count"`
|
|
LastUsedAt *string `json:"last_used_at"`
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
if err := json.NewEncoder(w).Encode(v); err != nil {
|
|
log.Printf("writeJSON encode error: %v", err)
|
|
}
|
|
}
|
|
|
|
func writeError(w http.ResponseWriter, status int, code, message string) {
|
|
writeJSON(w, status, ErrorResponse{Error: code, Message: message})
|
|
}
|
|
|
|
func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool {
|
|
r.Body = http.MaxBytesReader(w, r.Body, maxBodySize)
|
|
if err := json.NewDecoder(r.Body).Decode(dst); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_input", "malformed JSON: "+err.Error())
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func writeInternalError(w http.ResponseWriter, err error) {
|
|
log.Printf("internal error: %v", err)
|
|
writeError(w, http.StatusInternalServerError, "internal", "internal server error")
|
|
}
|
|
|
|
func entityToResponse(e *db.Entity) EntityResponse {
|
|
resp := EntityResponse{
|
|
ID: e.ID,
|
|
CreatedAt: e.CreatedAt.Format(time.RFC3339),
|
|
ModifiedAt: e.ModifiedAt.Format(time.RFC3339),
|
|
Body: e.Body,
|
|
Title: e.Title,
|
|
Description: e.Description,
|
|
Glyph: string(e.Glyph),
|
|
Pinned: e.Pinned,
|
|
Tags: e.Tags,
|
|
UseCount: e.UseCount,
|
|
}
|
|
if resp.Tags == nil {
|
|
resp.Tags = []string{}
|
|
}
|
|
resp.TimeAnchor = e.TimeAnchor
|
|
resp.CompletedAt = formatTimeRespPtr(e.CompletedAt)
|
|
resp.DeletedAt = formatTimeRespPtr(e.DeletedAt)
|
|
resp.LastUsedAt = formatTimeRespPtr(e.LastUsedAt)
|
|
if e.CardType != nil {
|
|
s := string(*e.CardType)
|
|
resp.CardType = &s
|
|
}
|
|
resp.CardData = e.CardData
|
|
return resp
|
|
}
|
|
|
|
func formatTimeRespPtr(t *time.Time) *string {
|
|
if t == nil {
|
|
return nil
|
|
}
|
|
s := t.Format(time.RFC3339)
|
|
return &s
|
|
}
|