180 lines
5.8 KiB
Go
180 lines
5.8 KiB
Go
package storage
|
|
|
|
// Account-scoped UDL administration: what a uid holds, and removing all of it.
|
|
//
|
|
// Both operations enumerate the same thing — every `udl:rec:<uid>/<cid>/<key>`
|
|
// cell. Keys are ordered, so one account's cells are the key range under
|
|
// `udl:rec:<uid>/`, and every account's are the range under `udl:rec:`.
|
|
// UDLAllUserStats stays a bulk call (one walk buckets every account) because
|
|
// the accounts page needs a number for each.
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/metazla/meta-core/internal/kv"
|
|
)
|
|
|
|
// UDLUserStats counts what one account holds. Records is the number of cells,
|
|
// including tombstones — a retracted rating is still a row this account owns
|
|
// and still something a purge has to remove.
|
|
type UDLUserStats struct {
|
|
Records int `json:"records"`
|
|
Cids int `json:"cids"`
|
|
Keys int `json:"keys"`
|
|
}
|
|
|
|
// safeUIDForPattern rejects anything that would reach across the key
|
|
// structure (a `/` would let a uid prefix-match another account's range). meta-core's uids are alphanumeric
|
|
// (identity.validUID), so this only ever fires on a hostile or malformed one —
|
|
// but it fires here, in the layer that builds the pattern, rather than relying
|
|
// on every caller having validated first.
|
|
func safeUIDForPattern(uid string) error {
|
|
if uid == "" {
|
|
return fmt.Errorf("empty uid")
|
|
}
|
|
if strings.ContainsAny(uid, "*?[]\\/:") {
|
|
return fmt.Errorf("uid contains characters that are not valid in a uid: %q", uid)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// splitUDLRecSuffix splits the "<uid>/<cid>/<key>" tail of a udl:rec key.
|
|
//
|
|
// SplitN with a limit of 3 so a key containing a slash survives intact. cids
|
|
// never contain one; keys are a controlled vocabulary today, but a namespaced
|
|
// one arriving later must not silently truncate here — a truncated key would
|
|
// make the purge SREM the wrong index member and leave the real one behind.
|
|
func splitUDLRecSuffix(suffix string) (uid, cid, key string, ok bool) {
|
|
parts := strings.SplitN(suffix, "/", 3)
|
|
if len(parts) != 3 || parts[0] == "" || parts[1] == "" || parts[2] == "" {
|
|
return "", "", "", false
|
|
}
|
|
return parts[0], parts[1], parts[2], true
|
|
}
|
|
|
|
// UDLUserCells returns every (cid, key) pair this account holds a cell for,
|
|
// keyed by the "cid/key" tail with the cid as value (the purge needs them
|
|
// grouped).
|
|
func (c *Client) UDLUserCells(uid string) (map[string]string, error) {
|
|
if err := safeUIDForPattern(uid); err != nil {
|
|
return nil, err
|
|
}
|
|
s, err := c.kv()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ctx, cancel := opCtx()
|
|
defer cancel()
|
|
|
|
out := make(map[string]string)
|
|
err = s.ScanPrefix(ctx, "udl:rec:"+uid+"/", readerPage, func(rows []kv.KV) error {
|
|
for _, row := range rows {
|
|
gotUID, cid, key, ok := splitUDLRecSuffix(strings.TrimPrefix(row.K, "udl:rec:"))
|
|
if !ok || gotUID != uid {
|
|
continue
|
|
}
|
|
out[cid+"/"+key] = cid
|
|
}
|
|
return nil
|
|
})
|
|
return out, err
|
|
}
|
|
|
|
// UDLAllUserStats counts every account's cells in one walk.
|
|
//
|
|
// Returns only accounts that actually hold records; a uid absent from the map
|
|
// holds none. Callers render a zero for those rather than treating it as an
|
|
// error — an account created a minute ago legitimately has nothing.
|
|
func (c *Client) UDLAllUserStats() (map[string]UDLUserStats, error) {
|
|
s, err := c.kv()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ctx, cancel := opCtx()
|
|
defer cancel()
|
|
|
|
type acc struct {
|
|
cells int
|
|
cids map[string]struct{}
|
|
keys map[string]struct{}
|
|
}
|
|
byUID := make(map[string]*acc)
|
|
err = s.ScanPrefix(ctx, "udl:rec:", readerPage, func(rows []kv.KV) error {
|
|
for _, row := range rows {
|
|
uid, cid, key, ok := splitUDLRecSuffix(strings.TrimPrefix(row.K, "udl:rec:"))
|
|
if !ok {
|
|
continue
|
|
}
|
|
a := byUID[uid]
|
|
if a == nil {
|
|
a = &acc{cids: make(map[string]struct{}), keys: make(map[string]struct{})}
|
|
byUID[uid] = a
|
|
}
|
|
a.cells++
|
|
a.cids[cid] = struct{}{}
|
|
a.keys[key] = struct{}{}
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make(map[string]UDLUserStats, len(byUID))
|
|
for uid, a := range byUID {
|
|
out[uid] = UDLUserStats{Records: a.cells, Cids: len(a.cids), Keys: len(a.keys)}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// UDLPurgeUser removes every trace of one account from the User Data Layer:
|
|
// its cells, its three user-scoped indexes, and its membership in the shared
|
|
// cid-scoped indexes. Returns what was removed.
|
|
//
|
|
// The shared indexes are the part that is easy to miss and expensive to leave.
|
|
// `udl:idx:cid:<cid>:key:<key>` and `udl:idx:cid:<cid>:users` are keyed by cid,
|
|
// not by uid, so a purge that only deleted `udl:rec:<uid>/*` would leave this
|
|
// uid listed as an opinion-holder on every title it ever touched.
|
|
//
|
|
// Idempotent: purging an account with nothing stored is a no-op returning
|
|
// zeroes, so a retry is safe.
|
|
func (c *Client) UDLPurgeUser(uid string) (UDLUserStats, error) {
|
|
cells, err := c.UDLUserCells(uid)
|
|
if err != nil {
|
|
return UDLUserStats{}, err
|
|
}
|
|
|
|
stats := UDLUserStats{Records: len(cells)}
|
|
distinctCids := make(map[string]struct{})
|
|
distinctKeys := make(map[string]struct{})
|
|
err = c.update(func(tx *kv.Tx) error {
|
|
for cidKey, cid := range cells {
|
|
key := strings.TrimPrefix(cidKey, cid+"/")
|
|
distinctCids[cid] = struct{}{}
|
|
distinctKeys[key] = struct{}{}
|
|
if _, err := tx.Delete(udlRecKey(uid, cid, key)); err != nil {
|
|
return err
|
|
}
|
|
if err := udlRem(tx, udlIdxCidKey(cid, key), uid); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
for cid := range distinctCids {
|
|
if err := udlRem(tx, udlIdxCidUsers(cid), uid); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
// The three user-scoped indexes (`:key:*`, `:cid:*`, `:cids`) are one
|
|
// key range, dropped whole — which also catches index entries whose
|
|
// cell is already gone (the state a half-finished purge leaves).
|
|
_, err := tx.DeletePrefix("udl:idx:user:"+uid+":", 0)
|
|
return err
|
|
})
|
|
if err != nil {
|
|
return UDLUserStats{}, fmt.Errorf("purge: %w", err)
|
|
}
|
|
stats.Cids = len(distinctCids)
|
|
stats.Keys = len(distinctKeys)
|
|
return stats, nil
|
|
}
|