Files
meta-core/internal/api/udl.go
T

358 lines
11 KiB
Go

package api
// User Data Layer facade (/api/udl/*). Since meta-core v2.0.0 a user's data are
// ordinary MMUR2 claims signed by the user's key (plaintext for public-social
// keys, ciphertext for private ones); these routes keep their paths for the
// clients (meta-watch, meta-read, meta-listen, the gateway mirror) and speak
// claims in the §3.4 JSON form:
//
// {"cid","uid","key","value": "…" | {"b64": "…"} | null,"ts","sig"}
//
// A write is the same as POST /api/claims; newest ts wins per (uid, cid, key).
import (
"encoding/json"
"math"
"net/http"
"strconv"
"strings"
"github.com/gorilla/mux"
"github.com/metazla/meta-core/internal/claim"
"github.com/metazla/meta-core/internal/storage"
)
// UDLPutResponse answers PUT /api/udl/record.
type UDLPutResponse struct {
Accepted bool `json:"accepted"`
Status storage.ClaimStatus `json:"status"`
}
// UDLPutBatchRequest is PUT /api/udl/records.
type UDLPutBatchRequest struct {
Records []claim.Claim `json:"records"`
}
// UDLPutBatchResult is one entry of the batch response.
type UDLPutBatchResult struct {
Key string `json:"key"`
Accepted bool `json:"accepted"`
Status storage.ClaimStatus `json:"status"`
Error string `json:"error,omitempty"`
}
// UDLPutBatchResponse answers PUT /api/udl/records.
type UDLPutBatchResponse struct {
Results []UDLPutBatchResult `json:"results"`
}
const maxUDLBatch = 1000
// UDLRecordResponse answers GET /api/udl/record. Claim is the stored claim,
// tombstone included (its ts is what a newer write must exceed); Exists is
// false when it is absent or a tombstone.
type UDLRecordResponse struct {
Exists bool `json:"exists"`
Claim *claim.Claim `json:"claim"`
}
// UDLAggregate is one key's public aggregate on a cid.
type UDLAggregate struct {
Key string `json:"key"`
Count int `json:"count"`
Avg *float64 `json:"avg"`
Trues int `json:"trues"`
SampleSize int `json:"sample_size"`
}
func (s *Server) udlReady(w http.ResponseWriter) bool {
if !s.storage.IsConnected() {
writeError(w, http.StatusServiceUnavailable, "storage not connected")
return false
}
return true
}
// handleUDLRecordGet handles GET /api/udl/record?uid=&cid=&key=.
func (s *Server) handleUDLRecordGet(w http.ResponseWriter, r *http.Request) {
if !s.udlReady(w) {
return
}
q := r.URL.Query()
uid, cid, key := q.Get("uid"), q.Get("cid"), q.Get("key")
if uid == "" || cid == "" || key == "" {
writeError(w, http.StatusBadRequest, "uid, cid and key are required")
return
}
cl, ok, err := s.storage.UDLGetClaim(uid, cid, key)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
resp := UDLRecordResponse{}
if ok {
resp.Claim = &cl
resp.Exists = !cl.Value.IsNull()
}
writeJSON(w, http.StatusOK, resp)
}
// handleUDLRecordPut handles PUT /api/udl/record with one claim as the body.
func (s *Server) handleUDLRecordPut(w http.ResponseWriter, r *http.Request) {
if !s.udlReady(w) {
return
}
var cl claim.Claim
if err := json.NewDecoder(r.Body).Decode(&cl); err != nil {
writeError(w, http.StatusBadRequest, "body must be one claim: "+err.Error())
return
}
results, err := s.storage.WriteClaims(r.Context(), []claim.Claim{cl})
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
res := results[0]
switch res.Status {
case storage.StatusAccepted, storage.StatusUnchanged:
writeJSON(w, http.StatusOK, UDLPutResponse{Accepted: true, Status: res.Status})
case storage.StatusStale:
writeErrorSlug(w, http.StatusConflict, "stale_version", "a claim with an equal or newer ts already exists", true)
default:
writeErrorSlug(w, http.StatusBadRequest, string(res.Status), res.Reason, false)
}
}
// handleUDLRecordsPut handles PUT /api/udl/records {records: [claim, …]}.
func (s *Server) handleUDLRecordsPut(w http.ResponseWriter, r *http.Request) {
if !s.udlReady(w) {
return
}
var req UDLPutBatchRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body: "+err.Error())
return
}
if len(req.Records) == 0 {
writeError(w, http.StatusBadRequest, "records is required")
return
}
if len(req.Records) > maxUDLBatch {
writeError(w, http.StatusBadRequest, "too many records in one batch")
return
}
results, err := s.storage.WriteClaims(r.Context(), req.Records)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
out := make([]UDLPutBatchResult, len(results))
for i, res := range results {
out[i] = UDLPutBatchResult{
Key: req.Records[i].Key,
Status: res.Status,
Accepted: res.Status == storage.StatusAccepted || res.Status == storage.StatusUnchanged,
Error: res.Reason,
}
}
writeJSON(w, http.StatusOK, UDLPutBatchResponse{Results: out})
}
func (s *Server) writeUDLEntries(w http.ResponseWriter, uid, cid, key string) {
entries, err := s.storage.UDLClaimsForUser(uid, cid, key)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if entries == nil {
entries = []claim.Claim{}
}
writeJSON(w, http.StatusOK, map[string]interface{}{"entries": entries})
}
// handleUDLUserKey handles GET /api/udl/user/{uid}/key/{key}: every live claim
// of uid on key (My List, Continue Watching).
func (s *Server) handleUDLUserKey(w http.ResponseWriter, r *http.Request) {
if !s.udlReady(w) {
return
}
vars := mux.Vars(r)
if vars["uid"] == "" || vars["key"] == "" {
writeError(w, http.StatusBadRequest, "uid and key are required")
return
}
s.writeUDLEntries(w, vars["uid"], "", vars["key"])
}
// handleUDLUserCid handles GET /api/udl/user/{uid}/cid/{cid}.
func (s *Server) handleUDLUserCid(w http.ResponseWriter, r *http.Request) {
if !s.udlReady(w) {
return
}
vars := mux.Vars(r)
if vars["uid"] == "" || vars["cid"] == "" {
writeError(w, http.StatusBadRequest, "uid and cid are required")
return
}
s.writeUDLEntries(w, vars["uid"], vars["cid"], "")
}
// handleUDLUserAll handles GET /api/udl/user/{uid}/all?after=&limit= — the
// whole profile, paged on cid boundaries (recovery / export).
func (s *Server) handleUDLUserAll(w http.ResponseWriter, r *http.Request) {
if !s.udlReady(w) {
return
}
uid := mux.Vars(r)["uid"]
if uid == "" {
writeError(w, http.StatusBadRequest, "uid is required")
return
}
limit := maxUDLBatch
if v := r.URL.Query().Get("limit"); v != "" {
n, err := strconv.Atoi(v)
if err != nil || n <= 0 {
writeError(w, http.StatusBadRequest, "limit must be a positive integer")
return
}
limit = min(n, limit)
}
entries, nextCid, err := s.storage.UDLAllForUser(uid, r.URL.Query().Get("after"), limit)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if entries == nil {
entries = []claim.Claim{}
}
writeJSON(w, http.StatusOK, map[string]interface{}{"entries": entries, "nextCid": nextCid})
}
// handleUDLUserCount handles GET /api/udl/user/{uid}/count.
func (s *Server) handleUDLUserCount(w http.ResponseWriter, r *http.Request) {
if !s.udlReady(w) {
return
}
uid := mux.Vars(r)["uid"]
if uid == "" {
writeError(w, http.StatusBadRequest, "uid is required")
return
}
n, err := s.storage.UDLCountUserCids(uid)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"cids": n})
}
// handleUDLCidUsers handles GET /api/udl/cid/{cid}/users: signers holding live
// claims on the cid.
func (s *Server) handleUDLCidUsers(w http.ResponseWriter, r *http.Request) {
if !s.udlReady(w) {
return
}
cid := mux.Vars(r)["cid"]
if cid == "" {
writeError(w, http.StatusBadRequest, "cid is required")
return
}
users, err := s.storage.UDLCidUsers(cid)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"users": users})
}
// handleUDLAggregate handles GET /api/udl/cid/{cid}/aggregate?keys=like,userRating.
func (s *Server) handleUDLAggregate(w http.ResponseWriter, r *http.Request) {
if !s.udlReady(w) {
return
}
cid := mux.Vars(r)["cid"]
if cid == "" {
writeError(w, http.StatusBadRequest, "cid is required")
return
}
keysParam := r.URL.Query().Get("keys")
if keysParam == "" {
keysParam = "like,userRating"
}
aggregates := make([]UDLAggregate, 0)
for _, key := range strings.Split(keysParam, ",") {
key = strings.TrimSpace(key)
if key == "" {
continue
}
values, err := s.storage.UDLPublicValues(cid, key)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
aggregates = append(aggregates, aggregatePublicValues(key, values))
}
writeJSON(w, http.StatusOK, map[string]interface{}{"aggregates": aggregates})
}
// aggregatePublicValues folds raw JSON scalar values into an UDLAggregate:
// counts booleans as trues and averages numeric values.
func aggregatePublicValues(key string, values []string) UDLAggregate {
agg := UDLAggregate{Key: key, SampleSize: len(values), Count: len(values)}
var sum float64
var numeric int
for _, raw := range values {
var v interface{}
if err := json.Unmarshal([]byte(raw), &v); err != nil {
continue
}
switch t := v.(type) {
case bool:
if t {
agg.Trues++
}
case float64:
sum += t
numeric++
}
}
if numeric > 0 {
avg := math.Round((sum/float64(numeric))*100) / 100
agg.Avg = &avg
}
return agg
}
// udlUserStatsResponse is the shape of GET /api/udl/users/stats.
type udlUserStatsResponse struct {
// Users maps uid -> counts. Accounts holding nothing are absent rather than
// present with zeroes; a caller rendering a list treats "missing" as 0.
Users map[string]storage.UDLUserStats `json:"users"`
}
// handleUDLUserStats handles GET /api/udl/users/stats — how much every account
// holds, in one keyspace walk.
//
// Bulk rather than per-uid because the only caller is the accounts page, which
// needs a number for every row: the counts come from a SCAN (there is no
// per-user cid index — see storage/udl_admin.go), and MATCH filters without
// indexing, so N per-uid calls would walk the whole keyspace N times.
//
// Deliberately not on the critical path of anything. It is an admin read whose
// cost grows with the keyspace, so callers should fetch it after rendering,
// never before.
func (s *Server) handleUDLUserStats(w http.ResponseWriter, r *http.Request) {
if !s.storage.IsConnected() {
writeErrorSlug(w, http.StatusServiceUnavailable, "storage_unavailable",
"storage not connected", true)
return
}
stats, err := s.storage.UDLAllUserStats()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, udlUserStatsResponse{Users: stats})
}