358 lines
11 KiB
Go
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})
|
|
}
|