package storage // Account-scoped UDL administration: what a uid holds, and removing all of it. // // Both operations enumerate the same thing — every `udl:rec://` // cell. Keys are ordered, so one account's cells are the key range under // `udl:rec:/`, 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 "//" 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::key:` and `udl:idx:cid::users` are keyed by cid, // not by uid, so a purge that only deleted `udl:rec:/*` 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 }