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