Compare commits

...
14 Commits
Author SHA1 Message Date
Worph 40b553016c cid vector
Build and Push Docker Image / build (push) Failing after 13s
2026-09-13 14:36:50 +02:00
Worph 4894810fd4 new file tuple API
Build and Push Docker Image / build (push) Failing after 13s
2026-09-13 12:01:33 +02:00
Worph ac21315255 improved cid resolution
Build and Push Docker Image / build (push) Failing after 13s
2026-09-12 21:37:38 +02:00
Worph ec1b227631 improved search 2026-09-12 16:37:51 +02:00
Worph 8f4e08b6f1 rework domain field
Build and Push Docker Image / build (push) Failing after 13s
2026-09-02 00:07:02 +02:00
Worph 0c4e3a2227 align to metadata keys doc
Build and Push Docker Image / build (push) Failing after 13s
2026-08-30 19:52:36 +02:00
Worph 1f41ea0166 bugfix scroll
Build and Push Docker Image / build (push) Failing after 14s
2026-08-25 18:09:44 +02:00
Worph 5efdd2c07d improved identity management 2026-08-25 17:48:23 +02:00
Worph 6ae87a2cdb audio support widening 2026-08-21 18:23:20 +02:00
Worph 604e718cdc add identity management
Build and Push Docker Image / build (push) Failing after 13s
2026-08-06 11:03:50 +02:00
Worph 0bbc656263 add identity page
Build and Push Docker Image / build (push) Failing after 13s
2026-08-05 23:37:12 +02:00
Worph a98734171c bugfix indexer field
Build and Push Docker Image / build (push) Failing after 13s
2026-08-03 19:44:22 +02:00
Worph a61057cd81 optimisation on assets retreive 2026-08-03 18:42:05 +02:00
Worph 55d08e77ed improvement on user management
Build and Push Docker Image / build (push) Failing after 14s
2026-08-02 21:35:39 +02:00
47 changed files with 6913 additions and 407 deletions
+5
View File
@@ -209,6 +209,7 @@ the `/file/{cid}` reverse-lookup path.)
```bash ```bash
GET /api/metadata/hash-ids GET /api/metadata/hash-ids
GET /api/files/tuples
GET /api/metadata/list GET /api/metadata/list
POST /api/metadata/search POST /api/metadata/search
POST /api/metadata/batch POST /api/metadata/batch
@@ -287,6 +288,10 @@ return a deprecation pointer at the `/api/watchers/*` routes
```bash ```bash
POST /api/admin/migrate-dual-roots # reunify stranded midhash-rooted entries POST /api/admin/migrate-dual-roots # reunify stranded midhash-rooted entries
POST /api/admin/sweep-literature-echoes[?apply=true]
# strip query echoes off literature cards: anilistid on site cards,
# workcid/chapterNumber/volumeNumber/chapterStart/chapterEnd on any card.
# Dry-run unless apply=true; returns {dryRun, planned, fixed, byKey}
GET/PUT/DELETE /webdav/... # mounted on /files GET/PUT/DELETE /webdav/... # mounted on /files
``` ```
+1 -1
View File
@@ -21,7 +21,7 @@
min-height: 100vh; min-height: 100vh;
} }
</style> </style>
<script type="module" crossorigin src="/assets/index-Cbd1uH5A.js"></script> <script type="module" crossorigin src="/assets/index-BW9EQGCP.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DtR_2V4s.css"> <link rel="stylesheet" crossorigin href="/assets/index-DtR_2V4s.css">
</head> </head>
<body> <body>
+10
View File
@@ -8,6 +8,7 @@
"name": "@meta-core/dashboard", "name": "@meta-core/dashboard",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@noble/secp256k1": "^3.1.0",
"iso-639-3": "^3.0.1", "iso-639-3": "^3.0.1",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
@@ -744,6 +745,15 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@noble/secp256k1": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@noble/secp256k1/-/secp256k1-3.1.0.tgz",
"integrity": "sha512-+F7iS7tUMaNGXcc9X3PjmjvuQnXEuSjCRNzVVA2xAcKXgCaP0dHYz4SFyt4FKNHef7sOP//xihowcySSS7PK9g==",
"license": "MIT",
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@remix-run/router": { "node_modules/@remix-run/router": {
"version": "1.23.2", "version": "1.23.2",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
+3 -2
View File
@@ -9,10 +9,11 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@noble/secp256k1": "^3.1.0",
"iso-639-3": "^3.0.1",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-router-dom": "^6.20.0", "react-router-dom": "^6.20.0"
"iso-639-3": "^3.0.1"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^18.2.38", "@types/react": "^18.2.38",
+127
View File
@@ -0,0 +1,127 @@
// Client-side proof of possession for account operations.
//
// meta-core stores every private key in plaintext, so it can sign as anybody —
// which means a signature it produced proves nothing about who asked. The only
// signature that carries authority is one made where the key actually is, and
// for a browser-driven admin console that is here. meta-core enforces the other
// half by refusing to sign anything in the challenge domain
// (identity.ChallengeDomain), so this path cannot be short-circuited.
//
// The secret key entered on this page is used for exactly one signature and is
// never sent anywhere: `/api/identity/challenge` receives only the uid, and the
// request that follows carries only the challenge and its signature.
//
// Same scheme, same curve and same encodings as meta-watch's sign-in
// (packages/meta-watch/ui/js/auth.js) — deliberately, so one implementation on
// the server verifies both.
import * as secp from '@noble/secp256k1';
export type IdentityAction = 'reveal' | 'delete';
function hexToBytes(hex: string): Uint8Array | null {
let h = hex.trim();
if (h.startsWith('0x') || h.startsWith('0X')) h = h.slice(2);
if (h.length !== 64 || /[^0-9a-fA-F]/.test(h)) return null;
const out = new Uint8Array(32);
for (let i = 0; i < 32; i++) out[i] = parseInt(h.slice(i * 2, i * 2 + 2), 16);
return out;
}
function bytesToBase64(bytes: Uint8Array): string {
let s = '';
for (const b of bytes) s += String.fromCharCode(b);
return btoa(s);
}
const B58 = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
/// base58btc, the multibase "z" alphabet. Spelled out rather than pulled in:
/// the alternative is a second dependency for thirty lines of arithmetic.
function base58Encode(bytes: Uint8Array): string {
const digits: number[] = [0];
for (const byte of bytes) {
let carry = byte;
for (let i = 0; i < digits.length; i++) {
carry += digits[i] << 8;
digits[i] = carry % 58;
carry = (carry / 58) | 0;
}
while (carry) {
digits.push(carry % 58);
carry = (carry / 58) | 0;
}
}
// Leading zero bytes encode as leading '1's rather than being dropped.
let out = '';
for (let i = 0; i < bytes.length && bytes[i] === 0; i++) out += B58[0];
for (let i = digits.length - 1; i >= 0; i--) out += B58[digits[i]];
return out;
}
/// The uid a secret key belongs to: "z" + base58btc(33-byte compressed pubkey).
/// Derived locally so a mistyped or wrong-account key is caught before any
/// request goes out. Mirrors `identityFromPrivKey` in meta-core's identity.go.
export function uidFromSecretKey(secretKeyHex: string): string | null {
const raw = hexToBytes(secretKeyHex);
if (!raw) return null;
try {
return 'z' + base58Encode(secp.getPublicKey(raw, true));
} catch {
return null; // not a valid scalar
}
}
/// Whether this page can sign at all.
///
/// noble's signAsync takes its sha256 from crypto.subtle, which browsers expose
/// only in a secure context. The dashboard behind Caddy is https and fine; the
/// debug-direct port (18083) is plain http, where every signature would fail
/// with a cryptic error unless we say so first.
export function canSign(): boolean {
return Boolean(globalThis.crypto?.subtle);
}
export const INSECURE_CONTEXT_MESSAGE =
'This page cannot sign over a plain-http connection — browsers only expose the ' +
'needed crypto on https (or localhost). Reach the dashboard through Caddy ' +
'(https://metacore-dev.localhost:8083) rather than the debug-direct port.';
/// Ask meta-core for a challenge, sign it locally, and hand back the proof.
///
/// Throws with a message fit to show the user. The caller passes the proof
/// straight into the reveal/delete request body.
export async function proveOwnership(
uid: string,
action: IdentityAction,
secretKeyHex: string
): Promise<{ challenge: string; signature: string }> {
if (!canSign()) throw new Error(INSECURE_CONTEXT_MESSAGE);
const raw = hexToBytes(secretKeyHex);
if (!raw) {
throw new Error('That is not a valid secret key: expected 64 hex characters.');
}
const derived = uidFromSecretKey(secretKeyHex);
if (!derived) throw new Error('That is not a valid secp256k1 secret key.');
if (derived !== uid) {
// Caught here rather than by the server, so the message can say which
// account the key actually is — the useful half of the answer.
throw new Error(`That key belongs to a different account (${derived}), not this one.`);
}
const cRes = await fetch('/api/identity/challenge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ uid, action }),
});
const cBody = await cRes.json().catch(() => ({}));
if (!cRes.ok || !cBody.challenge) {
throw new Error(cBody.message ?? `Could not start verification (HTTP ${cRes.status}).`);
}
// signAsync prehashes with sha256 and returns a 64-byte compact (r‖s)
// signature — noble v3 dropped DER. meta-core's identity.Verify accepts both.
const sig = await secp.signAsync(new TextEncoder().encode(cBody.challenge), raw);
return { challenge: cBody.challenge, signature: bytesToBase64(sig) };
}
+614 -130
View File
@@ -1,21 +1,74 @@
import { useEffect, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
// Status returned by GET /api/identity. Private key is never present here — import {
// it is shown exactly once at generation time (see GeneratedKeyPanel). INSECURE_CONTEXT_MESSAGE,
interface IdentityStatus { canSign,
hasIdentity: boolean; proveOwnership,
uid?: string; uidFromSecretKey,
curve?: string; type IdentityAction,
createdAt?: number; } from '../lib/sign';
}
interface GeneratedKey { // Admin console for meta-core's account keystore.
//
// meta-core stores one secp256k1 keypair per file under
// {META_CORE_PATH}/identity/accounts/<uid>.json and has NO notion of a current
// user — which account is "you" is decided by whichever client signs in
// (meta-watch). So this page lists accounts; it never speaks of an active one.
//
// The private key is returned by exactly two calls: POST /api/identity/generate
// (once, at creation) and POST /api/identity/reveal (on demand, for an account
// that already exists). Both land in the same exclusive SecretKeyPanel.
//
// Reveal and remove both require the account's own secret key, entered here and
// used to sign a one-shot challenge locally — the key itself is never sent. So
// this page can enumerate and create, but it holds no authority over an
// existing account that its operator does not already have the key for. There
// is deliberately no admin override: on this node the private key *is* the
// account.
interface Account {
uid: string; uid: string;
curve: string; curve: string;
createdAt: number; createdAt: number;
privateKeyHex: string;
} }
// What one account holds in the User Data Layer. Absent from
// /api/udl/users/stats means zero, not unknown.
interface Stats {
records: number;
cids: number;
keys: number;
}
// A secret on screen. Carries no createdAt: /api/identity/reveal answers with
// {uid, curve, privateKeyHex} only.
interface SecretKeyView {
uid: string;
privateKeyHex: string;
kind: 'generated' | 'revealed';
}
// Which row has an open confirmation, and for what. One discriminated value
// rather than a pair of ids, so at most one confirm panel can ever be open.
interface Pending {
uid: string;
kind: 'remove' | 'reveal';
}
// Both confirmable operations map to a server-side action name, and the
// challenge is bound to it: a challenge minted to reveal a key cannot delete
// the account. Keeping the mapping in one place stops the two drifting.
const ACTION_FOR: Record<Pending['kind'], IdentityAction> = {
reveal: 'reveal',
remove: 'delete',
};
// meta-watch's sentinel cell for profile records: display name and avatar are
// about the *user*, not a content file, so they live at (uid, "self", ...).
// meta-core stores them opaquely and knows nothing of the convention.
const PROFILE_CID = 'self';
const PROFILE_NAME_KEY = 'profile:name';
const cardStyle: React.CSSProperties = { const cardStyle: React.CSSProperties = {
background: '#16213e', background: '#16213e',
borderRadius: '8px', borderRadius: '8px',
@@ -59,6 +112,21 @@ const monoStyle: React.CSSProperties = {
borderRadius: '4px', borderRadius: '4px',
}; };
const dangerPanelStyle: React.CSSProperties = {
background: '#1f0a0a',
border: '1px solid #7f1d1d',
padding: '0.75rem 1rem',
borderRadius: '4px',
marginTop: '0.75rem',
};
const errorTextStyle: React.CSSProperties = {
color: '#f87171',
background: '#1f0a0a',
padding: '0.5rem 0.75rem',
borderRadius: '4px',
};
function formatCreatedAt(unixSec?: number): string { function formatCreatedAt(unixSec?: number): string {
if (!unixSec) return ''; if (!unixSec) return '';
try { try {
@@ -79,25 +147,165 @@ async function errorMessage(res: Response, fallback: string): Promise<string> {
return `${fallback} (HTTP ${res.status})`; return `${fallback} (HTTP ${res.status})`;
} }
function GeneratedKeyPanel({ data, onDismiss }: { data: GeneratedKey; onDismiss: () => void }) { // Copy text, falling back to selecting the element that displays it.
const [copied, setCopied] = useState(false); //
const copy = async () => { // The fallback is load-bearing, not politeness: the dashboard is served over
try { // plain HTTP, where navigator.clipboard is undefined — without it the copy
await navigator.clipboard.writeText(data.privateKeyHex); // buttons do nothing at all on a real deployment.
setCopied(true); async function copyToClipboard(text: string, fallbackElementId: string): Promise<void> {
setTimeout(() => setCopied(false), 2000); try {
} catch { await navigator.clipboard.writeText(text);
// Clipboard API blocked (non-HTTPS context, etc.) — fall back to selection. } catch {
const sel = window.getSelection(); const sel = window.getSelection();
const el = document.getElementById(fallbackElementId);
if (sel && el) {
const range = document.createRange(); const range = document.createRange();
const el = document.getElementById('mm-privkey-box'); range.selectNodeContents(el);
if (sel && el) { sel.removeAllRanges();
range.selectNodeContents(el); sel.addRange(range);
sel.removeAllRanges();
sel.addRange(range);
}
} }
}
}
async function fetchAccounts(): Promise<Account[]> {
const res = await fetch('/api/identity/accounts');
if (!res.ok) throw new Error(await errorMessage(res, 'Failed to load accounts'));
const data = (await res.json()) as { accounts?: Account[] };
// Backend order is CreatedAt ascending then uid (identity.List) — stable, so
// render it as given rather than re-sorting here.
return data.accounts ?? [];
}
// Resolve an account's display name from the User Data Layer, or null.
//
// Never throws and never reports an error upward: a name is decoration on top
// of the uid, and the account list must render identically whether Redis is up
// (identity itself is file-backed, the UDL is not). The name is also *unsigned*
// — meta-core stores the plaintext beside the signed record and verifies
// nothing — so the uid stays on screen as the real identity, and the length cap
// bounds what a hostile writer can do to the layout.
async function fetchDisplayName(uid: string): Promise<string | null> {
const url =
`/api/udl/record?uid=${encodeURIComponent(uid)}` +
`&cid=${encodeURIComponent(PROFILE_CID)}` +
`&key=${encodeURIComponent(PROFILE_NAME_KEY)}`;
try {
const res = await fetch(url);
if (!res.ok) return null; // 503 when storage is down, 500, …
const data = (await res.json()) as { exists?: boolean; value?: unknown };
// Key off `value`, not `exists`: clearing a name writes a tombstone, which
// leaves the cell existing with no plaintext. Private-tier cells look the
// same, which is exactly right — there is nothing to show for either.
if (typeof data.value !== 'string') return null;
const name = data.value.trim();
return name ? name.slice(0, 64) : null;
} catch {
return null;
}
}
// How much each account holds. One call for the whole page, not one per row:
// meta-core answers it from a keyspace SCAN (there is no per-user cid index),
// so per-row calls would walk the keyspace once per account.
//
// Never throws, for the same reason fetchDisplayName does not: the counts are
// context on top of the uid, and the list must render identically when Redis is
// down — accounts are file-backed, their data is not.
//
// Returns null on failure rather than an empty map. The difference is
// load-bearing: an empty map renders as "no stored data" against every account,
// which inside a delete confirmation is a false statement about what is at
// stake. Unknown has to look like unknown.
async function fetchStats(): Promise<Record<string, Stats> | null> {
try {
const res = await fetch('/api/udl/users/stats');
if (!res.ok) return null;
const data = (await res.json()) as { users?: Record<string, Stats> };
return data.users ?? {};
} catch {
return null;
}
}
// The secret-key box shared by the reveal and remove confirmations.
//
// Separate from the surrounding panel so both operations ask the same way, and
// so the key lives in this component's state rather than the page's — it is
// discarded with the panel instead of lingering in a parent that outlives it.
function SecretKeyConfirm({
label,
actionLabel,
busy,
onConfirm,
onCancel,
}: {
label: string;
actionLabel: string;
busy: boolean;
onConfirm: (secretKeyHex: string) => void;
onCancel: () => void;
}) {
const [secret, setSecret] = useState('');
const signable = canSign();
// Derived locally, purely to tell the operator they have the wrong key before
// they hit the button. The server would refuse either way.
const derived = secret.trim() ? uidFromSecretKey(secret) : null;
const mismatch = Boolean(secret.trim() && derived && derived !== label);
return (
<form
onSubmit={(e) => {
e.preventDefault();
onConfirm(secret.trim());
}}
>
<p style={{ color: '#fecaca', margin: '0 0 0.5rem' }}>
Paste this account's secret key to prove it is yours. It is used to sign a one-time
challenge <em>in this browser</em> and is never sent to the server.
</p>
{!signable && <p style={errorTextStyle}>{INSECURE_CONTEXT_MESSAGE}</p>}
<input
style={inputStyle}
type="password"
autoComplete="off"
placeholder="64 hex characters"
value={secret}
onChange={(e) => setSecret(e.target.value)}
disabled={busy || !signable}
autoFocus
/>
{mismatch && (
<p style={{ ...errorTextStyle, marginTop: 0 }}>
That key belongs to a different account ({derived}).
</p>
)}
<button
type="submit"
style={dangerButtonStyle}
disabled={busy || !signable || !secret.trim() || mismatch}
>
{busy ? 'Verifying…' : actionLabel}
</button>
<button type="button" style={buttonStyle} onClick={onCancel} disabled={busy}>
Cancel
</button>
</form>
);
}
// The one place a private key is ever displayed. Rendered exclusively (the
// account list is hidden while it is open) so the "save it now" contract of a
// freshly generated key is not competing with anything, and so the hardcoded
// element id below stays unique.
function SecretKeyPanel({ data, onDismiss }: { data: SecretKeyView; onDismiss: () => void }) {
const [copied, setCopied] = useState(false);
const copy = async () => {
await copyToClipboard(data.privateKeyHex, 'mm-privkey-box');
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}; };
return ( return (
<div style={{ ...cardStyle, border: '2px solid #dc2626' }}> <div style={{ ...cardStyle, border: '2px solid #dc2626' }}>
<div <div
@@ -109,9 +317,19 @@ function GeneratedKeyPanel({ data, onDismiss }: { data: GeneratedKey; onDismiss:
marginBottom: '1rem', marginBottom: '1rem',
}} }}
> >
<strong>This private key is shown ONCE.</strong> Save it now in a password manager. {data.kind === 'generated' ? (
It will never be displayed again — there is no recovery path. Losing it means losing access <>
to every record signed under this identity. <strong>This private key is shown ONCE.</strong> Save it now in a password manager.
You can ask for it again later with <em>Reveal private key</em>, but only for as long
as this account exists on this node — delete it and the key is gone for good.
</>
) : (
<>
<strong>This is the live private key for an existing account.</strong> Anyone holding
it can sign as this uid forever, on any machine. There is no revocation and no
rotation. Do not paste it into chat, tickets or screenshots.
</>
)}
</div> </div>
<p style={{ color: '#9ca3af', margin: '0 0 0.5rem' }}>uid (public):</p> <p style={{ color: '#9ca3af', margin: '0 0 0.5rem' }}>uid (public):</p>
<div style={monoStyle}>{data.uid}</div> <div style={monoStyle}>{data.uid}</div>
@@ -124,36 +342,40 @@ function GeneratedKeyPanel({ data, onDismiss }: { data: GeneratedKey; onDismiss:
{copied ? 'Copied' : 'Copy private key'} {copied ? 'Copied' : 'Copy private key'}
</button> </button>
<button style={dangerButtonStyle} onClick={onDismiss}> <button style={dangerButtonStyle} onClick={onDismiss}>
I have saved it {data.kind === 'generated' ? 'I have saved it' : 'Done — hide it'}
</button> </button>
</div> </div>
</div> </div>
); );
} }
function NoIdentityPanel({ // Create / import. Always available, not just on an empty keystore: this is an
// admin console, so adding an account to a populated node has to be possible.
function NewAccountPanel({
onGenerated, onGenerated,
onImported, onImported,
}: { }: {
onGenerated: (g: GeneratedKey) => void; onGenerated: (s: SecretKeyView) => void;
onImported: () => void; onImported: () => void;
}) { }) {
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [importing, setImporting] = useState(false); const [importing, setImporting] = useState(false);
const [importHex, setImportHex] = useState(''); const [importHex, setImportHex] = useState('');
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const generate = async () => { const generate = async () => {
setBusy(true); setBusy(true);
setError(null); setError(null);
setNotice(null);
try { try {
const res = await fetch('/api/identity/generate', { method: 'POST' }); const res = await fetch('/api/identity/generate', { method: 'POST' });
if (!res.ok) { if (!res.ok) {
setError(await errorMessage(res, 'Generate failed')); setError(await errorMessage(res, 'Generate failed'));
return; return;
} }
const data = (await res.json()) as GeneratedKey; const data = (await res.json()) as { uid: string; privateKeyHex: string };
onGenerated(data); onGenerated({ uid: data.uid, privateKeyHex: data.privateKeyHex, kind: 'generated' });
} catch (e) { } catch (e) {
setError(`Generate failed: ${e instanceof Error ? e.message : String(e)}`); setError(`Generate failed: ${e instanceof Error ? e.message : String(e)}`);
} finally { } finally {
@@ -165,6 +387,7 @@ function NoIdentityPanel({
e.preventDefault(); e.preventDefault();
setBusy(true); setBusy(true);
setError(null); setError(null);
setNotice(null);
try { try {
const res = await fetch('/api/identity/import', { const res = await fetch('/api/identity/import', {
method: 'POST', method: 'POST',
@@ -175,6 +398,15 @@ function NoIdentityPanel({
setError(await errorMessage(res, 'Import failed')); setError(await errorMessage(res, 'Import failed'));
return; return;
} }
// Import is idempotent — presenting the key IS the proof of ownership, so
// a key this node already holds is a success, not a conflict. `created`
// only decides the wording.
const data = (await res.json()) as { created?: boolean };
setNotice(
data.created
? 'Imported — a new account was added to this keystore.'
: 'That key was already in this keystore; nothing changed.'
);
setImportHex(''); setImportHex('');
setImporting(false); setImporting(false);
onImported(); onImported();
@@ -187,15 +419,16 @@ function NoIdentityPanel({
return ( return (
<div style={cardStyle}> <div style={cardStyle}>
<h3 style={{ marginTop: 0 }}>No identity yet</h3> <h3 style={{ marginTop: 0 }}>Add an account</h3>
<p style={{ color: '#9ca3af' }}> <p style={{ color: '#9ca3af', marginTop: 0 }}>
The User Data Layer needs a signing key for your ratings, watch progress, and "liked" flags Generating mints a fresh secp256k1 keypair here. Importing adds a key created elsewhere —
to be attributed to you across devices. Nothing happens automatically — generate one below, it is also how an existing account is moved onto this node, so importing a key the
or import a key you already have. keystore already holds succeeds and changes nothing.
</p> </p>
{error && ( {error && <p style={errorTextStyle}>{error}</p>}
<p style={{ color: '#f87171', background: '#1f0a0a', padding: '0.5rem 0.75rem', borderRadius: '4px' }}> {notice && (
{error} <p style={{ color: '#9ca3af', background: '#0a0a18', padding: '0.5rem 0.75rem', borderRadius: '4px' }}>
{notice}
</p> </p>
)} )}
<div> <div>
@@ -235,73 +468,156 @@ function NoIdentityPanel({
); );
} }
function HasIdentityPanel({ status, onReplace }: { status: IdentityStatus; onReplace: () => void }) { function AccountCard({
const [confirming, setConfirming] = useState(false); account,
const [busy, setBusy] = useState(false); name,
const [error, setError] = useState<string | null>(null); stats,
busy,
disabled,
pending,
error,
onAsk,
onCancel,
onReveal,
onRemove,
}: {
account: Account;
name?: string;
// undefined means "counted, and it holds nothing"; null means "could not be
// counted". The two must not read the same on a confirmation screen.
stats?: Stats | null;
busy: boolean;
disabled: boolean;
pending: Pending | null;
error: string | null;
onAsk: (p: Pending) => void;
onCancel: () => void;
onReveal: (uid: string, secretKeyHex: string) => void;
onRemove: (uid: string, secretKeyHex: string) => void;
}) {
const [copied, setCopied] = useState(false);
const uidBoxId = `mm-uid-${account.uid}`;
const label = name ?? account.uid;
const counted = stats !== null;
const records = stats?.records ?? 0;
const titles = stats?.cids ?? 0;
const remove = async () => { const copyUid = async () => {
setBusy(true); await copyToClipboard(account.uid, uidBoxId);
setError(null); setCopied(true);
try { setTimeout(() => setCopied(false), 2000);
const res = await fetch('/api/identity?confirm=true', { method: 'DELETE' });
if (!res.ok && res.status !== 204) {
setError(await errorMessage(res, 'Delete failed'));
return;
}
onReplace();
} catch (e) {
setError(`Delete failed: ${e instanceof Error ? e.message : String(e)}`);
} finally {
setBusy(false);
setConfirming(false);
}
}; };
return ( return (
<div style={cardStyle}> <div style={cardStyle}>
<h3 style={{ marginTop: 0 }}>Active identity</h3> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '1rem' }}>
<p style={{ color: '#9ca3af', margin: '0 0 0.5rem' }}>uid:</p> <div style={{ minWidth: 0, flex: 1 }}>
<div style={monoStyle}>{status.uid}</div> <h3 style={{ margin: '0 0 0.5rem', color: name ? '#fff' : '#888' }}>
<p style={{ color: '#888', marginTop: '1rem' }}> {name ?? 'Unnamed account'}
Curve: {status.curve} · Created: {formatCreatedAt(status.createdAt)} </h3>
</p> <div id={uidBoxId} style={monoStyle}>
<p style={{ color: '#9ca3af', fontSize: '0.85rem' }}> {account.uid}
The private key lives at <code>/meta-core/identity/identity.json</code> on this volume and </div>
is never returned through the API after generation. Other services on this stack (e.g. <p style={{ color: '#888', margin: '0.75rem 0 0', fontSize: '0.85rem' }}>
meta-watch) sign records by calling <code>POST /api/identity/sign</code>. Curve: {account.curve} · Created: {formatCreatedAt(account.createdAt)}
</p>
{error && (
<p style={{ color: '#f87171', background: '#1f0a0a', padding: '0.5rem 0.75rem', borderRadius: '4px' }}>
{error}
</p>
)}
{!confirming ? (
<button style={dangerButtonStyle} onClick={() => setConfirming(true)} disabled={busy}>
Replace identity
</button>
) : (
<div
style={{
background: '#1f0a0a',
border: '1px solid #7f1d1d',
padding: '0.75rem 1rem',
borderRadius: '4px',
marginTop: '0.5rem',
}}
>
<p style={{ color: '#fecaca', marginTop: 0 }}>
Replacing the identity deletes the current keypair from disk. Records signed by the
current uid will keep verifying for anyone who has them cached, but this node will no
longer be able to write under that uid. <strong>Make sure you have backed up the key
first.</strong>
</p> </p>
<button style={dangerButtonStyle} onClick={remove} disabled={busy}> <p style={{ color: '#888', margin: '0.25rem 0 0', fontSize: '0.85rem' }}>
{busy ? 'Deleting…' : 'Yes, delete identity'} {/* An account that has never been used legitimately holds nothing, so
zero is a real answer — but only once the count actually ran. */}
{!counted
? 'Stored data: could not be counted (storage unavailable)'
: records === 0
? 'No stored data'
: `${records.toLocaleString()} stored ${records === 1 ? 'record' : 'records'}` +
` across ${titles.toLocaleString()} ${titles === 1 ? 'title' : 'titles'}`}
</p>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', flexShrink: 0 }}>
<button style={{ ...buttonStyle, marginRight: 0 }} onClick={copyUid} disabled={disabled}>
{copied ? 'Copied' : 'Copy uid'}
</button> </button>
<button style={buttonStyle} onClick={() => setConfirming(false)} disabled={busy}> <button
Cancel style={{ ...dangerButtonStyle, marginRight: 0 }}
onClick={() => onAsk({ uid: account.uid, kind: 'reveal' })}
disabled={disabled}
>
Reveal private key
</button> </button>
<button
style={{ ...dangerButtonStyle, marginRight: 0 }}
onClick={() => onAsk({ uid: account.uid, kind: 'remove' })}
disabled={disabled}
>
Remove
</button>
</div>
</div>
{error && <p style={{ ...errorTextStyle, marginBottom: 0 }}>{error}</p>}
{pending?.uid === account.uid && pending.kind === 'reveal' && (
<div style={dangerPanelStyle}>
<p style={{ color: '#fecaca', marginTop: 0 }}>
Show the private key for <strong>{label}</strong>?
</p>
<p style={{ color: '#fecaca', marginTop: 0 }}>
The key <em>is</em> the account. Anyone who copies it can sign records as this uid on
any machine, forever — there is no way to revoke or rotate it afterwards. Because
proving ownership already requires the key, this only confirms that the copy this node
holds matches yours.
</p>
<SecretKeyConfirm
label={account.uid}
actionLabel="Show the private key"
busy={busy}
onConfirm={(secret) => onReveal(account.uid, secret)}
onCancel={onCancel}
/>
</div>
)}
{pending?.uid === account.uid && pending.kind === 'remove' && (
<div style={dangerPanelStyle}>
<p style={{ color: '#fecaca', marginTop: 0 }}>
Remove <strong>{label}</strong> from this node?
</p>
<ul style={{ color: '#fecaca', paddingLeft: '1.2rem', margin: '0 0 0.75rem' }}>
<li>
<strong>The key is gone for good.</strong> The uid <em>is</em> the public key, and
the private half exists only in this node's keystore. If nobody saved it, nothing —
not you, not meta-core — can ever sign as this uid again. Reveal and copy it first
if there is any doubt.
</li>
<li>
<strong>Nothing is retracted.</strong> Records already signed under this uid keep
verifying on every peer that replicated them. Deleting a key is not a recall.
</li>
<li>
<strong>Its data is destroyed, not orphaned.</strong>{' '}
{!counted
? 'This account\u2019s records could not be counted because storage is unavailable — ' +
'so meta-core will refuse this delete rather than remove the key and strand them.'
: records === 0
? 'This account holds no User Data Layer records, so there is nothing to purge.'
: `All ${records.toLocaleString()} User Data Layer ${
records === 1 ? 'record' : 'records'
} — likes, ratings, watch progress across ${titles.toLocaleString()} ${
titles === 1 ? 'title' : 'titles'
} — are permanently deleted from Redis, along with every index that names this uid.`}{' '}
This cannot be undone and there is no backup.
</li>
<li>
<strong>This signs nobody out elsewhere.</strong> Any device that holds this key
still holds it, and can import it back here at any time.
</li>
</ul>
<SecretKeyConfirm
label={account.uid}
actionLabel="Remove this account and all its data"
busy={busy}
onConfirm={(secret) => onRemove(account.uid, secret)}
onCancel={onCancel}
/>
</div> </div>
)} )}
</div> </div>
@@ -309,67 +625,235 @@ function HasIdentityPanel({ status, onReplace }: { status: IdentityStatus; onRep
} }
export default function Identity() { export default function Identity() {
const [status, setStatus] = useState<IdentityStatus | null>(null); const [accounts, setAccounts] = useState<Account[] | null>(null);
const [generated, setGenerated] = useState<GeneratedKey | null>(null); const [names, setNames] = useState<Record<string, string>>({});
const [stats, setStats] = useState<Record<string, Stats> | null>({});
const [notice, setNotice] = useState<string | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [fetchError, setFetchError] = useState<string | null>(null); const [fetchError, setFetchError] = useState<string | null>(null);
const [secret, setSecret] = useState<SecretKeyView | null>(null);
const [pending, setPending] = useState<Pending | null>(null);
const [busyUid, setBusyUid] = useState<string | null>(null);
const [rowError, setRowError] = useState<{ uid: string; message: string } | null>(null);
// Bumped per refresh so a slow response from a superseded load cannot
// overwrite a newer one. The page does not poll (unlike the rest of the
// dashboard) — a key list has no business changing under the cursor.
const reqGen = useRef(0);
const refresh = async () => { const refresh = async () => {
const gen = ++reqGen.current;
setLoading(true); setLoading(true);
setFetchError(null); setFetchError(null);
let list: Account[];
try { try {
const res = await fetch('/api/identity'); list = await fetchAccounts();
if (!res.ok) {
setFetchError(await errorMessage(res, 'Failed to load identity status'));
return;
}
setStatus((await res.json()) as IdentityStatus);
} catch (e) { } catch (e) {
setFetchError(`Failed to load identity status: ${e instanceof Error ? e.message : String(e)}`); if (gen !== reqGen.current) return;
} finally { setFetchError(e instanceof Error ? e.message : String(e));
setLoading(false); setLoading(false);
return;
} }
if (gen !== reqGen.current) return;
// Render the list before resolving names, so a down Redis delays nothing.
setAccounts(list);
setLoading(false);
// Names and counts both come after the list is on screen, and neither can
// fail the page: a down Redis costs decoration, not the account list.
const [settled, counts] = await Promise.all([
Promise.allSettled(list.map((a) => fetchDisplayName(a.uid))),
fetchStats(),
]);
if (gen !== reqGen.current) return;
const next: Record<string, string> = {};
settled.forEach((r, i) => {
if (r.status === 'fulfilled' && r.value) next[list[i].uid] = r.value;
});
// Replace rather than merge, so a removed account's name and counts go with
// it instead of lingering against a uid that no longer exists.
setNames(next);
setStats(counts);
}; };
useEffect(() => { useEffect(() => {
refresh(); refresh();
}, []); }, []);
const reveal = async (uid: string, secretKeyHex: string) => {
setBusyUid(uid);
setRowError(null);
try {
const proof = await proveOwnership(uid, ACTION_FOR.reveal, secretKeyHex);
// POST with a body rather than ?uid=, so neither the target nor the
// signature reaches the proxy's access log. The handler accepts the uid
// either way; the proof must be in the body.
const res = await fetch('/api/identity/reveal', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ confirm: true, uid, ...proof }),
});
if (!res.ok) {
setRowError({ uid, message: await errorMessage(res, 'Reveal failed') });
return;
}
const data = (await res.json()) as { uid: string; privateKeyHex: string };
setPending(null);
setSecret({ uid: data.uid, privateKeyHex: data.privateKeyHex, kind: 'revealed' });
} catch (e) {
setRowError({ uid, message: `Reveal failed: ${e instanceof Error ? e.message : String(e)}` });
} finally {
setBusyUid(null);
}
};
const remove = async (uid: string, secretKeyHex: string) => {
setBusyUid(uid);
setRowError(null);
setNotice(null);
try {
const proof = await proveOwnership(uid, ACTION_FOR.remove, secretKeyHex);
// The uid is mandatory: the signature authorises one specific account, so
// the backend refuses an unqualified delete rather than guess a target.
const res = await fetch('/api/identity', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ confirm: true, uid, ...proof }),
});
if (!res.ok) {
setRowError({ uid, message: await errorMessage(res, 'Remove failed') });
return;
}
// Report what the purge actually removed. "Account removed" on its own
// would not distinguish a clean delete from one that stranded the data.
const data = (await res.json().catch(() => ({}))) as { purged?: Stats };
const purged = data.purged;
setNotice(
purged && purged.records > 0
? `Account removed. Purged ${purged.records.toLocaleString()} User Data Layer ` +
`${purged.records === 1 ? 'record' : 'records'} across ` +
`${purged.cids.toLocaleString()} ${purged.cids === 1 ? 'title' : 'titles'}.`
: 'Account removed. It held no User Data Layer records.'
);
setPending(null);
await refresh();
} catch (e) {
setRowError({ uid, message: `Remove failed: ${e instanceof Error ? e.message : String(e)}` });
} finally {
setBusyUid(null);
}
};
const dismissSecret = () => {
setSecret(null);
refresh();
};
const ask = (p: Pending) => {
setRowError(null);
setNotice(null);
setPending(p);
};
const showList = !loading && !fetchError && !secret;
return ( return (
// App.tsx's content area is `overflow: hidden`, so every page owns its own
// scrolling (Files.tsx does the same). Without this the account list is
// simply clipped at the viewport with no scrollbar — invisible on a node
// with two accounts, and a dead end on one with fifteen.
//
// The scroller is the outer element and the padding lives inside it, so the
// scrollbar sits at the edge of the content area rather than floating in
// 2rem of whitespace, and `margin: 0 auto` still centres the column.
<div style={{ height: '100%', overflowY: 'auto', minHeight: 0 }}>
<div style={{ padding: '2rem', maxWidth: '900px', margin: '0 auto' }}> <div style={{ padding: '2rem', maxWidth: '900px', margin: '0 auto' }}>
<h1>Identity</h1> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h1 style={{ marginBottom: 0 }}>Identity</h1>
<button style={{ ...buttonStyle, marginRight: 0 }} onClick={refresh} disabled={loading}>
{loading ? 'Loading…' : 'Refresh'}
</button>
</div>
<p style={{ color: '#9ca3af' }}> <p style={{ color: '#9ca3af' }}>
Signing key for the User Data Layer (per-user ratings, progress, "liked" flags). One Every signing key in this node's keystore. meta-core has no "current user" — it stores
identity per node. The same key on another device makes that device count as "you". accounts, and which one is <em>you</em> is decided by whichever client signs in (e.g.
meta-watch). Keys live one JSON file per account under{' '}
<code>{'{META_CORE_PATH}'}/identity/accounts/&lt;uid&gt;.json</code>, and are used to sign
User Data Layer records (ratings, watch progress, "liked" flags).
</p> </p>
<div style={{ ...dangerPanelStyle, marginTop: 0, marginBottom: '1rem' }}>
<p style={{ color: '#fecaca', margin: 0 }}>
<strong>The key is the only authority.</strong> Revealing or removing an account requires
that account's secret key, entered here and used to sign a one-time challenge in your
browser — there is no admin override, and holding this page is not one. Listing and
creating accounts are still open to anything that can reach this API, so the perimeter in
front of this port is what keeps the roster private.
</p>
<p style={{ color: '#fecaca', margin: '0.5rem 0 0' }}>
What this does <em>not</em> defend against: anyone with filesystem access to{' '}
<code>{'{META_CORE_PATH}'}/identity/accounts/</code> reads every private key directly.
The keys are stored in plaintext, matching the trust model of the rest of{' '}
<code>/meta-core</code>.
</p>
</div>
{notice && (
<div style={{ ...cardStyle, border: '1px solid #0f3460' }}>
<p style={{ color: '#9ca3af', margin: 0 }}>{notice}</p>
</div>
)}
{loading && <div style={cardStyle}>Loading…</div>} {loading && <div style={cardStyle}>Loading…</div>}
{fetchError && ( {fetchError && (
<div style={{ ...cardStyle, border: '1px solid #7f1d1d' }}> <div style={{ ...cardStyle, border: '1px solid #7f1d1d' }}>
<p style={{ color: '#f87171', margin: 0 }}>{fetchError}</p> <p style={{ color: '#f87171', margin: '0 0 1rem' }}>{fetchError}</p>
<button style={buttonStyle} onClick={refresh}> <button style={buttonStyle} onClick={refresh}>
Retry Retry
</button> </button>
</div> </div>
)} )}
{!loading && !fetchError && generated && ( {!loading && !fetchError && secret && <SecretKeyPanel data={secret} onDismiss={dismissSecret} />}
<GeneratedKeyPanel
data={generated}
onDismiss={() => {
setGenerated(null);
refresh();
}}
/>
)}
{!loading && !fetchError && !generated && status && !status.hasIdentity && ( {showList && (
<NoIdentityPanel onGenerated={(g) => setGenerated(g)} onImported={refresh} /> <>
)} <NewAccountPanel onGenerated={(s) => setSecret(s)} onImported={refresh} />
{!loading && !fetchError && !generated && status && status.hasIdentity && ( <h2 style={{ fontSize: '1.1rem', color: '#9ca3af', margin: '1.5rem 0 1rem' }}>
<HasIdentityPanel status={status} onReplace={refresh} /> Accounts ({accounts?.length ?? 0})
</h2>
{accounts && accounts.length === 0 && (
<div style={cardStyle}>
<p style={{ color: '#9ca3af', margin: 0 }}>
No accounts in this keystore. Accounts are normally created by a client when
someone signs up; use <em>Add an account</em> above if you are bootstrapping this
node.
</p>
</div>
)}
{accounts?.map((a) => (
<AccountCard
key={a.uid}
account={a}
name={names[a.uid]}
stats={stats === null ? null : stats[a.uid]}
busy={busyUid === a.uid}
disabled={busyUid !== null}
pending={pending}
error={rowError?.uid === a.uid ? rowError.message : null}
onAsk={ask}
onCancel={() => setPending(null)}
onReveal={reveal}
onRemove={remove}
/>
))}
</>
)} )}
</div> </div>
</div>
); );
} }
+4
View File
@@ -301,6 +301,7 @@ CID-addressed (public, auth-bypassed at the perimeter)
Editor / KV / schema / snapshot Editor / KV / schema / snapshot
GET /api/metadata/hash-ids GET /api/metadata/hash-ids
GET /api/files/tuples
GET /api/metadata/list GET /api/metadata/list
POST /api/metadata/search POST /api/metadata/search
POST /api/metadata/batch POST /api/metadata/batch
@@ -339,6 +340,9 @@ Watchers
Admin Admin
POST /api/admin/migrate-dual-roots POST /api/admin/migrate-dual-roots
POST /api/admin/sweep-literature-echoes (dry-run unless ?apply=true;
strips query echoes off domain=literature fileType=card records —
see storage/literature_echo_sweep.go)
WebDAV WebDAV
/webdav/... (GET, PUT, DELETE, MKCOL, COPY, MOVE, PROPFIND) /webdav/... (GET, PUT, DELETE, MKCOL, COPY, MOVE, PROPFIND)
+71
View File
@@ -2,6 +2,7 @@ package api
import ( import (
"net/http" "net/http"
"strconv"
) )
// handleMigrateDualRoots triggers the one-shot sweep that re-unifies // handleMigrateDualRoots triggers the one-shot sweep that re-unifies
@@ -28,3 +29,73 @@ func (s *Server) handleMigrateDualRoots(w http.ResponseWriter, r *http.Request)
"fixed": fixed, "fixed": fixed,
}) })
} }
// handleMigrateDomainScreen triggers the one-shot sweep that rewrites the
// retired `domain=film|tv` values to `screen` and backfills `workForm` from
// `contentKind` (METADATA_KEYS.md §14.17). See
// storage.MigrateDomainScreen for why the vocabulary change is paid in the
// data rather than with a read-side alias.
//
// Returns {fixed: N} — records changed, not records examined. Safe to call
// repeatedly: a record already on the current vocabulary is skipped.
func (s *Server) handleMigrateDomainScreen(w http.ResponseWriter, r *http.Request) {
if !s.storage.IsConnected() {
writeError(w, http.StatusServiceUnavailable, "storage not connected")
return
}
fixed, err := s.storage.MigrateDomainScreenWithTimeout()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"success": true,
"fixed": fixed,
})
}
// handleSweepLiteratureEchoes removes query echoes stored on literature cards:
// `anilistid` on a site card, and `workcid` / position keys on any card
// (meta-read docs/reading-model.md §7). See storage.SweepLiteratureEchoes for
// why chapters and anchor cards' ids are deliberately left alone.
//
// Dry-run unless `?apply=true`: unlike the migrations above this one deletes
// keys, so a bare call only reports what it would delete. An unparseable
// `apply` is a 400 rather than a silent dry run, so a typo cannot pass for a
// sweep that ran.
//
// Returns {success, dryRun, planned, fixed, byKey} — planned/fixed count
// records, byKey counts fields per key. Safe to call repeatedly: a swept
// record plans nothing on the next pass.
func (s *Server) handleSweepLiteratureEchoes(w http.ResponseWriter, r *http.Request) {
if !s.storage.IsConnected() {
writeError(w, http.StatusServiceUnavailable, "storage not connected")
return
}
apply := false
if v := r.URL.Query().Get("apply"); v != "" {
parsed, err := strconv.ParseBool(v)
if err != nil {
writeError(w, http.StatusBadRequest, "apply must be true or false")
return
}
apply = parsed
}
res, err := s.storage.SweepLiteratureEchoesWithTimeout(apply)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"success": true,
"dryRun": !apply,
"planned": res.Planned,
"fixed": res.Fixed,
"byKey": res.ByKey,
})
}
+93
View File
@@ -0,0 +1,93 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/alicebob/miniredis/v2"
"github.com/metazla/meta-core/internal/storage"
)
// newSweepTestServer is a Server over miniredis with one literature card
// carrying a copied `workcid`.
func newSweepTestServer(t *testing.T) *Server {
t.Helper()
mr := miniredis.RunT(t)
c := storage.NewClient("")
if err := c.Connect("redis://" + mr.Addr()); err != nil {
t.Fatalf("connect: %v", err)
}
t.Cleanup(func() { _ = c.Close() })
for k, v := range map[string]string{
"domain": "literature",
"fileType": "card",
"workcid": "bagdsaaaua5qw42lmnfzxi3lbnztwcorrga2tonzy",
} {
if err := c.SetProperty("card-1", k, v); err != nil {
t.Fatalf("seed %s: %v", k, err)
}
}
return &Server{storage: c}
}
type sweepResponse struct {
Success bool `json:"success"`
DryRun bool `json:"dryRun"`
Planned int `json:"planned"`
Fixed int `json:"fixed"`
ByKey map[string]int `json:"byKey"`
}
func postSweep(t *testing.T, s *Server, query string) (*httptest.ResponseRecorder, sweepResponse) {
t.Helper()
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/admin/sweep-literature-echoes"+query, nil)
s.handleSweepLiteratureEchoes(rr, req)
var body sweepResponse
if rr.Code == http.StatusOK {
if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil {
t.Fatalf("decode %s: %v", rr.Body.String(), err)
}
}
return rr, body
}
// A bare call must never delete: the default is the dry run.
func TestSweepLiteratureEchoes_DefaultsToDryRun(t *testing.T) {
s := newSweepTestServer(t)
rr, body := postSweep(t, s, "")
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, body %s", rr.Code, rr.Body.String())
}
if !body.Success || !body.DryRun || body.Planned != 1 || body.Fixed != 0 || body.ByKey["workcid"] != 1 {
t.Errorf("dry run = %+v", body)
}
if v, _ := s.storage.GetProperty("card-1", "workcid"); v == "" {
t.Errorf("dry run deleted workcid")
}
rr, body = postSweep(t, s, "?apply=true")
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, body %s", rr.Code, rr.Body.String())
}
if body.DryRun || body.Planned != 1 || body.Fixed != 1 || body.ByKey["workcid"] != 1 {
t.Errorf("apply = %+v", body)
}
if v, _ := s.storage.GetProperty("card-1", "workcid"); v != "" {
t.Errorf("apply left workcid = %q", v)
}
}
// A typo must not pass for a sweep that ran.
func TestSweepLiteratureEchoes_RejectsUnparseableApply(t *testing.T) {
s := newSweepTestServer(t)
rr, _ := postSweep(t, s, "?apply=yes-please")
if rr.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", rr.Code)
}
}
+132
View File
@@ -0,0 +1,132 @@
package api
import (
"encoding/json"
"net/http"
"os"
"path"
"path/filepath"
"strings"
"github.com/metazla/meta-core/internal/watcher"
)
// registerFileRequest is the body of POST /api/files/register.
type registerFileRequest struct {
// Path relative to FILES_PATH, e.g. "plugin/subtitles/ab12….eng.srt".
Path string `json:"path"`
}
type registerFileResponse struct {
Root string `json:"root"`
CID string `json:"cid"`
Created bool `json:"created"`
Path string `json:"path"`
Size int64 `json:"size"`
}
// pluginRelPath normalises a FILES_PATH-relative path and refuses anything
// outside plugin/. Plugin output is the only tree a service may register this
// way: /files/watch is the user's library and belongs to the watcher.
func pluginRelPath(raw string) (string, bool) {
p := strings.TrimPrefix(filepath.ToSlash(strings.TrimSpace(raw)), "/")
if p == "" {
return "", false
}
for _, seg := range strings.Split(p, "/") {
if seg == ".." {
return "", false
}
}
clean := path.Clean(p)
if !strings.HasPrefix(clean, "plugin/") || clean == "plugin/" {
return "", false
}
return clean, true
}
// handleRegisterFile handles POST /api/files/register.
//
// Does for ONE file what the watcher does for every file it scans: midhash256
// it, mint a root carrying filePath/sizeByte/mtimeNano, register the
// cid:<midhash> alias, and append a file:events entry so meta-share's
// ipfs_seed chunks, seeds and provides it.
//
// It exists for cores that run with ENABLE_FILE_WATCHER=false (a meta-watch
// client box). Without it, bytes written under /files/plugin over WebDAV are
// never resolvable by CID and never reach the swarm. Idempotent: the same
// content at the same path returns the existing root with created=false.
func (s *Server) handleRegisterFile(w http.ResponseWriter, r *http.Request) {
if s.storage == nil || !s.storage.IsConnected() {
writeError(w, http.StatusServiceUnavailable, "storage not connected")
return
}
var req registerFileRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
rel, ok := pluginRelPath(req.Path)
if !ok {
writeError(w, http.StatusBadRequest, "path must be a file under plugin/")
return
}
fullPath := filepath.Join(s.config.FilesPath, filepath.FromSlash(rel))
absFiles, _ := filepath.Abs(filepath.Join(s.config.FilesPath, "plugin"))
absFull, _ := filepath.Abs(fullPath)
if !strings.HasPrefix(absFull, absFiles+string(filepath.Separator)) {
writeError(w, http.StatusBadRequest, "path must be a file under plugin/")
return
}
info, err := os.Stat(fullPath)
if err != nil {
writeError(w, http.StatusNotFound, "file not found")
return
}
if info.IsDir() {
writeError(w, http.StatusBadRequest, "path is a directory, not a file")
return
}
midhash, err := watcher.ComputeMidHash256(fullPath, info.Size())
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to hash file")
return
}
root, created, err := s.storage.RegisterFileTuple(fullPath, info.Size(), info.ModTime().UnixNano(), midhash)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if created {
ev := watcher.FileEvent{
Type: watcher.EventTypeAdd,
Path: rel,
Size: info.Size(),
MidHash256: midhash,
Timestamp: watcher.NowMS(),
}
if err := watcher.PublishEvent(s.storage, ev); err != nil {
// The record is written and resolvable; only seeding is late.
writeError(w, http.StatusInternalServerError, "registered, but file event not published: "+err.Error())
return
}
}
status := http.StatusOK
if created {
status = http.StatusCreated
}
writeJSON(w, status, registerFileResponse{
Root: root,
CID: midhash,
Created: created,
Path: rel,
Size: info.Size(),
})
}
+144
View File
@@ -0,0 +1,144 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/alicebob/miniredis/v2"
"github.com/metazla/meta-core/internal/config"
"github.com/metazla/meta-core/internal/storage"
)
// newRegisterTestServer wires handleRegisterFile to an in-memory Redis and a
// temp FILES_PATH. Built directly, not through NewServer, so no watcher runs —
// which is exactly the configuration this route exists for.
func newRegisterTestServer(t *testing.T) (*Server, string, *miniredis.Miniredis) {
t.Helper()
mr := miniredis.RunT(t)
c := storage.NewClient("")
if err := c.Connect("redis://" + mr.Addr()); err != nil {
t.Fatalf("connect to miniredis: %v", err)
}
t.Cleanup(func() { _ = c.Close() })
files := t.TempDir()
return &Server{storage: c, config: &config.Config{FilesPath: files}}, files, mr
}
func writeFixture(t *testing.T, files, rel, body string) string {
t.Helper()
full := filepath.Join(files, filepath.FromSlash(rel))
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(full, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
return full
}
func postRegister(t *testing.T, s *Server, path string) (int, registerFileResponse) {
t.Helper()
body, _ := json.Marshal(registerFileRequest{Path: path})
req := httptest.NewRequest(http.MethodPost, "/api/files/register", strings.NewReader(string(body)))
rr := httptest.NewRecorder()
s.handleRegisterFile(rr, req)
var resp registerFileResponse
_ = json.Unmarshal(rr.Body.Bytes(), &resp)
return rr.Code, resp
}
func streamLen(t *testing.T, mr *miniredis.Miniredis) int {
t.Helper()
entries, err := mr.Stream("file:events")
if err != nil {
return 0
}
return len(entries)
}
// The whole point of the route: with the watcher off, a file written under
// plugin/ becomes a record resolvable by its midhash, and meta-share hears
// about it on file:events.
func TestRegisterFile_MintsAliasAndPublishesEvent(t *testing.T) {
s, files, mr := newRegisterTestServer(t)
rel := "plugin/subtitles/ab12cd34.eng.srt"
full := writeFixture(t, files, rel, "1\n00:00:01,000 --> 00:00:02,000\nHello\n")
code, resp := postRegister(t, s, rel)
if code != http.StatusCreated || !resp.Created {
t.Fatalf("want 201 created, got %d %+v", code, resp)
}
if !strings.HasPrefix(resp.CID, "b") || resp.Root == "" {
t.Fatalf("want a multibase cid and a root, got %+v", resp)
}
root, err := s.storage.GetByCID(resp.CID)
if err != nil || root != resp.Root {
t.Fatalf("cid alias must resolve to the root: got %q err=%v, want %q", root, err, resp.Root)
}
if fp, _ := s.storage.GetProperty(resp.Root, "filePath"); fp != full {
t.Fatalf("filePath = %q, want %q", fp, full)
}
entries, err := mr.Stream("file:events")
if err != nil || len(entries) != 1 {
t.Fatalf("want exactly one file:events entry, got %d err=%v", len(entries), err)
}
vals := strings.Join(entries[0].Values, "|")
if !strings.Contains(vals, "midhash256|"+resp.CID) || !strings.Contains(vals, "path|"+rel) {
t.Fatalf("event must carry the midhash and the relative path, got %q", vals)
}
}
// Re-uploading the same subtitle is a no-op: same root, no second event (which
// would make meta-share re-chunk and re-provide the file).
func TestRegisterFile_IsIdempotent(t *testing.T) {
s, files, mr := newRegisterTestServer(t)
rel := "plugin/subtitles/same.eng.srt"
writeFixture(t, files, rel, "same bytes")
_, first := postRegister(t, s, rel)
code, second := postRegister(t, s, rel)
if code != http.StatusOK || second.Created {
t.Fatalf("second register must be 200 created=false, got %d %+v", code, second)
}
if second.Root != first.Root || second.CID != first.CID {
t.Fatalf("second register must return the same record: %+v vs %+v", second, first)
}
if n := streamLen(t, mr); n != 1 {
t.Fatalf("an idempotent re-register must not publish again, stream has %d", n)
}
}
// Only plugin output may be registered here. /files/watch is the user's
// library and belongs to the watcher; traversal must not escape either.
func TestRegisterFile_RejectsPathsOutsidePlugin(t *testing.T) {
s, files, _ := newRegisterTestServer(t)
writeFixture(t, files, "watch/movie.mkv", "x")
for _, p := range []string{
"", "plugin", "plugin/", "watch/movie.mkv", "/watch/movie.mkv",
"plugin/../watch/movie.mkv", "../etc/passwd", "plugin/../../etc/passwd",
} {
if code, _ := postRegister(t, s, p); code != http.StatusBadRequest {
t.Errorf("path %q: want 400, got %d", p, code)
}
}
}
func TestRegisterFile_MissingFileAndDirectory(t *testing.T) {
s, files, _ := newRegisterTestServer(t)
if code, _ := postRegister(t, s, "plugin/subtitles/nope.srt"); code != http.StatusNotFound {
t.Errorf("missing file: want 404, got %d", code)
}
if err := os.MkdirAll(filepath.Join(files, "plugin", "subtitles", "dir"), 0o755); err != nil {
t.Fatal(err)
}
if code, _ := postRegister(t, s, "plugin/subtitles/dir"); code != http.StatusBadRequest {
t.Errorf("directory: want 400, got %d", code)
}
}
+74
View File
@@ -0,0 +1,74 @@
package api
import "net/http"
// fileTupleJSON is one file-backed root on the wire.
type fileTupleJSON struct {
HashID string `json:"hashId"`
FilePath string `json:"filePath"`
SizeByte int64 `json:"sizeByte"`
MtimeNano int64 `json:"mtimeNano"`
}
// FileTuplesResponse is the body of GET /api/files/tuples.
//
// Files is a pointer so the summary form can omit it while the full form
// always carries it — an empty library is `"files": []`, never an absent key,
// which a client could mistake for a meta-core that lacks the rows.
type FileTuplesResponse struct {
Files *[]fileTupleJSON `json:"files,omitempty"`
Count int `json:"count"`
TotalSize int64 `json:"totalSize"`
}
// handleGetFileTuples handles GET /api/files/tuples.
//
// Lists the FILE-BACKED roots — records carrying filePath, sizeByte and
// mtimeNano, i.e. what the watcher or /api/files/register minted — with their
// sizes, plus the count and total. Gateway / cid-rooted records carry no file
// and are not included; neither is a locator record that only merged a cache
// file's metadata (it has no mtimeNano).
//
// It exists so a client never walks every hashId to answer "how many files,
// how big": GetTuplesForAllFiles reads the three fields for every root in
// chunked MGETs server-side (~250 round-trips for 82k roots, no SCAN), where
// the client-side equivalent was one HTTP request per record.
//
// ?summary=1 omits the rows and returns only {count, totalSize}.
func (s *Server) handleGetFileTuples(w http.ResponseWriter, r *http.Request) {
if !s.storage.IsConnected() {
writeError(w, http.StatusServiceUnavailable, "storage not connected")
return
}
tuples, err := s.storage.GetTuplesForAllFiles()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
summary := r.URL.Query().Get("summary")
withRows := summary == "" || summary == "0" || summary == "false"
resp := FileTuplesResponse{Count: len(tuples)}
var rows []fileTupleJSON
if withRows {
rows = make([]fileTupleJSON, 0, len(tuples))
}
for _, t := range tuples {
resp.TotalSize += t.Size
if withRows {
rows = append(rows, fileTupleJSON{
HashID: t.HashID,
FilePath: t.FilePath,
SizeByte: t.Size,
MtimeNano: t.MtimeNano,
})
}
}
if withRows {
resp.Files = &rows
}
writeJSON(w, http.StatusOK, resp)
}
+114
View File
@@ -0,0 +1,114 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"sort"
"testing"
"github.com/alicebob/miniredis/v2"
"github.com/metazla/meta-core/internal/storage"
)
func newFileTuplesTestServer(t *testing.T) (*Server, *storage.Client) {
t.Helper()
mr := miniredis.RunT(t)
c := storage.NewClient("")
if err := c.Connect("redis://" + mr.Addr()); err != nil {
t.Fatalf("connect to miniredis: %v", err)
}
t.Cleanup(func() { _ = c.Close() })
return &Server{storage: c}, c
}
func getFileTuples(t *testing.T, s *Server, query string) (int, FileTuplesResponse) {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/api/files/tuples"+query, nil)
rr := httptest.NewRecorder()
s.handleGetFileTuples(rr, req)
var resp FileTuplesResponse
_ = json.Unmarshal(rr.Body.Bytes(), &resp)
return rr.Code, resp
}
func mustMint(t *testing.T, c *storage.Client, path string, size, mtime int64) string {
t.Helper()
id, err := c.Mint(path, size, mtime)
if err != nil {
t.Fatalf("mint %s: %v", path, err)
}
return id
}
// Only records that are actually files count: a gateway record and a record
// that merely carries a path + size (no mtimeNano) are both left out.
func TestFileTuples_OnlyFileBackedRoots(t *testing.T) {
s, c := newFileTuplesTestServer(t)
a := mustMint(t, c, "/files/watch/a.mkv", 1000, 1)
b := mustMint(t, c, "/files/watch/b.mkv", 2500, 2)
if err := c.SetMetadataFlat("bagcsgatewayrecord", map[string]string{"title": "Some Release"}); err != nil {
t.Fatal(err)
}
if err := c.SetMetadataFlat("01partialroot", map[string]string{"filePath": "/files/cache/x.mkv", "sizeByte": "999"}); err != nil {
t.Fatal(err)
}
code, resp := getFileTuples(t, s, "")
if code != http.StatusOK {
t.Fatalf("want 200, got %d", code)
}
if resp.Count != 2 || resp.TotalSize != 3500 {
t.Fatalf("want count 2 / totalSize 3500, got %d / %d", resp.Count, resp.TotalSize)
}
if resp.Files == nil || len(*resp.Files) != 2 {
t.Fatalf("want 2 rows, got %+v", resp.Files)
}
got := []string{(*resp.Files)[0].HashID, (*resp.Files)[1].HashID}
want := []string{a, b}
sort.Strings(got)
sort.Strings(want)
if got[0] != want[0] || got[1] != want[1] {
t.Fatalf("want roots %v, got %v", want, got)
}
}
func TestFileTuples_SummaryOmitsRows(t *testing.T) {
s, c := newFileTuplesTestServer(t)
mustMint(t, c, "/files/watch/a.mkv", 1000, 1)
mustMint(t, c, "/files/watch/b.mkv", 2500, 2)
code, resp := getFileTuples(t, s, "?summary=1")
if code != http.StatusOK {
t.Fatalf("want 200, got %d", code)
}
if resp.Files != nil {
t.Fatalf("summary must omit rows, got %d", len(*resp.Files))
}
if resp.Count != 2 || resp.TotalSize != 3500 {
t.Fatalf("want count 2 / totalSize 3500, got %d / %d", resp.Count, resp.TotalSize)
}
}
// An empty library still carries "files": [] — clients treat an absent key as
// "this meta-core cannot list files", so it must not disappear when empty.
func TestFileTuples_EmptyLibraryKeepsFilesKey(t *testing.T) {
s, _ := newFileTuplesTestServer(t)
code, resp := getFileTuples(t, s, "")
if code != http.StatusOK {
t.Fatalf("want 200, got %d", code)
}
if resp.Files == nil || len(*resp.Files) != 0 || resp.Count != 0 {
t.Fatalf("want an empty files array and count 0, got %+v", resp)
}
}
func TestFileTuples_StorageNotConnected(t *testing.T) {
s := &Server{storage: storage.NewClient("")}
code, _ := getFileTuples(t, s, "")
if code != http.StatusServiceUnavailable {
t.Fatalf("want 503, got %d", code)
}
}
+275 -45
View File
@@ -3,10 +3,14 @@ package api
import ( import (
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"errors"
"log"
"net/http" "net/http"
"strconv"
"strings" "strings"
"github.com/metazla/meta-core/internal/identity" "github.com/metazla/meta-core/internal/identity"
"github.com/metazla/meta-core/internal/storage"
) )
// identityStatusResponse is the shape of GET /api/identity. The private key // identityStatusResponse is the shape of GET /api/identity. The private key
@@ -58,12 +62,40 @@ type identityImportResponse struct {
Created bool `json:"created"` Created bool `json:"created"`
} }
// identityRevealRequest asks for an existing account's private key back. The // identityChallengeRequest asks for a one-shot challenge to sign.
// key was shown once at generate; without a way to see it again, a client with type identityChallengeRequest struct {
// a real sign-out has no recovery path for a user who never saved it. UID string `json:"uid"`
Action string `json:"action"`
}
type identityChallengeResponse struct {
Challenge string `json:"challenge"`
ExpiresAt int64 `json:"expiresAt"`
}
// identityProof is the proof-of-possession carried by reveal and delete: the
// challenge text as issued, and a signature over it by the account's key.
//
// The signature is produced by whoever holds the key — the browser, locally.
// It is NOT obtainable from POST /api/identity/sign, which refuses any payload
// in the challenge domain; that refusal is what makes this proof mean
// something on a node that stores every private key in plaintext.
type identityProof struct {
Challenge string `json:"challenge"`
Signature string `json:"signature"`
}
// identityRevealRequest asks for an existing account's private key back.
//
// Gated on proof-of-possession, which makes it a "does this node hold the same
// key I do" check rather than the recovery path it once was. That is the
// deliberate consequence of the rule this file enforces: the private key, and
// nothing else, is authority over the account. An operator with root on the box
// can still read the file; an operator with only the API cannot.
type identityRevealRequest struct { type identityRevealRequest struct {
Confirm bool `json:"confirm"` Confirm bool `json:"confirm"`
UID string `json:"uid"` UID string `json:"uid"`
identityProof
} }
type identityRevealResponse struct { type identityRevealResponse struct {
@@ -75,6 +107,15 @@ type identityRevealResponse struct {
type identityDeleteRequest struct { type identityDeleteRequest struct {
Confirm bool `json:"confirm"` Confirm bool `json:"confirm"`
UID string `json:"uid"` UID string `json:"uid"`
identityProof
}
// identityDeleteResponse reports what the delete actually removed. The purge
// counts are the point: "account removed" alone leaves the caller unable to
// tell a clean removal from one that silently stranded a decade of ratings.
type identityDeleteResponse struct {
UID string `json:"uid"`
Purged storage.UDLUserStats `json:"purged"`
} }
type identitySignRequest struct { type identitySignRequest struct {
@@ -88,6 +129,41 @@ type identitySignResponse struct {
SigB64 string `json:"sigB64"` SigB64 string `json:"sigB64"`
} }
// identitySignBatchRequest signs many payloads under one account in one call.
//
// Exists because a User Data Layer write signs exactly one record, and the bulk
// verbs above it ("mark this whole series seen") produce one record per episode:
// a 300-episode show is 300 sequential sign round trips through
// meta-watch -> meta-search -> here, which dominates the operation. The private
// key still never leaves this process; only the transport is batched.
type identitySignBatchRequest struct {
// PayloadsB64 are the canonical byte strings to sign, in order. The
// response's signatures line up index-for-index.
PayloadsB64 []string `json:"payloadsB64"`
// UID selects which account signs — same optional single-identity
// back-compat as identitySignRequest.
UID string `json:"uid,omitempty"`
}
type identitySignBatchResponse struct {
SigsB64 []string `json:"sigsB64"`
}
// maxSignBatch bounds one batch. A signature is cheap but not free, and the
// request holds the account lock for its duration; a caller with more than this
// should page. Sized well above the longest real series (Supernatural, 327).
const maxSignBatch = 1000
// reservedPayloadMessage explains the one refusal the signing endpoints make.
//
// This is the load-bearing half of proof-of-possession on a node that holds
// every private key: without it, a caller wanting to delete someone else's
// account just asks meta-core to sign the challenge for them, and the gate on
// reveal/delete becomes a formality. See identity.ChallengeDomain.
const reservedPayloadMessage = "payload is in the reserved account-authorisation domain (" +
identity.ChallengeDomain + "…); meta-core will not sign one. " +
"Signing a challenge is what proves you hold the key, so it must be done where the key is."
type identityAEADKeyResponse struct { type identityAEADKeyResponse struct {
AEADKeyB64 string `json:"aeadKeyB64"` AEADKeyB64 string `json:"aeadKeyB64"`
} }
@@ -217,23 +293,105 @@ func (s *Server) handleIdentityImport(w http.ResponseWriter, r *http.Request) {
}) })
} }
// handleIdentityReveal returns an existing account's private key. Deliberately // handleIdentityChallenge issues the one-shot text a client signs to prove it
// POST + explicit confirm (never a bare GET) so it cannot be triggered by a // holds an account's private key.
// link or an image tag, and so it reads as the deliberate act it is. The //
// endpoint is auth-gated at the perimeter like the rest of /api/identity/*. // Unauthenticated by design: a challenge is useless without the key, and
func (s *Server) handleIdentityReveal(w http.ResponseWriter, r *http.Request) { // GET /api/identity/accounts already publishes every uid, so refusing to mint
var body identityRevealRequest // one for an unknown caller would protect nothing. Requiring the account to
_ = json.NewDecoder(r.Body).Decode(&body) // exist is for a clear error, not secrecy.
if !body.Confirm && r.URL.Query().Get("confirm") != "true" { func (s *Server) handleIdentityChallenge(w http.ResponseWriter, r *http.Request) {
writeErrorSlug(w, http.StatusBadRequest, "confirm_required", var body identityChallengeRequest
"revealing a private key requires {\"confirm\": true} or ?confirm=true", false) if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return return
} }
uid := strings.TrimSpace(body.UID) uid := strings.TrimSpace(body.UID)
if uid == "" {
writeErrorSlug(w, http.StatusBadRequest, "uid_required", "uid is required", false)
return
}
action := identity.Action(strings.TrimSpace(body.Action))
if !identity.ValidAction(action) {
writeErrorSlug(w, http.StatusBadRequest, "bad_action",
`action must be "reveal" or "delete"`, false)
return
}
if !identity.ExistsByUID(s.config.IdentityAccountsDir(), uid) {
writeErrorSlug(w, http.StatusNotFound, "no_identity", "no identity for the given uid", false)
return
}
text, expires, err := s.challenges.Issue(uid, action)
if err != nil {
if errors.Is(err, identity.ErrChallengeFull) {
writeErrorSlug(w, http.StatusServiceUnavailable, "challenges_full", err.Error(), true)
return
}
writeErrorSlug(w, http.StatusBadRequest, "bad_uid", err.Error(), false)
return
}
writeJSON(w, http.StatusOK, identityChallengeResponse{
Challenge: text,
ExpiresAt: expires.Unix(),
})
}
// requireProof resolves the target account and verifies the caller's
// proof-of-possession for `action`. status == 0 means the proof held.
//
// The uid is mandatory here, unlike elsewhere in this file: the old "sole
// account" fallback would have this pick a target the signature does not name,
// and a proof that authorises an unnamed account authorises the wrong one as
// soon as a second profile exists.
func (s *Server) requireProof(uid string, proof identityProof, action identity.Action) (
id *identity.Identity, status int, slug, msg string,
) {
uid = strings.TrimSpace(uid)
if uid == "" {
return nil, http.StatusBadRequest, "uid_required",
"uid is required: the signature authorises one specific account"
}
if proof.Challenge == "" || proof.Signature == "" {
return nil, http.StatusUnauthorized, "signature_required",
"this operation requires proof you hold the account's private key: " +
"POST /api/identity/challenge, sign the returned text, and resend it as {challenge, signature}"
}
sig, err := base64.StdEncoding.DecodeString(proof.Signature)
if err != nil {
return nil, http.StatusBadRequest, "bad_signature", "signature is not valid base64"
}
// Resolve before redeeming so a request naming a missing account does not
// burn a challenge it could not have used anyway.
id, status, slug, msg = s.resolveAccount(uid)
if status != 0 {
return nil, status, slug, msg
}
// Redeem first, verify second: the challenge is single-use either way, so a
// wrong signature costs the attacker a fresh round trip per guess rather
// than letting them grind one challenge offline against the same nonce.
if err := s.challenges.Redeem(proof.Challenge, uid, action); err != nil {
return nil, http.StatusUnauthorized, "bad_challenge", err.Error()
}
if err := identity.Verify(uid, []byte(proof.Challenge), sig); err != nil {
return nil, http.StatusUnauthorized, "bad_signature", err.Error()
}
return id, 0, "", ""
}
// handleIdentityReveal returns an existing account's private key to a caller
// that proves it already holds that key.
//
// Deliberately POST (never a bare GET) so it cannot be triggered by a link or
// an image tag. The proof is what actually guards it: before this gate existed,
// anything that could reach the port could read every private key on the node.
func (s *Server) handleIdentityReveal(w http.ResponseWriter, r *http.Request) {
var body identityRevealRequest
_ = json.NewDecoder(r.Body).Decode(&body)
uid := strings.TrimSpace(body.UID)
if uid == "" { if uid == "" {
uid = strings.TrimSpace(r.URL.Query().Get("uid")) uid = strings.TrimSpace(r.URL.Query().Get("uid"))
} }
id, status, slug, msg := s.resolveAccount(uid) id, status, slug, msg := s.requireProof(uid, body.identityProof, identity.ActionReveal)
if status != 0 { if status != 0 {
writeErrorSlug(w, status, slug, msg, false) writeErrorSlug(w, status, slug, msg, false)
return return
@@ -245,46 +403,62 @@ func (s *Server) handleIdentityReveal(w http.ResponseWriter, r *http.Request) {
}) })
} }
// handleIdentityDelete removes one account and everything it wrote.
//
// Two things happen, in this order and no other:
//
// 1. the account's User Data Layer rows are purged (cells plus every index
// that names the uid, including the cid-scoped ones shared with other
// accounts);
// 2. the keypair file is removed.
//
// Purge-then-delete so a failure never strands data: if the purge errors the
// key is still on disk, the account still exists, and the caller can retry.
// The reverse order would leave rows nobody can ever attribute, reach or
// rewrite — the orphaning this endpoint exists to prevent.
//
// What deletion still does NOT do, and cannot: records already signed under
// this uid and replicated to other peers keep verifying there. Removing a key
// is not a recall.
func (s *Server) handleIdentityDelete(w http.ResponseWriter, r *http.Request) { func (s *Server) handleIdentityDelete(w http.ResponseWriter, r *http.Request) {
var body identityDeleteRequest var body identityDeleteRequest
// Tolerate empty body — confirm flag in a query param is also acceptable // Tolerate an empty body so the uid may ride in the query — meta-watch's
// for shells where sending a JSON body with DELETE is awkward. // proxy puts it there. The proof itself must be in the body: a signature in
// a URL ends up in every access log between here and the browser.
_ = json.NewDecoder(r.Body).Decode(&body) _ = json.NewDecoder(r.Body).Decode(&body)
if !body.Confirm && r.URL.Query().Get("confirm") != "true" {
writeErrorSlug(w, http.StatusBadRequest, "confirm_required",
"identity deletion requires {\"confirm\": true} or ?confirm=true", false)
return
}
dir := s.config.IdentityAccountsDir()
uid := strings.TrimSpace(body.UID) uid := strings.TrimSpace(body.UID)
if uid == "" { if uid == "" {
uid = strings.TrimSpace(r.URL.Query().Get("uid")) uid = strings.TrimSpace(r.URL.Query().Get("uid"))
} }
if uid == "" { id, status, slug, msg := s.requireProof(uid, body.identityProof, identity.ActionDelete)
// Back-compat: with no uid, delete the sole account. Ambiguous (and so if status != 0 {
// refused) once more than one account exists. writeErrorSlug(w, status, slug, msg, false)
list, err := identity.List(dir)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
switch len(list) {
case 0:
w.WriteHeader(http.StatusNoContent) // idempotent
return
case 1:
uid = list[0].UID
default:
writeErrorSlug(w, http.StatusBadRequest, "uid_required",
"multiple accounts exist; specify uid to delete", false)
return
}
}
if err := identity.DeleteByUID(dir, uid); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return return
} }
w.WriteHeader(http.StatusNoContent)
// A purge needs storage. Refusing here — rather than deleting the key and
// reporting the purge as "0 records" — is the difference between a retry
// and permanent orphaned data: once the key is gone the account cannot be
// named for a second attempt through this API.
if !s.storage.IsConnected() {
writeErrorSlug(w, http.StatusServiceUnavailable, "storage_unavailable",
"storage is not connected; refusing to delete the key while its data cannot be purged", true)
return
}
purged, err := s.storage.UDLPurgeUser(id.UID)
if err != nil {
writeErrorSlug(w, http.StatusInternalServerError, "purge_failed",
"could not purge this account's data; the account was NOT deleted: "+err.Error(), true)
return
}
if err := identity.DeleteByUID(s.config.IdentityAccountsDir(), id.UID); err != nil {
writeErrorSlug(w, http.StatusInternalServerError, "delete_failed", err.Error(), false)
return
}
log.Printf("[API] Deleted account %s and purged %d User Data Layer records across %d cids",
id.UID, purged.Records, purged.Cids)
writeJSON(w, http.StatusOK, identityDeleteResponse{UID: id.UID, Purged: purged})
} }
func (s *Server) handleIdentitySign(w http.ResponseWriter, r *http.Request) { func (s *Server) handleIdentitySign(w http.ResponseWriter, r *http.Request) {
@@ -307,6 +481,10 @@ func (s *Server) handleIdentitySign(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "bytesB64 is not valid base64") writeError(w, http.StatusBadRequest, "bytesB64 is not valid base64")
return return
} }
if identity.IsChallengePayload(raw) {
writeErrorSlug(w, http.StatusForbidden, "reserved_payload", reservedPayloadMessage, false)
return
}
sig, err := identity.Sign(id, raw) sig, err := identity.Sign(id, raw)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) writeError(w, http.StatusInternalServerError, err.Error())
@@ -317,6 +495,58 @@ func (s *Server) handleIdentitySign(w http.ResponseWriter, r *http.Request) {
}) })
} }
// handleIdentitySignBatch signs every payload in the request under one account.
//
// All-or-nothing: a single bad base64 or signing failure fails the whole
// request rather than returning a short array. The caller pairs signatures with
// records by index, so a partial response would silently misalign them — and a
// misaligned signature verifies against the wrong record, which is worse than
// an error.
func (s *Server) handleIdentitySignBatch(w http.ResponseWriter, r *http.Request) {
var body identitySignBatchRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
uid := body.UID
if strings.TrimSpace(uid) == "" {
uid = r.URL.Query().Get("uid")
}
if len(body.PayloadsB64) == 0 {
writeError(w, http.StatusBadRequest, "payloadsB64 is required")
return
}
if len(body.PayloadsB64) > maxSignBatch {
writeError(w, http.StatusBadRequest, "too many payloads in one batch")
return
}
id, status, slug, msg := s.resolveAccount(uid)
if status != 0 {
writeErrorSlug(w, status, slug, msg, false)
return
}
sigs := make([]string, 0, len(body.PayloadsB64))
for i, p := range body.PayloadsB64 {
raw, err := base64.StdEncoding.DecodeString(p)
if err != nil {
writeError(w, http.StatusBadRequest, "payloadsB64["+strconv.Itoa(i)+"] is not valid base64")
return
}
if identity.IsChallengePayload(raw) {
writeErrorSlug(w, http.StatusForbidden, "reserved_payload",
"payloadsB64["+strconv.Itoa(i)+"]: "+reservedPayloadMessage, false)
return
}
sig, err := identity.Sign(id, raw)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
sigs = append(sigs, base64.StdEncoding.EncodeToString(sig))
}
writeJSON(w, http.StatusOK, identitySignBatchResponse{SigsB64: sigs})
}
func (s *Server) handleIdentityAEADKey(w http.ResponseWriter, r *http.Request) { func (s *Server) handleIdentityAEADKey(w http.ResponseWriter, r *http.Request) {
id, status, slug, msg := s.resolveAccount(r.URL.Query().Get("uid")) id, status, slug, msg := s.resolveAccount(r.URL.Query().Get("uid"))
if status != 0 { if status != 0 {
+257
View File
@@ -0,0 +1,257 @@
package api
// End-to-end exercise of the account gate against a live router + miniredis:
// generate -> write UDL rows -> stats -> challenge -> sign -> delete -> verify
// both the key and every row are gone. Deliberately drives the real HTTP
// handlers rather than the packages beneath them, because the bugs that matter
// here (a challenge accepted for the wrong action, a purge that runs after the
// key is gone) live in the wiring.
import (
"bytes"
"encoding/base64"
"encoding/hex"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/alicebob/miniredis/v2"
"github.com/gorilla/mux"
"github.com/metazla/meta-core/internal/config"
"github.com/metazla/meta-core/internal/identity"
"github.com/metazla/meta-core/internal/storage"
)
func newTestServer(t *testing.T) *Server {
t.Helper()
mr := miniredis.RunT(t)
stor := storage.NewClient("")
if err := stor.Connect("redis://" + mr.Addr()); err != nil {
t.Fatalf("connect: %v", err)
}
t.Cleanup(func() { _ = stor.Close() })
s := &Server{
config: &config.Config{MetaCorePath: t.TempDir()},
storage: stor,
challenges: identity.NewChallengeStore(),
router: mux.NewRouter(),
}
s.setupIdentityTestRoutes()
return s
}
// Only the routes under test, so this does not depend on every other subsystem
// booting.
func (s *Server) setupIdentityTestRoutes() {
s.router.HandleFunc("/api/identity/accounts", s.handleIdentityAccounts).Methods("GET")
s.router.HandleFunc("/api/identity/challenge", s.handleIdentityChallenge).Methods("POST")
s.router.HandleFunc("/api/identity/generate", s.handleIdentityGenerate).Methods("POST")
s.router.HandleFunc("/api/identity/reveal", s.handleIdentityReveal).Methods("POST")
s.router.HandleFunc("/api/identity", s.handleIdentityDelete).Methods("DELETE")
s.router.HandleFunc("/api/identity/sign", s.handleIdentitySign).Methods("POST")
s.router.HandleFunc("/api/udl/users/stats", s.handleUDLUserStats).Methods("GET")
}
func do(t *testing.T, s *Server, method, path string, body any) (*httptest.ResponseRecorder, map[string]any) {
t.Helper()
var rdr *bytes.Reader
if body != nil {
b, _ := json.Marshal(body)
rdr = bytes.NewReader(b)
} else {
rdr = bytes.NewReader(nil)
}
req := httptest.NewRequest(method, path, rdr)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
s.router.ServeHTTP(rec, req)
out := map[string]any{}
_ = json.Unmarshal(rec.Body.Bytes(), &out)
return rec, out
}
// signAs produces the proof a browser would, from the raw key.
func signAs(t *testing.T, privHex, challenge string) map[string]any {
t.Helper()
raw, err := hex.DecodeString(privHex)
if err != nil {
t.Fatalf("decode key: %v", err)
}
id, err := identity.Import(hex.EncodeToString(raw))
if err != nil {
t.Fatalf("import: %v", err)
}
sig, err := identity.Sign(id, []byte(challenge))
if err != nil {
t.Fatalf("sign: %v", err)
}
return map[string]any{
"challenge": challenge,
"signature": base64.StdEncoding.EncodeToString(sig),
}
}
func TestE2E_DeleteRequiresProofAndPurgesEverything(t *testing.T) {
s := newTestServer(t)
rec, gen := do(t, s, "POST", "/api/identity/generate", nil)
if rec.Code != http.StatusCreated {
t.Fatalf("generate: %d %s", rec.Code, rec.Body)
}
uid := gen["uid"].(string)
priv := gen["privateKeyHex"].(string)
// Some data to lose.
for _, cell := range [][2]string{{"cidA", "like"}, {"cidA", "rating"}, {"cidB", "seek"}} {
if ok, err := s.storage.UDLUpsertIfNewer(uid, cell[0], cell[1], 1, 100, "rec", "", false, false); err != nil || !ok {
t.Fatalf("seed: %v", err)
}
}
// The count the UI shows in the confirmation.
rec, stats := do(t, s, "GET", "/api/udl/users/stats", nil)
if rec.Code != http.StatusOK {
t.Fatalf("stats: %d %s", rec.Code, rec.Body)
}
users := stats["users"].(map[string]any)
mine := users[uid].(map[string]any)
if mine["records"].(float64) != 3 || mine["cids"].(float64) != 2 {
t.Fatalf("stats = %v, want 3 records / 2 cids", mine)
}
// Unsigned delete must be refused, and must not have touched anything.
rec, body := do(t, s, "DELETE", "/api/identity", map[string]any{"uid": uid, "confirm": true})
if rec.Code != http.StatusUnauthorized || body["error"] != "signature_required" {
t.Fatalf("unsigned delete = %d %v, want 401 signature_required", rec.Code, body)
}
if !identity.ExistsByUID(s.config.IdentityAccountsDir(), uid) {
t.Fatal("a refused delete removed the key anyway")
}
// meta-core must refuse to sign the caller's authorisation for them —
// without this the gate is decoration on a node holding every key.
ch, _, err := s.challenges.Issue(uid, identity.ActionDelete)
if err != nil {
t.Fatalf("issue: %v", err)
}
rec, body = do(t, s, "POST", "/api/identity/sign", map[string]any{
"uid": uid,
"bytesB64": base64.StdEncoding.EncodeToString([]byte(ch)),
})
if rec.Code != http.StatusForbidden || body["error"] != "reserved_payload" {
t.Fatalf("sign of a challenge = %d %v, want 403 reserved_payload", rec.Code, body)
}
// A challenge minted for reveal must not authorise a delete.
rec, chBody := do(t, s, "POST", "/api/identity/challenge", map[string]any{"uid": uid, "action": "reveal"})
if rec.Code != http.StatusOK {
t.Fatalf("challenge: %d %s", rec.Code, rec.Body)
}
proof := signAs(t, priv, chBody["challenge"].(string))
req := map[string]any{"uid": uid, "confirm": true}
for k, v := range proof {
req[k] = v
}
rec, body = do(t, s, "DELETE", "/api/identity", req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("reveal challenge used for delete = %d %v, want 401", rec.Code, body)
}
// The real thing.
rec, chBody = do(t, s, "POST", "/api/identity/challenge", map[string]any{"uid": uid, "action": "delete"})
if rec.Code != http.StatusOK {
t.Fatalf("challenge: %d %s", rec.Code, rec.Body)
}
proof = signAs(t, priv, chBody["challenge"].(string))
req = map[string]any{"uid": uid, "confirm": true}
for k, v := range proof {
req[k] = v
}
rec, body = do(t, s, "DELETE", "/api/identity", req)
if rec.Code != http.StatusOK {
t.Fatalf("signed delete = %d %s", rec.Code, rec.Body)
}
purged := body["purged"].(map[string]any)
if purged["records"].(float64) != 3 || purged["cids"].(float64) != 2 {
t.Fatalf("purged = %v, want 3 records / 2 cids", purged)
}
// Key gone.
if identity.ExistsByUID(s.config.IdentityAccountsDir(), uid) {
t.Fatal("the account file survived the delete")
}
// Data gone — the orphaning this whole change exists to prevent.
after, err := s.storage.UDLAllUserStats()
if err != nil {
t.Fatalf("stats after: %v", err)
}
if got, present := after[uid]; present {
t.Fatalf("deleted account still holds %+v", got)
}
for _, cid := range []string{"cidA", "cidB"} {
uids, err := s.storage.UDLCidUsers(cid)
if err != nil {
t.Fatalf("cid users: %v", err)
}
if len(uids) != 0 {
t.Fatalf("%s still names %v after the purge", cid, uids)
}
}
// The challenge is single-use, so replaying the same proof does nothing.
rec, _ = do(t, s, "DELETE", "/api/identity", req)
if rec.Code == http.StatusOK {
t.Fatal("a replayed proof must not succeed")
}
}
func TestE2E_RevealRequiresProof(t *testing.T) {
s := newTestServer(t)
_, gen := do(t, s, "POST", "/api/identity/generate", nil)
uid := gen["uid"].(string)
priv := gen["privateKeyHex"].(string)
rec, body := do(t, s, "POST", "/api/identity/reveal", map[string]any{"uid": uid, "confirm": true})
if rec.Code != http.StatusUnauthorized || body["error"] != "signature_required" {
t.Fatalf("unsigned reveal = %d %v, want 401 signature_required", rec.Code, body)
}
_, chBody := do(t, s, "POST", "/api/identity/challenge", map[string]any{"uid": uid, "action": "reveal"})
proof := signAs(t, priv, chBody["challenge"].(string))
req := map[string]any{"uid": uid, "confirm": true}
for k, v := range proof {
req[k] = v
}
rec, body = do(t, s, "POST", "/api/identity/reveal", req)
if rec.Code != http.StatusOK {
t.Fatalf("signed reveal = %d %s", rec.Code, rec.Body)
}
if body["privateKeyHex"] != priv {
t.Fatal("reveal returned a different key than generate did")
}
}
// Another account's key must not authorise this one, even with a challenge
// minted for the right uid and action.
func TestE2E_AnotherAccountsKeyIsRefused(t *testing.T) {
s := newTestServer(t)
_, alice := do(t, s, "POST", "/api/identity/generate", nil)
_, mallory := do(t, s, "POST", "/api/identity/generate", nil)
uid := alice["uid"].(string)
_, chBody := do(t, s, "POST", "/api/identity/challenge", map[string]any{"uid": uid, "action": "delete"})
proof := signAs(t, mallory["privateKeyHex"].(string), chBody["challenge"].(string))
req := map[string]any{"uid": uid, "confirm": true}
for k, v := range proof {
req[k] = v
}
rec, body := do(t, s, "DELETE", "/api/identity", req)
if rec.Code != http.StatusUnauthorized || body["error"] != "bad_signature" {
t.Fatalf("mallory deleting alice = %d %v, want 401 bad_signature", rec.Code, body)
}
if !identity.ExistsByUID(s.config.IdentityAccountsDir(), uid) {
t.Fatal("alice's account was deleted by mallory's signature")
}
}
+42 -60
View File
@@ -4,7 +4,6 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"strconv" "strconv"
"strings"
"github.com/gorilla/mux" "github.com/gorilla/mux"
) )
@@ -39,6 +38,36 @@ type MetadataSearchRequest struct {
Property string `json:"property,omitempty"` Property string `json:"property,omitempty"`
PropertyValue string `json:"propertyValue,omitempty"` PropertyValue string `json:"propertyValue,omitempty"`
Limit int `json:"limit,omitempty"` Limit int `json:"limit,omitempty"`
// Filters is the structured form: AND across entries, OR within an
// entry's Values. Evaluated in memory against the search index alongside
// the free-text Query, so an id-anchored lookup costs a map probe per
// record rather than a Redis round-trip per record.
//
// Property/PropertyValue is the single-filter legacy shape and folds into
// this list (see normalizedFilters) with Exact=false, preserving its
// substring semantics byte-for-byte.
Filters []MetadataFilter `json:"filters,omitempty"`
}
// MetadataFilter is one structured constraint on a record.
//
// A record satisfies it when ANY of Values matches, and a search result must
// satisfy EVERY filter (OR within, AND across). An entry with no usable value
// constrains nothing — same rule the gateway's `genres_filter_matches` uses, so
// a caller that lowers an empty filter doesn't accidentally empty the result.
type MetadataFilter struct {
// Property is the field name, WITHOUT a key-set suffix: `genres`, not
// `genres/Action`. Both storage shapes are probed — see recordMatchesFilter.
Property string `json:"property"`
// Values are OR'd. Compared case-insensitively.
Values []string `json:"values,omitempty"`
// Exact selects CSV-exact comparison (the semantics meta-gateway's
// `any_csv_value_matches` applies at its own Layer-B re-filter) over
// substring containment. Callers doing identity lookups want this: with a
// result limit, a substring match on `tmdbid=1405` also admits `21405` and
// can crowd the real matches out of the page.
Exact bool `json:"exact,omitempty"`
} }
// MetadataSearchResult is a single search result // MetadataSearchResult is a single search result
@@ -415,65 +444,18 @@ func (s *Server) handleSearchMetadata(w http.ResponseWriter, r *http.Request) {
return return
} }
// Free-text query: served from the in-memory search index (search_index.go) // Everything else — free text, structured filters, both, or neither
// so we match in memory instead of doing a SCAN+MGET Redis round-trip per // (which returns the first `limit`) — is served from the in-memory search
// record (that was 40s+ over ~72k records). Full metadata is fetched only // index (search_index.go). Matching and the returned metadata both come
// for the <=limit matches. // from memory, so a query costs zero Redis round-trips.
if req.Query != "" && req.Property == "" { //
s.serveIndexedSearch(w, req) // There is deliberately no second path. The property branch used to fall
return // through to a `GetAllHashIDs` + per-record `GetMetadataFlat` scan, which
} // is what made an id-anchored lookup 3.63s over ~20k records (vs 10ms for
// the free-text branch on the same corpus) and kept meta-core out of the
// Property filter (rare) or empty query (returns the first `limit`): the // gateway's anchored search path entirely. The index already holds every
// legacy scan. Empty query only fetches `limit` records; property search is // field of every record, so that scan was re-reading data it had in hand.
// low-volume and not on the hot search path. s.serveIndexedSearch(w, req)
allHashIDs, err := s.storage.GetAllHashIDs()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
var results []MetadataSearchResult
total := len(allHashIDs)
for _, hashID := range allHashIDs {
if len(results) >= req.Limit {
break
}
metadata, err := s.storage.GetMetadataFlat(hashID)
if err != nil || metadata == nil {
continue
}
matches := false
// Property search
if req.Property != "" && req.PropertyValue != "" {
value, ok := metadata[req.Property]
if ok && strings.Contains(strings.ToLower(value), strings.ToLower(req.PropertyValue)) {
matches = true
}
}
// If no query or property filter, return all
if req.Query == "" && req.Property == "" {
matches = true
}
if matches {
results = append(results, MetadataSearchResult{
HashID: hashID,
Metadata: metadata,
})
}
}
writeJSON(w, http.StatusOK, MetadataSearchResponse{
Results: results,
Count: len(results),
Total: total,
})
} }
// handleBatchUpdate handles POST /api/metadata/batch // handleBatchUpdate handles POST /api/metadata/batch
+211
View File
@@ -0,0 +1,211 @@
package api
import "testing"
// Tests for the structured-filter matcher that lets meta-core answer an
// id-anchored query from the in-memory search index (search_index.go).
//
// Why this matters beyond "filters work": meta-core is a *coarse pre-filter*
// for meta-gateway, which re-evaluates every record it emits through its own
// `query_eval::record_matches`. So the property that has to hold is that this
// matcher stays a SUPERSET of that one — it may admit records the gateway will
// drop, but it must never drop a record the gateway would have kept. Every
// case below is written from that angle:
//
// - key-set fields (`genres/Action`) must match a `genres:action` filter, or
// card-tier records vanish from a genre-scoped query;
// - a missing field fails CLOSED, matching record_matches;
// - an empty/unusable filter constrains NOTHING, so a caller that lowers a
// blank filter gets its results rather than an empty page.
func f(property string, exact bool, values ...string) MetadataFilter {
return MetadataFilter{Property: property, Values: values, Exact: exact}
}
func TestRecordMatchesFilter(t *testing.T) {
fields := map[string]string{
"fileType": "video",
"videoType": "tv,anime",
"tmdbid": "1405",
"genres/Action": "true",
"genres/Science Fiction": "true",
"source/gateway:nyaa.si": "true",
"categories/XXX": "true",
"retracted": "false",
}
cases := []struct {
name string
filter MetadataFilter
want bool
}{
// Flat scalar.
{"flat exact hit", f("fileType", true, "video"), true},
{"flat exact miss", f("fileType", true, "audio"), false},
{"flat exact is case-insensitive", f("fileType", true, "VIDEO"), true},
// csv-set field: each comma piece is compared independently.
{"csv member hit", f("videoType", true, "anime"), true},
{"csv member miss", f("videoType", true, "movie"), false},
// Exact vs contains — the reason Exact exists. A substring match on an
// identity field admits neighbours and, under a result limit, crowds
// the real matches off the page.
{"contains admits a superstring", f("tmdbid", false, "140"), true},
{"exact rejects a superstring", f("tmdbid", true, "140"), false},
{"exact accepts the whole value", f("tmdbid", true, "1405"), true},
// Key-set shape. The stored member is verbatim (`genres/Action`) while
// query values arrive lowercased, so the case-insensitive sweep is what
// makes a genre-scoped row work at all.
{"key-set exact casing", f("genres", true, "Action"), true},
{"key-set lowercased query", f("genres", true, "action"), true},
{"key-set with a space", f("genres", true, "science fiction"), true},
{"key-set miss", f("genres", true, "horror"), false},
{"key-set label containing a colon", f("source", true, "gateway:nyaa.si"), true},
// OR within one filter's values.
{"or hits on the second value", f("fileType", true, "audio", "video"), true},
{"or misses on all values", f("fileType", true, "audio", "image"), false},
// Fail-closed on an absent field — mirrors record_matches, and is what
// keeps a filtered query from returning unrelated records.
{"absent field fails closed", f("imdbid", true, "tt0903747"), false},
// A filter that constrains nothing must not empty the result.
{"no values constrains nothing", f("fileType", true), true},
{"blank value constrains nothing", f("fileType", true, " "), true},
{"no property constrains nothing", f("", true, "video"), true},
// A FLAT field whose value happens to be "false" is still a value, and
// an exact filter for it matches. Only the key-set arm treats "false"
// as non-membership (pinned in the test below).
{"flat field valued false still matches", f("retracted", true, "false"), true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := recordMatchesFilter(fields, &tc.filter); got != tc.want {
t.Fatalf("recordMatchesFilter(%+v) = %v, want %v", tc.filter, got, tc.want)
}
})
}
}
// `retracted = "false"` is a flat field, not a key-set member, so the flat arm
// legitimately matches the literal string "false". The key-set arm is what must
// treat it as non-membership — pinned separately so the two arms don't get
// conflated if either is refactored.
func TestFalseyKeySetMemberIsNotMembership(t *testing.T) {
fields := map[string]string{"genres/Action": "false", "genres/Drama": "0"}
for _, v := range []string{"Action", "Drama"} {
filter := f("genres", true, v)
if recordMatchesFilter(fields, &filter) {
t.Fatalf("genres/%s with a falsey value must not assert membership", v)
}
}
}
func TestRecordMatchesFiltersIsAndAcrossEntries(t *testing.T) {
fields := map[string]string{"fileType": "video", "season": "2"}
all := []MetadataFilter{f("fileType", true, "video"), f("season", true, "2")}
if !recordMatchesFilters(fields, all) {
t.Fatal("a record satisfying every filter must match")
}
one := []MetadataFilter{f("fileType", true, "video"), f("season", true, "3")}
if recordMatchesFilters(fields, one) {
t.Fatal("filters are AND across entries — one miss must reject the record")
}
if !recordMatchesFilters(fields, nil) {
t.Fatal("no filters must match everything")
}
}
// The legacy Property/PropertyValue pair keeps its substring semantics: it is
// the shape existing callers already use, and tightening it silently would
// change results for them. New callers pass Filters and opt into Exact.
func TestNormalizedFiltersFoldsTheLegacyPair(t *testing.T) {
got := normalizedFilters(MetadataSearchRequest{Property: "tmdbid", PropertyValue: "1405"})
if len(got) != 1 {
t.Fatalf("legacy pair must fold into exactly one filter, got %d", len(got))
}
if got[0].Property != "tmdbid" || len(got[0].Values) != 1 || got[0].Values[0] != "1405" {
t.Fatalf("legacy pair folded incorrectly: %+v", got[0])
}
if got[0].Exact {
t.Fatal("the legacy pair must stay substring (Exact=false) for compatibility")
}
// A half-specified pair is not a filter — it must not become one that
// matches nothing and empties the response.
if n := len(normalizedFilters(MetadataSearchRequest{Property: "tmdbid"})); n != 0 {
t.Fatalf("a property with no value must not fold into a filter, got %d", n)
}
if n := len(normalizedFilters(MetadataSearchRequest{PropertyValue: "1405"})); n != 0 {
t.Fatalf("a value with no property must not fold into a filter, got %d", n)
}
both := normalizedFilters(MetadataSearchRequest{
Property: "tmdbid",
PropertyValue: "1405",
Filters: []MetadataFilter{f("fileType", true, "video")},
})
if len(both) != 2 {
t.Fatalf("the legacy pair must be appended to explicit filters, got %d", len(both))
}
}
// The acceptance bar for routing the anchored path through the index: a
// full-miss filtered sweep over a realistic corpus must stay in the same class
// as the free-text branch (measured 10 ms over 19 622 live records), not the
// legacy per-record Redis scan it replaces (measured 3.63 s over the same
// corpus). Full-miss is the worst case — the loop cannot short-circuit on the
// result limit, so every record is evaluated.
//
// Run: go test ./internal/api/ -bench BenchmarkFilterSweep -benchtime 10x
func BenchmarkFilterSweep(b *testing.B) {
const records = 20000
corpus := make([]map[string]string, records)
for i := range corpus {
corpus[i] = map[string]string{
"title": "Some Release",
"fileType": "video",
"videoType": "tv",
"tmdbid": "1405",
"season": "2",
"genres/Action": "true",
"genres/Drama": "true",
"source/gateway:nyaa.si": "true",
"categories/TV/Anime": "true",
"indexer": "nyaa.si",
"fileName": "some.release.s02e05.mkv",
"description/eng": "text",
"poster": "bafy",
"sizeByte": "123456789",
"contentKind": "episode",
"anchoredBy/tmdb:1405": "true",
}
}
// A miss on the identity field: the shape an id-anchored gateway query
// takes, and the one that must not degrade.
filters := []MetadataFilter{
f("tmdbid", true, "999999"),
f("fileType", true, "video"),
f("genres", true, "action"),
}
b.ResetTimer()
for n := 0; n < b.N; n++ {
hits := 0
for _, fields := range corpus {
if recordMatchesFilters(fields, filters) {
hits++
}
}
if hits != 0 {
b.Fatalf("expected a full miss, got %d hits", hits)
}
}
}
+248 -16
View File
@@ -3,6 +3,7 @@ package api
import ( import (
"log" "log"
"net/http" "net/http"
"sort"
"strings" "strings"
"sync" "sync"
"sync/atomic" "sync/atomic"
@@ -17,19 +18,46 @@ import (
// batched bulk read (storage.GetSearchIndexTuples), off the request path. // batched bulk read (storage.GetSearchIndexTuples), off the request path.
const searchIndexRefreshInterval = 60 * time.Second const searchIndexRefreshInterval = 60 * time.Second
// searchIndexReconcileEvery is how many refresh ticks pass between full
// rebuilds. Every other tick is incremental. 30 ticks × 60 s = a full
// reconcile every 30 minutes — see runRefreshLoop for why one is kept at all.
const searchIndexReconcileEvery = 30
// searchIndex is an in-memory snapshot of every record's search haystack // searchIndex is an in-memory snapshot of every record's search haystack
// (hashID + lowercased searchable fields), rebuilt periodically from Redis. It // (hashID + lowercased searchable fields), kept current from Redis. It exists
// exists so POST /api/metadata/search matches in memory (string ops over a // so POST /api/metadata/search matches in memory (string ops over a slice)
// slice) instead of doing a SCAN+MGET Redis round-trip per record — the latter // instead of doing a SCAN+MGET Redis round-trip per record — the latter took
// took 40s+ over ~72k records and made a live parallel search path unusable. // 40s+ over ~72k records and made a live parallel search path unusable.
//
// # Two update paths, and why
//
// `refresh` rebuilds everything: a whole-keyspace SCAN, batched MGETs, and a
// complete fresh copy. That is O(corpus) regardless of how much changed, and it
// used to run every 60 s forever. Measured on a 19 622-record staging corpus
// (2026-08-30): the loop accounted for ~45 % of ALL Redis CPU, and the Go side
// held 783 MB for data Redis stores in 135 MB — largely the double copy.
//
// `refreshIncremental` re-reads only the records that changed, tracked by
// storage's dirty set (search_dirty.go). Cost is O(change). It is the steady
// state; the full rebuild runs once at startup (there is nothing to be
// incremental against) and then only as an explicit repair.
//
// The distinction matters because meta-core is the platform's permanent,
// additive store — every search hit on every gateway is retained — so a rebuild
// priced against the whole corpus is a cost that only ever grows.
type searchIndex struct { type searchIndex struct {
mu sync.RWMutex mu sync.RWMutex
// byID is the authoritative content; tuples is a stable-order view of it
// rebuilt on each update. Map iteration order in Go is randomised, so
// serving a limited query straight off the map would return a different
// subset each call — the slice is what keeps results stable.
byID map[string]storage.SearchTuple
tuples []storage.SearchTuple tuples []storage.SearchTuple
ready atomic.Bool ready atomic.Bool
} }
// snapshot returns the current tuples slice and whether the index has built at // snapshot returns the current tuples slice and whether the index has built at
// least once. The refresh swaps in a fresh slice under the write lock (never // least once. Every update swaps in a fresh slice under the write lock (never
// mutates in place), so the returned slice is safe to read lock-free. // mutates in place), so the returned slice is safe to read lock-free.
func (idx *searchIndex) snapshot() ([]storage.SearchTuple, bool) { func (idx *searchIndex) snapshot() ([]storage.SearchTuple, bool) {
idx.mu.RLock() idx.mu.RLock()
@@ -37,7 +65,21 @@ func (idx *searchIndex) snapshot() ([]storage.SearchTuple, bool) {
return idx.tuples, idx.ready.Load() return idx.tuples, idx.ready.Load()
} }
// refresh rebuilds the index from Redis once (a batched bulk read). // rebuildViewLocked regenerates the stable-order slice from byID. Caller holds
// the write lock.
func (idx *searchIndex) rebuildViewLocked() {
view := make([]storage.SearchTuple, 0, len(idx.byID))
for _, t := range idx.byID {
view = append(view, t)
}
// Stable across rebuilds so a limited query returns a consistent page
// rather than a fresh random subset every interval.
sort.Slice(view, func(i, j int) bool { return view[i].HashID < view[j].HashID })
idx.tuples = view
}
// refresh rebuilds the whole index from Redis (a batched bulk read). Used for
// the initial build and as an explicit repair — not the steady state.
func (idx *searchIndex) refresh(stor *storage.Client) { func (idx *searchIndex) refresh(stor *storage.Client) {
if stor == nil || !stor.IsConnected() { if stor == nil || !stor.IsConnected() {
return return
@@ -47,27 +89,101 @@ func (idx *searchIndex) refresh(stor *storage.Client) {
log.Printf("[API] search index refresh failed: %v", err) log.Printf("[API] search index refresh failed: %v", err)
return return
} }
byID := make(map[string]storage.SearchTuple, len(tuples))
for _, t := range tuples {
byID[t.HashID] = t
}
idx.mu.Lock() idx.mu.Lock()
idx.tuples = tuples idx.byID = byID
idx.rebuildViewLocked()
idx.mu.Unlock() idx.mu.Unlock()
idx.ready.Store(true) idx.ready.Store(true)
} }
// runRefreshLoop builds the index immediately, then rebuilds on a ticker for the // refreshIncremental drains the dirty set and patches only those records.
// process lifetime. // Returns whether more dirty entries remain, so a burst is caught up over
// consecutive passes rather than one per interval.
//
// A record in the dirty set but absent from storage was deleted and is evicted
// here — otherwise it would keep answering searches until the next full
// rebuild. A batch that fails after the (destructive) drain is re-marked by
// storage so it is retried rather than silently lost.
func (idx *searchIndex) refreshIncremental(stor *storage.Client) (more bool) {
if stor == nil || !stor.IsConnected() {
return false
}
ids, more, err := stor.DrainDirty()
if err != nil {
log.Printf("[API] search index drain failed: %v", err)
return false
}
if len(ids) == 0 {
return false
}
tuples, gone, err := stor.GetSearchIndexTuplesFor(ids)
if err != nil {
stor.MarkDirty(ids...)
log.Printf("[API] incremental search index refresh failed: %v", err)
return false
}
idx.mu.Lock()
if idx.byID == nil {
idx.byID = make(map[string]storage.SearchTuple, len(tuples))
}
for _, t := range tuples {
idx.byID[t.HashID] = t
}
for _, id := range gone {
delete(idx.byID, id)
}
idx.rebuildViewLocked()
idx.mu.Unlock()
log.Printf("[API] search index: %d updated, %d evicted (%d records)", len(tuples), len(gone), len(idx.tuples))
return more
}
// runRefreshLoop builds the index once in full, then keeps it current from the
// dirty set, with a periodic full reconcile as a safety net.
//
// The reconcile exists because a *missed* dirty mark is silent: no error, just
// a record whose indexed copy quietly diverges from storage until something
// forces a rebuild. Marking is spread across eight write paths plus anything
// added later, and the cost of getting it wrong is invisible — so the index
// should not depend on that coverage being perfect forever. Every
// searchIndexReconcileEvery-th tick pays the old O(corpus) pass and repairs
// whatever drifted.
//
// That still drops the full rebuild from every 60 s to every 30 min — a ~30×
// reduction in the steady-state cost that measured at ~45 % of all Redis CPU —
// while keeping the self-healing property that made the naive loop safe.
func (idx *searchIndex) runRefreshLoop(stor *storage.Client) { func (idx *searchIndex) runRefreshLoop(stor *storage.Client) {
idx.refresh(stor) idx.refresh(stor)
ticker := time.NewTicker(searchIndexRefreshInterval) ticker := time.NewTicker(searchIndexRefreshInterval)
defer ticker.Stop() defer ticker.Stop()
ticks := 0
for range ticker.C { for range ticker.C {
idx.refresh(stor) ticks++
if ticks%searchIndexReconcileEvery == 0 {
idx.refresh(stor)
continue
}
// Drain until caught up, so a burst larger than one batch does not take
// one interval per batch to land.
for idx.refreshIncremental(stor) {
}
} }
} }
// serveIndexedSearch answers a free-text (non-property) query from the in-memory // serveIndexedSearch answers a search from the in-memory index: a token-AND
// index: token-AND match against each record's haystack (or a whole-query hashID // match against each record's haystack (or a whole-query hashID substring,
// substring, preserving the legacy behaviour), then fetch full metadata for only // preserving the legacy behaviour) AND every structured filter, then returns
// the <=limit matches. No Redis round-trip per scanned record. // the full metadata for the <=limit survivors. No Redis round-trip per scanned
// record — matching and results both come from memory.
//
// This is the ONLY search path. Free text, filters, both, or neither all land
// here; only the `hashId` exact-match branch in handleSearchMetadata bypasses it.
func (s *Server) serveIndexedSearch(w http.ResponseWriter, req MetadataSearchRequest) { func (s *Server) serveIndexedSearch(w http.ResponseWriter, req MetadataSearchRequest) {
tuples, ready := s.search.snapshot() tuples, ready := s.search.snapshot()
if !ready { if !ready {
@@ -78,15 +194,20 @@ func (s *Server) serveIndexedSearch(w http.ResponseWriter, req MetadataSearchReq
} }
tokens := strings.Fields(strings.ToLower(req.Query)) tokens := strings.Fields(strings.ToLower(req.Query))
queryLower := strings.ToLower(req.Query) queryLower := strings.ToLower(req.Query)
filters := normalizedFilters(req)
results := make([]MetadataSearchResult, 0, req.Limit) results := make([]MetadataSearchResult, 0, req.Limit)
for _, t := range tuples { for _, t := range tuples {
if len(results) >= req.Limit { if len(results) >= req.Limit {
break break
} }
if !matchAllTokens(t.Haystack, tokens) && if req.Query != "" &&
!matchAllTokens(t.Haystack, tokens) &&
!strings.Contains(strings.ToLower(t.HashID), queryLower) { !strings.Contains(strings.ToLower(t.HashID), queryLower) {
continue continue
} }
if !recordMatchesFilters(t.Fields, filters) {
continue
}
// Full metadata is cached in the index — return it straight from memory, // Full metadata is cached in the index — return it straight from memory,
// no Redis round-trip per result. // no Redis round-trip per result.
results = append(results, MetadataSearchResult{HashID: t.HashID, Metadata: t.Fields}) results = append(results, MetadataSearchResult{HashID: t.HashID, Metadata: t.Fields})
@@ -108,3 +229,114 @@ func matchAllTokens(haystack string, tokens []string) bool {
} }
return true return true
} }
// normalizedFilters returns the request's structured filters with the legacy
// Property/PropertyValue pair folded in as one more entry.
//
// The legacy pair keeps Exact=false so its long-standing substring semantics
// are unchanged for existing callers; new callers pass Filters and choose.
func normalizedFilters(req MetadataSearchRequest) []MetadataFilter {
if req.Property == "" || req.PropertyValue == "" {
return req.Filters
}
out := make([]MetadataFilter, 0, len(req.Filters)+1)
out = append(out, req.Filters...)
return append(out, MetadataFilter{
Property: req.Property,
Values: []string{req.PropertyValue},
})
}
// recordMatchesFilters reports whether fields satisfies every filter (AND).
func recordMatchesFilters(fields map[string]string, filters []MetadataFilter) bool {
for i := range filters {
if !recordMatchesFilter(fields, &filters[i]) {
return false
}
}
return true
}
// recordMatchesFilter reports whether fields satisfies one filter (OR within
// its values), probing BOTH storage shapes a field can take:
//
// - a flat scalar/csv-set field — `fileType = "video"`, `videoType = "tv,anime"`;
// - a key-set member — `genres/Action = "true"`, `source/gateway:nyaa.si = "true"`
// (METADATA_KEYS "How the store is shaped").
//
// Probing both is what lets one filter shape serve the whole vocabulary. The
// gateway needs a `genres:action` filter to find a record whose genre lives in
// the key `genres/Action`, and a `fileType:video` filter to find one whose value
// lives in the field `fileType` — without meta-core knowing which fields are
// key-sets. It also means this stays a SUPERSET of the gateway's own
// `record_matches`, which is the contract: meta-core pre-filters, the gateway
// re-filters authoritatively.
//
// A filter with no usable value constrains nothing (matches). A filter whose
// field is absent fails closed, matching `record_matches`.
func recordMatchesFilter(fields map[string]string, f *MetadataFilter) bool {
values := make([]string, 0, len(f.Values))
for _, v := range f.Values {
if v = strings.TrimSpace(v); v != "" {
values = append(values, v)
}
}
if f.Property == "" || len(values) == 0 {
return true
}
// Key-set shape: `<property>/<value>` present and truthy. Try the exact
// key first — that hits whenever the caller's casing matches what was
// written, which is the common case and costs one map probe.
for _, v := range values {
if isTruthyMember(fields[f.Property+"/"+v]) {
return true
}
}
// Members are written verbatim (`genres/Action`) while query values arrive
// lowercased, so fall back to one case-insensitive sweep. Single pass over
// the record's fields for ALL values, not one pass per value.
wanted := make(map[string]struct{}, len(values))
for _, v := range values {
wanted[strings.ToLower(v)] = struct{}{}
}
prefix := strings.ToLower(f.Property) + "/"
for k, val := range fields {
if len(k) <= len(prefix) || !strings.EqualFold(k[:len(prefix)], prefix) {
continue
}
if _, ok := wanted[strings.ToLower(k[len(prefix):])]; ok && isTruthyMember(val) {
return true
}
}
// Flat shape: the field's own value, split on `,` for csv-set fields.
fieldValue, ok := fields[f.Property]
if !ok {
return false
}
for _, piece := range strings.Split(fieldValue, ",") {
piece = strings.TrimSpace(piece)
if piece == "" {
continue
}
for _, v := range values {
if f.Exact {
if strings.EqualFold(piece, v) {
return true
}
} else if strings.Contains(strings.ToLower(piece), strings.ToLower(v)) {
return true
}
}
}
return false
}
// isTruthyMember reports whether a key-set member's value asserts membership.
// Mirrors the gateway's `is_truthy_member`: presence with a non-empty,
// non-"false"/"0" value.
func isTruthyMember(v string) bool {
v = strings.TrimSpace(strings.ToLower(v))
return v != "" && v != "false" && v != "0"
}
+40 -2
View File
@@ -42,8 +42,11 @@ type Server struct {
schemaIndexer *schema.Indexer schemaIndexer *schema.Indexer
webdavHandler *webdav.Handler webdavHandler *webdav.Handler
search *searchIndex search *searchIndex
router *mux.Router // challenges backs proof-of-possession on reveal/delete. In-process, so a
server *http.Server // restart invalidates the ones in flight — see identity/challenge.go.
challenges *identity.ChallengeStore
router *mux.Router
server *http.Server
} }
// NewServer creates a new API server // NewServer creates a new API server
@@ -139,6 +142,8 @@ func NewServer(
// Initialize WebDAV handler (caching is handled by nginx proxy_cache) // Initialize WebDAV handler (caching is handled by nginx proxy_cache)
s.webdavHandler = webdav.NewHandler(cfg) s.webdavHandler = webdav.NewHandler(cfg)
s.challenges = identity.NewChallengeStore()
// One-time migration: fold a pre-multi-account identity.json into the // One-time migration: fold a pre-multi-account identity.json into the
// per-account keystore so existing single-identity hosts keep their uid. // per-account keystore so existing single-identity hosts keep their uid.
if migrated, err := identity.MigrateLegacy(cfg.IdentityFilePath(), cfg.IdentityAccountsDir()); err != nil { if migrated, err := identity.MigrateLegacy(cfg.IdentityFilePath(), cfg.IdentityAccountsDir()); err != nil {
@@ -170,11 +175,13 @@ func (s *Server) setupRoutes() {
// at the perimeter — do NOT add to nginx-hash-lock unauth bypass. // at the perimeter — do NOT add to nginx-hash-lock unauth bypass.
s.router.HandleFunc("/api/identity", s.handleIdentityGet).Methods("GET") s.router.HandleFunc("/api/identity", s.handleIdentityGet).Methods("GET")
s.router.HandleFunc("/api/identity/accounts", s.handleIdentityAccounts).Methods("GET") s.router.HandleFunc("/api/identity/accounts", s.handleIdentityAccounts).Methods("GET")
s.router.HandleFunc("/api/identity/challenge", s.handleIdentityChallenge).Methods("POST")
s.router.HandleFunc("/api/identity/generate", s.handleIdentityGenerate).Methods("POST") s.router.HandleFunc("/api/identity/generate", s.handleIdentityGenerate).Methods("POST")
s.router.HandleFunc("/api/identity/import", s.handleIdentityImport).Methods("POST") s.router.HandleFunc("/api/identity/import", s.handleIdentityImport).Methods("POST")
s.router.HandleFunc("/api/identity/reveal", s.handleIdentityReveal).Methods("POST") s.router.HandleFunc("/api/identity/reveal", s.handleIdentityReveal).Methods("POST")
s.router.HandleFunc("/api/identity", s.handleIdentityDelete).Methods("DELETE") s.router.HandleFunc("/api/identity", s.handleIdentityDelete).Methods("DELETE")
s.router.HandleFunc("/api/identity/sign", s.handleIdentitySign).Methods("POST") s.router.HandleFunc("/api/identity/sign", s.handleIdentitySign).Methods("POST")
s.router.HandleFunc("/api/identity/sign-batch", s.handleIdentitySignBatch).Methods("POST")
s.router.HandleFunc("/api/identity/aead-key", s.handleIdentityAEADKey).Methods("GET") s.router.HandleFunc("/api/identity/aead-key", s.handleIdentityAEADKey).Methods("GET")
// Snapshot (export / import / wipe) // Snapshot (export / import / wipe)
@@ -206,10 +213,12 @@ func (s *Server) setupRoutes() {
// Continue Watching, ratings, profile). See internal/api/udl.go. // Continue Watching, ratings, profile). See internal/api/udl.go.
s.router.HandleFunc("/api/udl/record", s.handleUDLRecordGet).Methods("GET") s.router.HandleFunc("/api/udl/record", s.handleUDLRecordGet).Methods("GET")
s.router.HandleFunc("/api/udl/record", s.handleUDLRecordPut).Methods("PUT") s.router.HandleFunc("/api/udl/record", s.handleUDLRecordPut).Methods("PUT")
s.router.HandleFunc("/api/udl/records", s.handleUDLRecordsPut).Methods("PUT")
s.router.HandleFunc("/api/udl/user/{uid}/key/{key}", s.handleUDLUserKey).Methods("GET") s.router.HandleFunc("/api/udl/user/{uid}/key/{key}", s.handleUDLUserKey).Methods("GET")
s.router.HandleFunc("/api/udl/user/{uid}/cid/{cid}", s.handleUDLUserCid).Methods("GET") s.router.HandleFunc("/api/udl/user/{uid}/cid/{cid}", s.handleUDLUserCid).Methods("GET")
s.router.HandleFunc("/api/udl/cid/{cid}/users", s.handleUDLCidUsers).Methods("GET") s.router.HandleFunc("/api/udl/cid/{cid}/users", s.handleUDLCidUsers).Methods("GET")
s.router.HandleFunc("/api/udl/cid/{cid}/aggregate", s.handleUDLAggregate).Methods("GET") s.router.HandleFunc("/api/udl/cid/{cid}/aggregate", s.handleUDLAggregate).Methods("GET")
s.router.HandleFunc("/api/udl/users/stats", s.handleUDLUserStats).Methods("GET")
// Metadata operations - base endpoints // Metadata operations - base endpoints
s.router.HandleFunc("/meta/{hash}", s.handleGetMeta).Methods("GET") s.router.HandleFunc("/meta/{hash}", s.handleGetMeta).Methods("GET")
@@ -234,6 +243,14 @@ func (s *Server) setupRoutes() {
s.router.HandleFunc("/api/file/{cid}/info", s.handleGetFileByCIDInfo).Methods("GET") s.router.HandleFunc("/api/file/{cid}/info", s.handleGetFileByCIDInfo).Methods("GET")
s.router.HandleFunc("/file/{cid}", s.handleGetFileByCID).Methods("GET") s.router.HandleFunc("/file/{cid}", s.handleGetFileByCID).Methods("GET")
s.router.HandleFunc("/file/cid", s.handleComputeFileCID).Methods("POST") s.router.HandleFunc("/file/cid", s.handleComputeFileCID).Methods("POST")
// Register one file under /files/plugin by midhash — the watcher's
// mint + alias + file:events path for a single file. Registered
// unconditionally: it exists for boxes whose watcher is OFF.
s.router.HandleFunc("/api/files/register", s.handleRegisterFile).Methods("POST")
// File-backed roots (filePath+sizeByte+mtimeNano) with sizes, batched
// server-side, so clients answer "how many files, how big" without one
// read per hashId. ?summary=1 returns only {count, totalSize}.
s.router.HandleFunc("/api/files/tuples", s.handleGetFileTuples).Methods("GET")
// Public metadata-by-CID resolver. Reverse-index lookup → full document. // Public metadata-by-CID resolver. Reverse-index lookup → full document.
// Auth-bypassed alongside /api/file/{cid} so meta-share peers can query // Auth-bypassed alongside /api/file/{cid} so meta-share peers can query
@@ -244,6 +261,12 @@ func (s *Server) setupRoutes() {
// (the dual-root pattern caused by historical writes that bypassed CID // (the dual-root pattern caused by historical writes that bypassed CID
// resolution). // resolution).
s.router.HandleFunc("/api/admin/migrate-dual-roots", s.handleMigrateDualRoots).Methods("POST") s.router.HandleFunc("/api/admin/migrate-dual-roots", s.handleMigrateDualRoots).Methods("POST")
// One-shot vocabulary sweep: domain film|tv → screen, workForm backfill
// (METADATA_KEYS.md §14.17). Idempotent.
s.router.HandleFunc("/api/admin/migrate-domain-screen", s.handleMigrateDomainScreen).Methods("POST")
// One-shot sweep of query echoes on literature cards (meta-read
// reading-model.md §7). Dry-run unless ?apply=true. Idempotent.
s.router.HandleFunc("/api/admin/sweep-literature-echoes", s.handleSweepLiteratureEchoes).Methods("POST")
// SSE event streams. Mediated mirror of the Redis Streams; consumers // SSE event streams. Mediated mirror of the Redis Streams; consumers
// reach them over HTTP instead of touching Redis directly. Auth-bypass // reach them over HTTP instead of touching Redis directly. Auth-bypass
@@ -351,6 +374,21 @@ func (s *Server) Start() error {
// Republish events for all existing metadata // Republish events for all existing metadata
go func() { go func() {
// Build any missing per-record field indexes first, in ONE
// keyspace pass. The republish below reads every record, and
// an un-indexed record read costs a full-keyspace SCAN — on a
// real store that walk warmed at ~1.3 records/s (a ~2.6h tail,
// paid again on every restart). Warming first turns the whole
// republish into index-served reads. Idempotent, so a
// fully-indexed store just pays the single scan.
// See storage/field_index.go.
started := time.Now()
if n, err := s.storage.WarmFieldIndexes(); err != nil {
log.Printf("[API] Warning: field index warm failed: %v (reads fall back to SCAN)", err)
} else if n > 0 {
log.Printf("[API] Warmed %d field indexes in %s", n, time.Since(started).Round(time.Millisecond))
}
count, err := s.RepublishMetadata() count, err := s.RepublishMetadata()
if err != nil { if err != nil {
log.Printf("[API] Warning: failed to republish metadata on startup: %v", err) log.Printf("[API] Warning: failed to republish metadata on startup: %v", err)
+148 -3
View File
@@ -16,6 +16,7 @@ import (
"strings" "strings"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/metazla/meta-core/internal/storage"
) )
// UDLPutRequest is the body of PUT /api/udl/record. // UDLPutRequest is the body of PUT /api/udl/record.
@@ -27,6 +28,14 @@ type UDLPutRequest struct {
Ts int64 `json:"ts"` Ts int64 `json:"ts"`
Record string `json:"record"` // base64 CBOR, opaque + signed Record string `json:"record"` // base64 CBOR, opaque + signed
Value *json.RawMessage `json:"value,omitempty"` Value *json.RawMessage `json:"value,omitempty"`
// Tombstone marks this write as a deletion of (uid, cid, key). The record
// itself is opaque here — meta-core never decodes the CBOR, and a private-
// tier value and a tombstone are equally "no plaintext value" — so the
// writer has to say. Without it the cid is never removed from
// udl:idx:user:<uid>:key:<key>, which then grows for the life of the
// account and is returned in full on every My List / Continue Watching
// read. Omitted by older writers, which keeps the previous behaviour.
Tombstone bool `json:"tombstone,omitempty"`
} }
// UDLPutResponse is the response of PUT /api/udl/record. // UDLPutResponse is the response of PUT /api/udl/record.
@@ -34,12 +43,50 @@ type UDLPutResponse struct {
Accepted bool `json:"accepted"` Accepted bool `json:"accepted"`
} }
// UDLPutBatchRequest is the body of PUT /api/udl/records — many cells, one
// request. Each entry is exactly a UDLPutRequest, so a client can build the
// same signed record for either endpoint.
type UDLPutBatchRequest struct {
Records []UDLPutRequest `json:"records"`
}
// UDLPutBatchResult is one cell's outcome. Accepted=false with an empty Error
// means the write was stale (the single-record endpoint's 409), which in a
// batch is an ordinary outcome rather than a failure.
type UDLPutBatchResult struct {
Key string `json:"key"`
Accepted bool `json:"accepted"`
Error string `json:"error,omitempty"`
}
// UDLPutBatchResponse mirrors the request order index-for-index.
type UDLPutBatchResponse struct {
Results []UDLPutBatchResult `json:"results"`
}
// maxUDLBatch bounds one batch. Each record is a Redis round trip while the
// request is held open; sized to match the sign batch on the other side of the
// same operation (see maxSignBatch).
const maxUDLBatch = 1000
// UDLRecordResponse is the response of GET /api/udl/record. // UDLRecordResponse is the response of GET /api/udl/record.
type UDLRecordResponse struct { type UDLRecordResponse struct {
Record string `json:"record"` Record string `json:"record"`
Version int64 `json:"version"` Version int64 `json:"version"`
Ts int64 `json:"ts"` Ts int64 `json:"ts"`
Exists bool `json:"exists"` Exists bool `json:"exists"`
// Value is the plaintext JSON scalar stored beside the opaque record, and
// is present only for PUBLIC-tier keys (like, rating, profile:name,
// profile:avatar). json.RawMessage rather than string because the field is
// generic over keys — a bool for `like`, a number for `rating`, a string
// for a profile name (see aggregatePublicValues) — and because it is the
// exact type UDLPutRequest.Value uses, so a value written through PUT reads
// back byte-identical.
//
// Absent, never null, when there is no plaintext: private-tier cells,
// tombstones and never-written cells must not read as an empty public
// value. Readers key off presence, not `exists`.
Value *json.RawMessage `json:"value,omitempty"`
} }
// UDLAggregate mirrors meta-watch's Aggregate wire shape exactly (snake_case // UDLAggregate mirrors meta-watch's Aggregate wire shape exactly (snake_case
@@ -71,12 +118,20 @@ func (s *Server) handleUDLRecordGet(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusInternalServerError, err.Error()) writeError(w, http.StatusInternalServerError, err.Error())
return return
} }
writeJSON(w, http.StatusOK, UDLRecordResponse{ resp := UDLRecordResponse{
Record: cell.Record, Record: cell.Record,
Version: cell.Version, Version: cell.Version,
Ts: cell.Ts, Ts: cell.Ts,
Exists: exists, Exists: exists,
}) }
// json.Valid guards the encoder: a malformed `value` hash field (hand-edited
// Redis, a future writer bug) would otherwise fail mid-encode and emit a
// truncated body instead of a readable error.
if exists && cell.HasValue && json.Valid([]byte(cell.Value)) {
raw := json.RawMessage(cell.Value)
resp.Value = &raw
}
writeJSON(w, http.StatusOK, resp)
} }
// handleUDLRecordPut handles PUT /api/udl/record. Version-gated: a stale write // handleUDLRecordPut handles PUT /api/udl/record. Version-gated: a stale write
@@ -104,7 +159,7 @@ func (s *Server) handleUDLRecordPut(w http.ResponseWriter, r *http.Request) {
hasValue = true hasValue = true
} }
accepted, err := s.storage.UDLUpsertIfNewer(req.Uid, req.Cid, req.Key, req.Version, req.Ts, req.Record, publicValue, hasValue) accepted, err := s.storage.UDLUpsertIfNewer(req.Uid, req.Cid, req.Key, req.Version, req.Ts, req.Record, publicValue, hasValue, req.Tombstone)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) writeError(w, http.StatusInternalServerError, err.Error())
return return
@@ -116,6 +171,64 @@ func (s *Server) handleUDLRecordPut(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, UDLPutResponse{Accepted: true}) writeJSON(w, http.StatusOK, UDLPutResponse{Accepted: true})
} }
// handleUDLRecordsPut handles PUT /api/udl/records — the batch form of
// handleUDLRecordPut.
//
// Exists for the bulk verbs above this layer ("mark this whole series seen"),
// where one user action produces one cell per episode. Each cell still goes
// through the same version-gated Lua upsert, so the CRDT semantics are
// identical — this batches the transport only.
//
// **Not transactional, and deliberately not 409.** A per-record `accepted` flag
// replaces the single-record conflict status: in a batch, one stale cell is a
// normal outcome (another device already marked that episode) and must not fail
// the 299 cells beside it. Callers read the array, not the status code.
func (s *Server) handleUDLRecordsPut(w http.ResponseWriter, r *http.Request) {
if !s.storage.IsConnected() {
writeError(w, http.StatusServiceUnavailable, "storage not connected")
return
}
var req UDLPutBatchRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
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 := make([]UDLPutBatchResult, 0, len(req.Records))
for _, rec := range req.Records {
if rec.Uid == "" || rec.Cid == "" || rec.Key == "" || rec.Record == "" {
results = append(results, UDLPutBatchResult{
Key: rec.Key,
Error: "uid, cid, key and record are required",
})
continue
}
publicValue := ""
hasValue := false
if rec.Value != nil {
publicValue = string(*rec.Value)
hasValue = true
}
accepted, err := s.storage.UDLUpsertIfNewer(rec.Uid, rec.Cid, rec.Key, rec.Version, rec.Ts, rec.Record, publicValue, hasValue, rec.Tombstone)
if err != nil {
// A storage error is per-record here rather than fatal for the whole
// batch: the cells already written stay written, and reporting which
// ones failed lets the caller retry exactly those.
results = append(results, UDLPutBatchResult{Key: rec.Key, Error: err.Error()})
continue
}
results = append(results, UDLPutBatchResult{Key: rec.Key, Accepted: accepted})
}
writeJSON(w, http.StatusOK, UDLPutBatchResponse{Results: results})
}
// handleUDLUserKey handles GET /api/udl/user/{uid}/key/{key} // handleUDLUserKey handles GET /api/udl/user/{uid}/key/{key}
// (active_for_user_key — My List, Continue Watching). // (active_for_user_key — My List, Continue Watching).
func (s *Server) handleUDLUserKey(w http.ResponseWriter, r *http.Request) { func (s *Server) handleUDLUserKey(w http.ResponseWriter, r *http.Request) {
@@ -237,3 +350,35 @@ func aggregatePublicValues(key string, values []string) UDLAggregate {
} }
return agg 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})
}
+226
View File
@@ -0,0 +1,226 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/alicebob/miniredis/v2"
"github.com/metazla/meta-core/internal/storage"
)
// newUDLTestServer returns a Server wired to an in-memory Redis.
//
// The struct is built directly rather than through NewServer: NewServer boots
// the file watcher, mounts manager, watchers poller and WebDAV handler, none of
// which the UDL handlers touch — they use s.storage and nothing else.
func newUDLTestServer(t *testing.T) *Server {
t.Helper()
mr := miniredis.RunT(t)
c := storage.NewClient("")
if err := c.Connect("redis://" + mr.Addr()); err != nil {
t.Fatalf("connect to miniredis: %v", err)
}
t.Cleanup(func() { _ = c.Close() })
return &Server{storage: c}
}
// getRecord drives handleUDLRecordGet and decodes the body into raw JSON
// fields, so a test can assert on the *encoding* of `value` (is it a quoted
// string? is the key there at all?) rather than on what a typed struct would
// have coerced it into.
func getRecord(t *testing.T, s *Server, uid, cid, key string) (int, map[string]json.RawMessage) {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/api/udl/record?uid="+uid+"&cid="+cid+"&key="+key, nil)
rr := httptest.NewRecorder()
s.handleUDLRecordGet(rr, req)
var body map[string]json.RawMessage
if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil {
t.Fatalf("decode response %q: %v", rr.Body.String(), err)
}
return rr.Code, body
}
// The plaintext value of a PUBLIC-tier cell must come back over the wire, and
// come back *as it was written* — a JSON string stays a quoted string, a bool
// stays a bool.
//
// Both halves matter. Without the value at all, the meta-core dashboard can
// only ever show 44-character base58 uids: display names live in the User Data
// Layer as the public cell (uid, "self", "profile:name"), and the plaintext
// sits in Redis where the storage layer has always read it — the handler just
// used to drop it. And the type has to stay json.RawMessage: this endpoint is
// generic over keys, so a `*string` would mangle `like` (bool) and `rating`
// (number) while looking correct for names.
func TestUDLRecordGet_ReturnsPublicValue(t *testing.T) {
s := newUDLTestServer(t)
const uid = "zAlice"
if ok, err := s.storage.UDLUpsertIfNewer(uid, "self", "profile:name", 1, 100, "cmVj", `"Ada Lovelace"`, true, false); err != nil || !ok {
t.Fatalf("seed name: ok=%v err=%v", ok, err)
}
if ok, err := s.storage.UDLUpsertIfNewer(uid, "bagcsaaa", "like", 1, 100, "cmVj", `true`, true, false); err != nil || !ok {
t.Fatalf("seed like: ok=%v err=%v", ok, err)
}
code, body := getRecord(t, s, uid, "self", "profile:name")
if code != http.StatusOK {
t.Fatalf("status = %d, want 200", code)
}
if got := string(body["value"]); got != `"Ada Lovelace"` {
t.Fatalf("value = %s, want a quoted JSON string", got)
}
var name string
if err := json.Unmarshal(body["value"], &name); err != nil || name != "Ada Lovelace" {
t.Fatalf("value should unmarshal to the plain name, got %q err=%v", name, err)
}
// A boolean key survives unquoted — the assertion a *string field fails.
if _, body = getRecord(t, s, uid, "bagcsaaa", "like"); string(body["value"]) != "true" {
t.Fatalf("like value = %s, want the bare JSON literal true", body["value"])
}
}
// A cell with no plaintext must omit `value` entirely rather than send null.
//
// That is the shape of every private-tier key (seek, watched, card, localpath —
// their real value rides encrypted inside the opaque record) and of every
// tombstone. A reader keys off presence: "there is no plaintext here" and "the
// plaintext is null" are different facts, and a name cleared by its owner
// leaves the cell existing with no value, which must not read as a name.
func TestUDLRecordGet_OmitsValueForPrivateCell(t *testing.T) {
s := newUDLTestServer(t)
const uid = "zBob"
if ok, err := s.storage.UDLUpsertIfNewer(uid, "bagcsaaa", "seek", 1, 100, "ZW5jcnlwdGVk", "", false, false); err != nil || !ok {
t.Fatalf("seed private cell: ok=%v err=%v", ok, err)
}
code, body := getRecord(t, s, uid, "bagcsaaa", "seek")
if code != http.StatusOK {
t.Fatalf("status = %d, want 200", code)
}
if _, ok := body["value"]; ok {
t.Fatalf("private cell must omit `value`, got %s", body["value"])
}
if string(body["exists"]) != "true" {
t.Fatalf("exists = %s, want true", body["exists"])
}
}
// A cell that was never written answers 200 with exists:false and no value —
// not a 404, and not an empty-string value that would render as a blank name.
func TestUDLRecordGet_MissingCellHasNoValue(t *testing.T) {
s := newUDLTestServer(t)
code, body := getRecord(t, s, "zNobody", "self", "profile:name")
if code != http.StatusOK {
t.Fatalf("status = %d, want 200", code)
}
if string(body["exists"]) != "false" {
t.Fatalf("exists = %s, want false", body["exists"])
}
if _, ok := body["value"]; ok {
t.Fatalf("missing cell must omit `value`, got %s", body["value"])
}
}
// putRecords drives handleUDLRecordsPut with a raw JSON body and decodes the
// per-record results.
func putRecords(t *testing.T, s *Server, body string) (int, UDLPutBatchResponse) {
t.Helper()
req := httptest.NewRequest(http.MethodPut, "/api/udl/records", strings.NewReader(body))
rr := httptest.NewRecorder()
s.handleUDLRecordsPut(rr, req)
var out UDLPutBatchResponse
if rr.Code == http.StatusOK {
if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil {
t.Fatalf("decode response %q: %v", rr.Body.String(), err)
}
}
return rr.Code, out
}
// **The load-bearing property of the batch endpoint: one stale cell does not
// fail the batch.**
//
// The single-record PUT answers a stale version with 409, which is right when
// the caller wrote one thing. In a batch it would be wrong: bulk verbs ("mark
// this whole series seen") routinely include episodes another device already
// marked, and 409-ing there would abandon every cell after it. So staleness is
// reported per record as accepted:false, with 200 on the request itself.
func TestUDLRecordsPut_StaleCellDoesNotFailTheBatch(t *testing.T) {
s := newUDLTestServer(t)
const uid = "zCarol"
const cid = "bagdsaaaseries"
// Seed s01e01 at version 4 — the batch below tries to write it at 2.
if ok, err := s.storage.UDLUpsertIfNewer(uid, cid, "seen:s01e01", 4, 100, "b2xk", "", false, false); err != nil || !ok {
t.Fatalf("seed: ok=%v err=%v", ok, err)
}
code, out := putRecords(t, s, `{"records":[
{"uid":"`+uid+`","cid":"`+cid+`","key":"seen:s01e01","version":2,"ts":200,"record":"c3RhbGU"},
{"uid":"`+uid+`","cid":"`+cid+`","key":"seen:s01e02","version":1,"ts":200,"record":"ZnJlc2g"}
]}`)
if code != http.StatusOK {
t.Fatalf("status = %d, want 200 — a stale cell is an outcome, not an error", code)
}
if len(out.Results) != 2 {
t.Fatalf("results = %d, want 2 (one per record, in order)", len(out.Results))
}
if out.Results[0].Accepted || out.Results[0].Error != "" {
t.Fatalf("stale cell = %+v, want accepted:false with no error", out.Results[0])
}
if !out.Results[1].Accepted {
t.Fatalf("fresh cell = %+v, want accepted:true", out.Results[1])
}
// The fresh cell really landed, and the stale one did not overwrite.
if _, body := getRecord(t, s, uid, cid, "seen:s01e02"); string(body["exists"]) != "true" {
t.Fatalf("s01e02 was not written")
}
if _, body := getRecord(t, s, uid, cid, "seen:s01e01"); string(body["version"]) != "4" {
t.Fatalf("s01e01 version = %s, want 4 (the stale write must not apply)", body["version"])
}
}
// A malformed record is reported in its own slot rather than rejecting the
// request, for the same reason: the caller retries that cell, not the batch.
func TestUDLRecordsPut_ReportsBadRecordPerSlot(t *testing.T) {
s := newUDLTestServer(t)
code, out := putRecords(t, s, `{"records":[
{"uid":"zDan","cid":"bagdsaaa","key":"","version":1,"ts":1,"record":"eA"},
{"uid":"zDan","cid":"bagdsaaa","key":"seen:s01e01","version":1,"ts":1,"record":"eA"}
]}`)
if code != http.StatusOK {
t.Fatalf("status = %d, want 200", code)
}
if out.Results[0].Error == "" {
t.Fatalf("record with no key must report an error, got %+v", out.Results[0])
}
if !out.Results[1].Accepted {
t.Fatalf("the valid record beside it must still land, got %+v", out.Results[1])
}
}
// An empty batch is a client bug, not a no-op: it almost always means the
// caller built its record list wrong, and answering 200 with an empty array
// would hide that.
func TestUDLRecordsPut_RejectsEmptyBatch(t *testing.T) {
s := newUDLTestServer(t)
if code, _ := putRecords(t, s, `{"records":[]}`); code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", code)
}
}
+92
View File
@@ -0,0 +1,92 @@
package cid
import (
"encoding/base32"
"encoding/binary"
"fmt"
"strings"
"unicode/utf8"
)
// DecodeCard recovers the (source, id) pair a card locator (CodeCard, 0x1007)
// wraps. Wire form:
//
// 0x01 ‖ varint(0x1007) ‖ 0x00 (identity mh) ‖ varint(len(digest)) ‖ digest
// digest = varint(len(source)) ‖ source ‖ id (both UTF-8)
//
// Decode stops at the multihash code because ranking needs nothing past it;
// reading *which work* a card addresses needs the digest too, so this is the
// one place in Go that walks the whole thing.
//
// ⚠ Mirror of meta-read `card_cid::decode` and of meta-feeder-sdk
// `compute_card_cid`. The vectors in card_test.go are pinned literals copied
// from meta-read, not round-trips, so a drift on either side fails a test
// instead of silently reading a different work out of the same CID.
//
// Identity multihash only: a 0x1007 CID carrying any other multihash is not
// something anyone mints, and reading its digest as (source, id) would be
// reading hash bytes as text. Bytes past the declared digest length are
// ignored, as meta-read does.
func DecodeCard(cidStr string) (source, id string, err error) {
if len(cidStr) < 2 || cidStr[0] != 'b' {
return "", "", fmt.Errorf("cid %q: not a multibase base32 CIDv1", cidStr)
}
raw, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(strings.ToUpper(cidStr[1:]))
if err != nil {
return "", "", fmt.Errorf("cid %q: base32 decode: %w", cidStr, err)
}
next := func(buf []byte, what string) (uint64, []byte, error) {
v, n := binary.Uvarint(buf)
if n <= 0 {
return 0, nil, fmt.Errorf("cid %q: bad %s varint", cidStr, what)
}
return v, buf[n:], nil
}
version, raw, err := next(raw, "version")
if err != nil {
return "", "", err
}
if version != 1 {
return "", "", fmt.Errorf("cid %q: unsupported CID version %d", cidStr, version)
}
codec, raw, err := next(raw, "codec")
if err != nil {
return "", "", err
}
if codec != CodeCard {
return "", "", fmt.Errorf("cid %q: codec 0x%x is not a card locator", cidStr, codec)
}
mh, raw, err := next(raw, "multihash-code")
if err != nil {
return "", "", err
}
if mh != 0 {
return "", "", fmt.Errorf("cid %q: card locator with non-identity multihash 0x%x", cidStr, mh)
}
digestLen, raw, err := next(raw, "digest-length")
if err != nil {
return "", "", err
}
if digestLen > uint64(len(raw)) {
return "", "", fmt.Errorf("cid %q: digest truncated", cidStr)
}
digest := raw[:digestLen]
sourceLen, rest, err := next(digest, "source-length")
if err != nil {
return "", "", err
}
if sourceLen > uint64(len(rest)) {
return "", "", fmt.Errorf("cid %q: source truncated", cidStr)
}
source, id = string(rest[:sourceLen]), string(rest[sourceLen:])
if source == "" || id == "" {
return "", "", fmt.Errorf("cid %q: empty source or id", cidStr)
}
if !utf8.ValidString(source) || !utf8.ValidString(id) {
return "", "", fmt.Errorf("cid %q: source or id is not UTF-8", cidStr)
}
return source, id, nil
}
+71
View File
@@ -0,0 +1,71 @@
package cid
import (
"encoding/base32"
"encoding/binary"
"strings"
"testing"
)
// Pinned from meta-read `src/card_cid.rs` / meta-feeder-sdk `compute_card_cid`
// — literals, not round-trips (see DecodeCard).
const (
goldenAnilistCard = "bagdsaaaua5qw42lmnfzxi3lbnztwcorrga2tonzy"
goldenMangadexCard = "bagdsaabybrwwc3thmfsgk6bon5zgol3nmfxgoyjpmiygenzsgftgmlldgm4dqljugq4dmllbmeygmlldgjrdaytcgmzdcnjrgi"
)
// wireCID builds a CIDv1 from raw parts so the negative cases below can vary
// one field at a time.
func wireCID(codec, mh uint64, digest []byte) string {
buf := []byte{0x01}
buf = binary.AppendUvarint(buf, codec)
buf = binary.AppendUvarint(buf, mh)
buf = binary.AppendUvarint(buf, uint64(len(digest)))
buf = append(buf, digest...)
return "b" + strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(buf))
}
func cardDigest(source, id string) []byte {
d := binary.AppendUvarint(nil, uint64(len(source)))
d = append(d, source...)
return append(d, id...)
}
func TestDecodeCard_GoldenVectors(t *testing.T) {
for _, tc := range []struct {
cid, source, id string
}{
{goldenAnilistCard, "anilist", "manga:105778"},
{goldenMangadexCard, "mangadex.org", "/manga/b0b721ff-c388-4486-aa0f-c2b0bb321512"},
} {
source, id, err := DecodeCard(tc.cid)
if err != nil {
t.Fatalf("DecodeCard(%s): %v", tc.cid, err)
}
if source != tc.source || id != tc.id {
t.Errorf("DecodeCard(%s) = (%q, %q), want (%q, %q)", tc.cid, source, id, tc.source, tc.id)
}
// The test encoder must agree with the pinned literal, or the negative
// cases below are testing a different wire format.
if got := wireCID(CodeCard, 0, cardDigest(tc.source, tc.id)); got != tc.cid {
t.Errorf("wireCID(%q, %q) = %s, want %s", tc.source, tc.id, got, tc.cid)
}
}
}
func TestDecodeCard_RejectsEverythingElse(t *testing.T) {
for name, s := range map[string]string{
"url locator codec": wireCID(CodeURL, 0, cardDigest("anilist", "1")),
"non-identity multihash": wireCID(CodeCard, CodeSha256, cardDigest("anilist", "1")),
"empty id": wireCID(CodeCard, 0, cardDigest("anilist", "")),
"source past digest": wireCID(CodeCard, 0, []byte{0x20, 'a'}),
"not a CID": "title:berserk",
"CIDv0": "QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG",
"truncated": goldenAnilistCard[:12],
"empty": "",
} {
if source, id, err := DecodeCard(s); err == nil {
t.Errorf("%s: DecodeCard(%q) = (%q, %q), want error", name, s, source, id)
}
}
}
+19 -1
View File
@@ -22,7 +22,7 @@
"25": "nzb-posting - content-derived (Message-ID set digest), but not itself fetchable", "25": "nzb-posting - content-derived (Message-ID set digest), but not itself fetchable",
"20": "BitTorrent infohashes - swarm interop", "20": "BitTorrent infohashes - swarm interop",
"10": "midhash256 - the record address", "10": "midhash256 - the record address",
"5": "opaque locators (nzb-release, url, card) - never outrank a real digest", "5": "opaque locators (nzb-release, url, card, yt-video, ext-play) - never outrank a real digest",
"0": "weak/unknown digests, unparseable input" "0": "weak/unknown digests, unparseable input"
}, },
"tieBreak": "lexicographically smaller cid wins", "tieBreak": "lexicographically smaller cid wins",
@@ -116,6 +116,24 @@
"rank": 5, "rank": 5,
"note": "codec 0x1007 + IDENTITY multihash 0x00, digest = varint(len(source)) || source || id — here (\"tmdb\",\"tv:95479\"). Same codec-slot rule as nzb-release/url: match on the CODEC, never the mh. The limit case of the family — the only CID in MetaMesh with NO bytes behind it anywhere, ever: it addresses a *work* (a series, a film), not a file, so the byte path 404s by design and bitswap never offers it (seeding is gated on sha2-256). Tier 5 is load-bearing for the planned cross-source merge: one card record may carry BOTH a TMDB and a MyAnimeList locator, and if a real card body is ever added (see card-tier-search.md §4.3) its sha2-256 must win the canonical election over both locators without any addressing change. Ranking it 0 instead would still lose to a digest, but would put the implementations back out of agreement — which is the exact failure this fixture exists to prevent." "note": "codec 0x1007 + IDENTITY multihash 0x00, digest = varint(len(source)) || source || id — here (\"tmdb\",\"tv:95479\"). Same codec-slot rule as nzb-release/url: match on the CODEC, never the mh. The limit case of the family — the only CID in MetaMesh with NO bytes behind it anywhere, ever: it addresses a *work* (a series, a film), not a file, so the byte path 404s by design and bitswap never offers it (seeding is gated on sha2-256). Tier 5 is load-bearing for the planned cross-source merge: one card record may carry BOTH a TMDB and a MyAnimeList locator, and if a real card body is ever added (see card-tier-search.md §4.3) its sha2-256 must win the canonical election over both locators without any addressing change. Ranking it 0 instead would still lose to a digest, but would put the implementations back out of agreement — which is the exact failure this fixture exists to prevent."
}, },
{
"name": "yt-video-locator",
"cid": "bagecaaarav3gszdfn42ein3vgvfumn2tka4a",
"rank": 5,
"note": "codec 0x1008 + IDENTITY multihash 0x00, digest = varint(len(kind)) || kind || id — here (\"video\",\"4D7u5KF7SP8\"). DELEGATED PLAYBACK: the limit case beyond card. A card addresses a work with no bytes anywhere; this addresses a *rendition* whose bytes exist but are permanently someone else's — an external player renders it, nothing is transported, seeded or content-addressed. Framing is byte-identical to card (0x1007) on purpose, so each implementation extends the parser it already wrote. Same codec-slot rule: match on the CODEC, never the mh (identity 0x00 would fall through and score 0). Tier 5 is load-bearing for the on-ramp: a delegated reference and a real archived copy can sit on ONE record, and that sha2-256 must win the canonical election over this locator with no addressing change. NOTE the byte path must REFUSE this (400), not 404 — see docs/cid-formats.md §7.4."
},
{
"name": "ext-play-locator",
"cid": "bagesaabenb2hi4dthixs6zlymfwxa3dffzrgc3temnqw24bomnxw2l3bnrrhk3jppa",
"rank": 5,
"note": "codec 0x1009 + IDENTITY multihash 0x00, digest = the verbatim UTF-8 URL. WIRE-IDENTICAL to url (0x1006) apart from the codec, and that difference is the entire contract: 0x1006 means 'fetch these bytes once and seed them', 0x1009 means 'open this in a browser, there are no bytes for us'. A decoder that matched on SHAPE rather than on the codec slot would see an identity multihash wrapping an http(s) URL, take the 0x1006 path, fetch a watch/Bandcamp page, chunk it, seed the HTML and link that content CID onto the record AS THE FILE — the MSR1 poisoning shape, except permanent and replicated. Same tier/reasoning as the rest of the locator family."
},
{
"name": "provider-file-locator",
"cid": "bagfcaaa2bvxxazloon2we5djorwgk43gnfwgkorxga3dcobtgq",
"rank": 5,
"note": "codec 0x100A + IDENTITY multihash 0x00, digest = varint(len(source)) || source || id - here (\"opensubtitles\",\"file:7061834\"). The GENERIC fetchable locator: one codec for every provider whose bytes are fetched the same way; the provider is inside the digest. Framing byte-identical to card (0x1007), but the byte path RESOLVES it (via source's fetch tier) instead of 404ing. Same codec-slot rule as the rest of the family: match on the CODEC, never the mh (identity 0x00 would fall through and score 0). Tier 5: once the fetched bytes' sha2-256 is linked onto the record it wins the canonical election with no addressing change."
},
{ {
"name": "sha1", "name": "sha1",
"cid": "baeircff3eqvxxaqqot2lt242stkdtk3gq4c2vra", "cid": "baeircff3eqvxxaqqot2lt242stkdtk3gq4c2vra",
+27 -1
View File
@@ -58,6 +58,31 @@ const (
// one record, and a real digest added later still wins the election. // one record, and a real digest added later still wins the election.
CodeCard = 0x1007 // custom — identity-multihash CID wrapping a card (work) id CodeCard = 0x1007 // custom — identity-multihash CID wrapping a card (work) id
// CodeYtVideo and CodeExtPlay are the DELEGATED-PLAYBACK locators — the
// limit case beyond CodeCard. A card addresses a work with no bytes
// anywhere; these address a *rendition* whose bytes exist but are
// permanently someone else's: an external player renders the media, nothing
// is transported, seeded or content-addressed.
//
// They rank on the CODEC like the rest of the family — their multihash is
// identity (0x00), so a rank that dispatched on the multihash would see 0x00,
// fall through and silently score 0. That is the exact drift that once made
// meta-share and meta-search elect different canonical CIDs for one record.
//
// Tier 5 is also what keeps the on-ramp safe: a delegated reference and a
// real CID can sit on one record, and if the media is ever archived to the
// mesh its sha2-256 wins the canonical election with no addressing change.
CodeYtVideo = 0x1008 // custom — identity-multihash CID wrapping a YouTube (kind, id)
CodeExtPlay = 0x1009 // custom — identity-multihash CID wrapping a URL to OPEN, never fetch
// CodeProviderFile is the FETCHABLE twin of CodeExtPlay: a provider-held
// file addressed as varint(len(source))‖source‖id — ("opensubtitles",
// "file:7061834"). Framing is byte-identical to CodeCard; only the byte
// path differs (it redeems the id through that source's feeder instead of
// 404ing). Same codec-slot rule and same tier: once the fetched bytes'
// sha2-256 is linked onto the record it wins with no addressing change.
CodeProviderFile = 0x100A // custom — identity-multihash CID wrapping a (source, id) provider file
// CodeNzbPosting is the INVERSE trap of the two above: its multihash is a // CodeNzbPosting is the INVERSE trap of the two above: its multihash is a
// REAL sha2-256 digest (over the normalised, sorted article Message-ID // REAL sha2-256 digest (over the normalised, sorted article Message-ID
// set of a Usenet posting we scanned ourselves — never a locator, no // set of a Usenet posting we scanned ourselves — never a locator, no
@@ -165,7 +190,8 @@ func Rank(cidStr string) int {
} }
// Opaque locators. Codec-only: their multihash is identity (0x00), so // Opaque locators. Codec-only: their multihash is identity (0x00), so
// there is nothing to match on the mh side. // there is nothing to match on the mh side.
if codec == CodeNzbRelease || codec == CodeURL || codec == CodeCard { if codec == CodeNzbRelease || codec == CodeURL || codec == CodeCard ||
codec == CodeYtVideo || codec == CodeExtPlay || codec == CodeProviderFile {
return RankLocator return RankLocator
} }
// nzb-posting: codec-only, checked BEFORE the mh switch below — its // nzb-posting: codec-only, checked BEFORE the mh switch below — its
+10
View File
@@ -9,6 +9,8 @@ import (
"time" "time"
"github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9"
"github.com/metazla/meta-core/internal/storage"
) )
const ( const (
@@ -126,6 +128,14 @@ func (p *MetaPublisher) publishEvent(channel, operation string) {
if strings.Contains(key, "__index__") || !strings.HasPrefix(key, "file:") { if strings.Contains(key, "__index__") || !strings.HasPrefix(key, "file:") {
return return
} }
// Skip the per-record field index. It is bookkeeping (see
// storage.FieldsField), not a metadata field — publishing it would make
// every downstream consumer that parses `file:<id>/<field>` off this
// stream (meta-search, meta-dup, meta-fuse, meta-sort) see a phantom
// field change on a key they can never read.
if strings.HasSuffix(key, "/"+storage.FieldsField) {
return
}
// Build event fields // Build event fields
fields := map[string]interface{}{ fields := map[string]interface{}{
+126
View File
@@ -0,0 +1,126 @@
package identity
// In-memory, single-use challenge store backing proof-of-possession on the
// account operations that need it (reveal, delete).
//
// In memory on purpose: a challenge is worthless once redeemed and worthless
// once expired, so persisting it would only widen the window in which a leaked
// one is useful. A meta-core restart invalidates every challenge in flight,
// which costs a client one retry.
//
// Mirrors the sign-in challenge in meta-watch's src/auth.rs, down to the TTL —
// the browser signs immediately, so seconds is the right order and a narrow
// window bounds replay if one ever surfaces in a proxy log.
import (
"errors"
"sync"
"time"
)
// ChallengeTTL is how long an issued challenge stays answerable.
const ChallengeTTL = 120 * time.Second
// maxChallenges caps the store. An unauthenticated caller can mint challenges
// for any uid it can name, so the map is attacker-growable; without a ceiling
// that is a memory-exhaustion lever. At the cap, issuing fails rather than
// evicting: dropping someone else's live challenge on demand would let an
// attacker deny sign-outs, which is worse than making them retry.
const maxChallenges = 4096
var (
// ErrChallengeUnknown covers absent, already-redeemed and expired alike.
// One error for all three deliberately: distinguishing them tells a prober
// whether a challenge string was ever real.
ErrChallengeUnknown = errors.New("challenge is unknown, already used, or expired")
ErrChallengeFull = errors.New("too many challenges in flight; try again shortly")
)
type challengeEntry struct {
uid string
action Action
expires time.Time
}
// ChallengeStore issues and redeems challenges. Safe for concurrent use.
type ChallengeStore struct {
mu sync.Mutex
entries map[string]challengeEntry
ttl time.Duration
// now is swappable so the tests can age entries without sleeping.
now func() time.Time
}
func NewChallengeStore() *ChallengeStore {
return &ChallengeStore{
entries: make(map[string]challengeEntry),
ttl: ChallengeTTL,
now: time.Now,
}
}
// Issue mints a challenge for (uid, action) and remembers it until redeemed or
// expired. Returns the literal text the client signs and its expiry.
func (s *ChallengeStore) Issue(uid string, action Action) (string, time.Time, error) {
if !validUID(uid) {
return "", time.Time{}, errors.New("invalid uid")
}
if !ValidAction(action) {
return "", time.Time{}, errors.New("unknown action")
}
text, err := NewChallengeText(uid, action)
if err != nil {
return "", time.Time{}, err
}
s.mu.Lock()
defer s.mu.Unlock()
s.sweepLocked()
if len(s.entries) >= maxChallenges {
return "", time.Time{}, ErrChallengeFull
}
expires := s.now().Add(s.ttl)
s.entries[text] = challengeEntry{uid: uid, action: action, expires: expires}
return text, expires, nil
}
// Redeem consumes a challenge, asserting it was issued for this exact uid and
// action. Single-use: the entry is dropped on any lookup that finds it, so a
// mismatched redemption burns the challenge rather than leaving it to be
// retried against a different account.
func (s *ChallengeStore) Redeem(text, uid string, action Action) error {
s.mu.Lock()
defer s.mu.Unlock()
entry, ok := s.entries[text]
if !ok {
return ErrChallengeUnknown
}
delete(s.entries, text)
if s.now().After(entry.expires) {
return ErrChallengeUnknown
}
if entry.uid != uid || entry.action != action {
return ErrChallengeUnknown
}
// Re-read the signed bytes rather than trusting the map key alone: the
// signature covers `text`, so `text` is what has to name this uid+action.
if !ChallengeMatches(text, uid, action) {
return ErrChallengeUnknown
}
return nil
}
// sweepLocked drops expired entries. Caller holds the lock.
//
// Called only from Issue: with no background goroutine, expiry is enforced
// lazily on the one path that grows the map, which is exactly where the bound
// matters.
func (s *ChallengeStore) sweepLocked() {
now := s.now()
for text, e := range s.entries {
if now.After(e.expires) {
delete(s.entries, text)
}
}
}
+43
View File
@@ -0,0 +1,43 @@
package identity
import (
"encoding/base64"
"testing"
)
// A real signature produced by @noble/secp256k1 v3 — the library the dashboard
// and meta-watch both sign with — pinned as a fixture and verified here.
//
// This is the one seam neither side's own tests can cover: Go signs DER, the
// browser signs 64-byte compact, and every unit test above verifies signatures
// this package produced itself. If the encodings ever disagree, the gate fails
// closed in production (nobody can delete their own account) while every other
// test stays green. Regenerate with:
//
// node -e 'import("@noble/secp256k1").then(async s => { … })'
//
// against the same fixed scalar (32 zero bytes with 42 in the last).
func TestVerify_AcceptsARealNobleV3Signature(t *testing.T) {
const (
uid = "ztbJ71eLvfmGXw2ydaSPHavfwjva5Bzadutw2tvYDVFVY"
challenge = "metamesh-identity-v1:delete:ztbJ71eLvfmGXw2ydaSPHavfwjva5Bzadutw2tvYDVFVY:deadbeef"
sigB64 = "lIiZGL0j9l/w2WXwzwe56cICnusuYdD3oaTuYfFTtNJBR9MolXFPk3014WQeAlvmbX4LfnW+TQz4asqoSwmSAQ=="
)
sig, err := base64.StdEncoding.DecodeString(sigB64)
if err != nil {
t.Fatalf("decode fixture: %v", err)
}
if len(sig) != 64 {
t.Fatalf("fixture is %d bytes, expected noble's 64-byte compact form", len(sig))
}
if err := Verify(uid, []byte(challenge), sig); err != nil {
t.Fatalf("a genuine browser signature must verify: %v", err)
}
// And the same signature must not verify over a different challenge — a
// verifier that ignored the payload would pass the line above too.
if err := Verify(uid, []byte(challenge+"x"), sig); err == nil {
t.Fatal("the fixture verified over the wrong payload")
}
}
+160
View File
@@ -0,0 +1,160 @@
package identity
// Signature verification against a uid, and the challenge vocabulary that
// makes a signature mean "I hold this account's private key" rather than
// "meta-core signed something for me".
//
// The asymmetry that motivates this file: meta-core *holds* every private key,
// so it can sign as anybody. A signature is therefore only proof of possession
// if the payload is one meta-core refuses to sign on request — hence
// ChallengeDomain, which POST /api/identity/sign rejects outright. Without that
// refusal an attacker mints their own authorisation in one extra call and the
// whole gate is decoration.
//
// Verification itself needs no stored state: a uid *is* the public key
// ("z" + base58btc(33-byte compressed sec1)), so the pubkey is recovered from
// the uid the caller names. Mirrors `verify_local` / `verify_local_compact` in
// meta-watch's src/record/sign.rs — same curve, same digest, both signature
// encodings.
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"errors"
"fmt"
"strings"
"github.com/decred/dcrd/dcrec/secp256k1/v4"
"github.com/decred/dcrd/dcrec/secp256k1/v4/ecdsa"
"github.com/mr-tron/base58"
)
// ChallengeDomain prefixes every byte string that authorises an account
// operation. POST /api/identity/sign and /sign-batch refuse payloads carrying
// it, so the only way to produce one is to hold the key yourself.
//
// Versioned because it is a wire commitment: changing it invalidates every
// challenge in flight, which is harmless, but changing it *silently* would let
// an old-domain payload keep working. Bump it deliberately.
const ChallengeDomain = "metamesh-identity-v1:"
// Action is the operation a challenge authorises. A challenge minted to reveal
// a key must not also delete the account, so the action is inside the signed
// bytes and is re-checked at redemption.
type Action string
const (
ActionReveal Action = "reveal"
ActionDelete Action = "delete"
)
// ValidAction reports whether a is an action a challenge may be issued for.
func ValidAction(a Action) bool {
return a == ActionReveal || a == ActionDelete
}
// ValidUID exposes the uid shape check to callers outside this package (the
// API layer, which must reject a hostile uid before it reaches a Redis SCAN
// pattern or a file path).
func ValidUID(uid string) bool { return validUID(uid) }
// IsChallengePayload reports whether b is (or claims to be) an account
// authorisation payload. Used by the signing endpoints to refuse minting one.
//
// Deliberately a prefix test on raw bytes rather than a parse: anything that
// merely *looks* like a challenge is refused, so a malformed or future-version
// payload fails closed instead of being signed.
func IsChallengePayload(b []byte) bool {
return strings.HasPrefix(string(b), ChallengeDomain)
}
// PubkeyFromUID recovers the compressed public key a uid encodes.
func PubkeyFromUID(uid string) (*secp256k1.PublicKey, error) {
if !validUID(uid) {
return nil, fmt.Errorf("invalid uid: %q", uid)
}
if !strings.HasPrefix(uid, "z") {
return nil, errors.New("uid is not multibase base58btc (missing 'z' prefix)")
}
raw, err := base58.Decode(uid[1:])
if err != nil {
return nil, fmt.Errorf("uid is not valid base58: %w", err)
}
if len(raw) != 33 {
return nil, fmt.Errorf("uid decodes to %d bytes, want a 33-byte compressed pubkey", len(raw))
}
pub, err := secp256k1.ParsePubKey(raw)
if err != nil {
return nil, fmt.Errorf("uid is not a point on secp256k1: %w", err)
}
return pub, nil
}
// Verify checks sig over SHA-256(payload) against the public key inside uid.
//
// Accepts both encodings in circulation: DER (what Sign emits, and what User
// Data Layer records carry) and 64-byte compact r‖s (what the browser emits —
// noble-secp256k1 v3 dropped DER, and meta-watch's sign-in already sends
// compact). Rejecting either would break one of the two callers.
func Verify(uid string, payload, sig []byte) error {
pub, err := PubkeyFromUID(uid)
if err != nil {
return err
}
parsed, err := parseSignature(sig)
if err != nil {
return err
}
digest := sha256.Sum256(payload)
if !parsed.Verify(digest[:], pub) {
return errors.New("signature does not verify for this uid")
}
return nil
}
func parseSignature(sig []byte) (*ecdsa.Signature, error) {
if len(sig) == 64 {
var r, s secp256k1.ModNScalar
// SetByteSlice reports overflow (>= curve order), which is not a valid
// scalar. A zero r or s is likewise never produced by a real signer.
if r.SetByteSlice(sig[:32]) || r.IsZero() {
return nil, errors.New("compact signature has an invalid r")
}
if s.SetByteSlice(sig[32:]) || s.IsZero() {
return nil, errors.New("compact signature has an invalid s")
}
return ecdsa.NewSignature(&r, &s), nil
}
parsed, err := ecdsa.ParseDERSignature(sig)
if err != nil {
return nil, fmt.Errorf("signature is neither 64-byte compact nor valid DER: %w", err)
}
return parsed, nil
}
// NewChallengeText mints the literal string a client signs. The uid and action
// are inside it, so a signature cannot be lifted from one account or operation
// onto another even if the nonce leaks.
func NewChallengeText(uid string, action Action) (string, error) {
nonce := make([]byte, 32)
if _, err := rand.Read(nonce); err != nil {
return "", fmt.Errorf("read nonce: %w", err)
}
return ChallengeDomain + string(action) + ":" + uid + ":" + hex.EncodeToString(nonce), nil
}
// ChallengeMatches reports whether text is a well-formed challenge for exactly
// this uid and action. The store already keys on the full text, so this is a
// belt-and-braces re-read of the signed bytes themselves: it is the signed
// content, not the lookup key, that a verifier must agree with.
//
// Compared in constant time — the text is a bearer secret until redeemed.
func ChallengeMatches(text, uid string, action Action) bool {
want := ChallengeDomain + string(action) + ":" + uid + ":"
if len(text) <= len(want) {
return false
}
return subtle.ConstantTimeCompare([]byte(text[:len(want)]), []byte(want)) == 1
}
+278
View File
@@ -0,0 +1,278 @@
package identity
import (
"crypto/sha256"
"encoding/hex"
"strings"
"testing"
"time"
"github.com/decred/dcrd/dcrec/secp256k1/v4"
"github.com/decred/dcrd/dcrec/secp256k1/v4/ecdsa"
)
// The whole gate rests on a uid being the public key: verification consults no
// stored state, so a signature is checked against the account the caller names
// rather than against whatever key meta-core happens to hold.
func TestVerify_AcceptsDERFromOurOwnSigner(t *testing.T) {
id, err := Generate()
if err != nil {
t.Fatalf("generate: %v", err)
}
payload := []byte(ChallengeDomain + "delete:" + id.UID + ":abcdef")
sig, err := Sign(id, payload)
if err != nil {
t.Fatalf("sign: %v", err)
}
if err := Verify(id.UID, payload, sig); err != nil {
t.Fatalf("DER signature from Sign must verify: %v", err)
}
}
// The browser is the signer that matters here, and noble-secp256k1 v3 emits
// 64-byte compact r‖s — it dropped DER entirely. A verifier that took only DER
// would reject every real proof while passing this package's own tests.
func TestVerify_AcceptsCompactAsTheBrowserEmitsIt(t *testing.T) {
id, err := Generate()
if err != nil {
t.Fatalf("generate: %v", err)
}
payload := []byte("some challenge text")
raw, _ := hex.DecodeString(id.PrivateKeyHex)
priv := secp256k1.PrivKeyFromBytes(raw)
digest := sha256.Sum256(payload)
der := ecdsa.Sign(priv, digest[:])
// Re-encode the same signature compactly, the way the browser would.
parsed, err := ecdsa.ParseDERSignature(der.Serialize())
if err != nil {
t.Fatalf("parse own DER: %v", err)
}
r := parsed.R()
sc := parsed.S()
compact := make([]byte, 0, 64)
rb := r.Bytes()
sb := sc.Bytes()
compact = append(compact, rb[:]...)
compact = append(compact, sb[:]...)
if err := Verify(id.UID, payload, compact); err != nil {
t.Fatalf("compact signature must verify: %v", err)
}
}
func TestVerify_RejectsAnotherAccountsSignature(t *testing.T) {
alice, _ := Generate()
mallory, _ := Generate()
payload := []byte("challenge for alice")
sig, err := Sign(mallory, payload)
if err != nil {
t.Fatalf("sign: %v", err)
}
if err := Verify(alice.UID, payload, sig); err == nil {
t.Fatal("a signature by mallory must not verify as alice")
}
}
func TestVerify_RejectsTamperedPayload(t *testing.T) {
id, _ := Generate()
sig, _ := Sign(id, []byte("delete account A"))
if err := Verify(id.UID, []byte("delete account B"), sig); err == nil {
t.Fatal("a signature must not verify over different bytes")
}
}
func TestVerify_RejectsGarbageSignatureEncodings(t *testing.T) {
id, _ := Generate()
for name, sig := range map[string][]byte{
"empty": {},
"short": {1, 2, 3},
"zero compact": make([]byte, 64),
"not der": []byte("this is not a signature at all......."),
"63 bytes": make([]byte, 63),
"65 bytes non-der": make([]byte, 65),
} {
if err := Verify(id.UID, []byte("x"), sig); err == nil {
t.Errorf("%s: expected rejection", name)
}
}
}
func TestPubkeyFromUID_RejectsMalformedUIDs(t *testing.T) {
id, _ := Generate()
for name, uid := range map[string]string{
"empty": "",
"no z prefix": id.UID[1:],
"truncated": id.UID[:20],
"path traversal": "../../etc/passwd",
"with slash": "z/" + id.UID,
"not on curve": "z" + strings.Repeat("1", 45),
} {
if _, err := PubkeyFromUID(uid); err == nil {
t.Errorf("%s (%q): expected rejection", name, uid)
}
}
if _, err := PubkeyFromUID(id.UID); err != nil {
t.Fatalf("a real uid must parse: %v", err)
}
}
// The refusal that makes proof-of-possession mean anything on a node holding
// every private key. If this ever returns false for a challenge, an attacker
// asks /api/identity/sign to authorise their own delete.
func TestIsChallengePayload_CatchesAnythingInTheDomain(t *testing.T) {
yes := [][]byte{
[]byte(ChallengeDomain),
[]byte(ChallengeDomain + "delete:zAlice:beef"),
[]byte(ChallengeDomain + "some-future-action:whatever"),
[]byte("metamesh-identity-v1:"), // literal, in case the const drifts
}
for _, b := range yes {
if !IsChallengePayload(b) {
t.Errorf("must refuse to sign %q", b)
}
}
no := [][]byte{
{},
[]byte("metamesh-identity-v2:delete:zAlice"), // different domain, not ours to refuse
[]byte("a record payload"),
[]byte(" " + ChallengeDomain), // not a prefix
}
for _, b := range no {
if IsChallengePayload(b) {
t.Errorf("must still sign %q", b)
}
}
}
func TestChallengeMatches_TiesTheTextToUIDAndAction(t *testing.T) {
text, err := NewChallengeText("zAlice", ActionDelete)
if err != nil {
t.Fatalf("mint: %v", err)
}
if !ChallengeMatches(text, "zAlice", ActionDelete) {
t.Fatal("its own uid+action must match")
}
if ChallengeMatches(text, "zBob", ActionDelete) {
t.Fatal("another uid must not match")
}
if ChallengeMatches(text, "zAlice", ActionReveal) {
t.Fatal("another action must not match")
}
// A prefix-only uid must not slip through: "zAlic" is a prefix of "zAlice",
// and a naive HasPrefix on the uid alone would accept it.
if ChallengeMatches(text, "zAlic", ActionDelete) {
t.Fatal("a uid that is a prefix of the real one must not match")
}
}
func TestNewChallengeText_IsUnpredictable(t *testing.T) {
seen := make(map[string]struct{}, 64)
for i := 0; i < 64; i++ {
text, err := NewChallengeText("zAlice", ActionDelete)
if err != nil {
t.Fatalf("mint: %v", err)
}
if _, dup := seen[text]; dup {
t.Fatal("challenge repeated — the nonce is not random")
}
seen[text] = struct{}{}
}
}
// --- challenge store --------------------------------------------------------
func TestChallengeStore_RedeemIsSingleUse(t *testing.T) {
s := NewChallengeStore()
text, _, err := s.Issue("zAlice", ActionDelete)
if err != nil {
t.Fatalf("issue: %v", err)
}
if err := s.Redeem(text, "zAlice", ActionDelete); err != nil {
t.Fatalf("first redeem: %v", err)
}
if err := s.Redeem(text, "zAlice", ActionDelete); err == nil {
t.Fatal("a challenge must not be redeemable twice — that is replay")
}
}
// A challenge minted to *look at* a key must not delete the account, and one
// minted for Alice must not act on Bob. Both are inside the signed text, so a
// store that ignored them would authorise the wrong operation with a perfectly
// valid signature.
func TestChallengeStore_RedeemRefusesWrongUIDOrAction(t *testing.T) {
s := NewChallengeStore()
text, _, _ := s.Issue("zAlice", ActionReveal)
if err := s.Redeem(text, "zAlice", ActionDelete); err == nil {
t.Fatal("a reveal challenge must not authorise a delete")
}
text2, _, _ := s.Issue("zAlice", ActionDelete)
if err := s.Redeem(text2, "zBob", ActionDelete); err == nil {
t.Fatal("alice's challenge must not authorise deleting bob")
}
}
// A failed redemption still consumes the challenge, so a wrong guess costs a
// round trip rather than leaving the nonce up for another attempt.
func TestChallengeStore_FailedRedeemStillBurnsIt(t *testing.T) {
s := NewChallengeStore()
text, _, _ := s.Issue("zAlice", ActionDelete)
_ = s.Redeem(text, "zBob", ActionDelete)
if err := s.Redeem(text, "zAlice", ActionDelete); err == nil {
t.Fatal("a burnt challenge must stay burnt for its rightful owner too")
}
}
func TestChallengeStore_Expires(t *testing.T) {
s := NewChallengeStore()
now := time.Unix(1_700_000_000, 0)
s.now = func() time.Time { return now }
text, expires, _ := s.Issue("zAlice", ActionDelete)
if !expires.Equal(now.Add(ChallengeTTL)) {
t.Fatalf("expiry %v, want %v", expires, now.Add(ChallengeTTL))
}
now = now.Add(ChallengeTTL + time.Second)
if err := s.Redeem(text, "zAlice", ActionDelete); err == nil {
t.Fatal("an expired challenge must not redeem")
}
}
// Issuing is unauthenticated, so the map is attacker-growable. The cap is what
// stops that being a memory-exhaustion lever; expiry sweeping is what stops the
// cap being reached by honest traffic.
func TestChallengeStore_CapsInFlightAndSweepsExpired(t *testing.T) {
s := NewChallengeStore()
now := time.Unix(1_700_000_000, 0)
s.now = func() time.Time { return now }
for i := 0; i < maxChallenges; i++ {
if _, _, err := s.Issue("zAlice", ActionDelete); err != nil {
t.Fatalf("issue %d: %v", i, err)
}
}
if _, _, err := s.Issue("zAlice", ActionDelete); err == nil {
t.Fatal("expected the store to refuse once full")
}
// Once they age out, the next issue sweeps and succeeds again.
now = now.Add(ChallengeTTL + time.Second)
if _, _, err := s.Issue("zAlice", ActionDelete); err != nil {
t.Fatalf("after expiry the store must accept again: %v", err)
}
}
func TestChallengeStore_RefusesInvalidInputs(t *testing.T) {
s := NewChallengeStore()
if _, _, err := s.Issue("../etc", ActionDelete); err == nil {
t.Fatal("a uid that is not uid-shaped must be refused")
}
if _, _, err := s.Issue("zAlice", Action("sudo")); err == nil {
t.Fatal("an unknown action must be refused")
}
}
+65 -20
View File
@@ -83,6 +83,7 @@ func (c *Client) Mint(filePath string, size, mtimeNano int64) (string, error) {
pipe.Set(ctx, prefix+"sizeByte", strconv.FormatInt(size, 10), 0) pipe.Set(ctx, prefix+"sizeByte", strconv.FormatInt(size, 10), 0)
pipe.Set(ctx, prefix+"mtimeNano", strconv.FormatInt(mtimeNano, 10), 0) pipe.Set(ctx, prefix+"mtimeNano", strconv.FormatInt(mtimeNano, 10), 0)
pipe.SAdd(ctx, c.buildIndexKey(), uuid) pipe.SAdd(ctx, c.buildIndexKey(), uuid)
c.addFieldsToPipe(ctx, pipe, uuid, []string{"filePath", "sizeByte", "mtimeNano"})
if _, err := pipe.Exec(ctx); err != nil { if _, err := pipe.Exec(ctx); err != nil {
return "", fmt.Errorf("mint pipeline: %w", err) return "", fmt.Errorf("mint pipeline: %w", err)
} }
@@ -116,7 +117,13 @@ func (c *Client) addAliasLocked(ctx context.Context, uuid, cidStr string) error
return fmt.Errorf("uuid and cid are required") return fmt.Errorf("uuid and cid are required")
} }
keysetKey := c.buildKeyPrefix(uuid) + CIDsKeyPrefix + cidStr keysetField := CIDsKeyPrefix + cidStr
keysetKey := c.buildKeyPrefix(uuid) + keysetField
// Every branch below writes the key-set member, so index the field name
// once here — including the two guard branches that skip the alias itself.
// Missing it would leave `cids/<cid>` invisible to a record read.
defer c.addFieldLocked(ctx, uuid, keysetField)
// Self-pointing alias guard. If the caller is trying to register // Self-pointing alias guard. If the caller is trying to register
// cid:<cid> → <cid> (i.e. the root IS the cid value), that's the // cid:<cid> → <cid> (i.e. the root IS the cid value), that's the
@@ -233,26 +240,13 @@ func (c *Client) AddDuplicatePath(uuid, path string) (bool, error) {
return added > 0, nil return added > 0, nil
} }
// cidsForRootLocked scans the bare-CID key-set for a root and returns its // cidsForRootLocked returns a root's bare-CID key-set members (with the cids/
// members (bare CIDs, with the cids/ prefix stripped). Caller must hold c.mu. // prefix stripped). Caller must hold c.mu.
//
// Reads the field index and filters by prefix; only an un-indexed root falls
// back to a SCAN, and then over the narrower cids/ glob. See field_index.go.
func (c *Client) cidsForRootLocked(ctx context.Context, uuid string) ([]string, error) { func (c *Client) cidsForRootLocked(ctx context.Context, uuid string) ([]string, error) {
prefix := c.buildKeyPrefix(uuid) + CIDsKeyPrefix return c.fieldsWithPrefixLocked(ctx, uuid, CIDsKeyPrefix)
var out []string
var cursor uint64
for {
keys, next, err := c.client.Scan(ctx, cursor, prefix+"*", 1000).Result()
if err != nil {
return nil, fmt.Errorf("scan cids: %w", err)
}
for _, k := range keys {
out = append(out, strings.TrimPrefix(k, prefix))
}
cursor = next
if cursor == 0 {
break
}
}
return out, nil
} }
// DeleteRoot removes everything keyed off uuid: every file:<uuid>/* property // DeleteRoot removes everything keyed off uuid: every file:<uuid>/* property
@@ -315,6 +309,10 @@ func (c *Client) DeleteRoot(uuid string) error {
if err := c.client.SRem(ctx, c.buildIndexKey(), uuid).Err(); err != nil { if err := c.client.SRem(ctx, c.buildIndexKey(), uuid).Err(); err != nil {
return fmt.Errorf("srem index: %w", err) return fmt.Errorf("srem index: %w", err)
} }
// Marked so the incremental search-index refresh reads it, finds it gone,
// and evicts it — otherwise a deleted root keeps answering searches until
// the next full rebuild.
c.markDirtyLocked(ctx, uuid)
return nil return nil
} }
@@ -424,3 +422,50 @@ func (c *Client) GetMetadataDocument(uuid string) (*MetadataDocument, error) {
Duplicates: dups, Duplicates: dups,
}, nil }, nil
} }
// RegisterFileTuple records one physical file under its midhash256 CID, the
// same way the file watcher does for every file it scans: mint a UUID root
// (filePath/sizeByte/mtimeNano) and write `cids/<midhash>`, whose hook
// registers the cid:<midhash> reverse alias. Content already known under
// another path is recorded as a duplicate; the same path just refreshes
// size/mtime.
//
// Returns the root and whether it was newly minted. Shared by the watcher and
// POST /api/files/register, which is how a box running with the watcher off
// (a meta-watch client core) still gets files it writes under /files/plugin
// resolvable by CID.
func (c *Client) RegisterFileTuple(absPath string, size, mtimeNano int64, midhash string) (string, bool, error) {
if midhash == "" {
return "", false, fmt.Errorf("midhash is required")
}
uuid, err := c.GetByCID(midhash)
if err != nil {
return "", false, fmt.Errorf("GetByCID(%s): %w", midhash, err)
}
if uuid == "" {
uuid, err = c.Mint(absPath, size, mtimeNano)
if err != nil {
return "", false, fmt.Errorf("Mint(%s): %w", absPath, err)
}
if err := c.SetProperty(uuid, CIDsKeyPrefix+midhash, "true"); err != nil {
return uuid, true, fmt.Errorf("SetProperty cids/%s for %s: %w", midhash, uuid, err)
}
return uuid, true, nil
}
existing, _ := c.GetProperty(uuid, "filePath")
if existing != absPath {
if _, err := c.AddDuplicatePath(uuid, absPath); err != nil {
return uuid, false, fmt.Errorf("AddDuplicatePath %s @ %s: %w", uuid, absPath, err)
}
return uuid, false, nil
}
if _, err := c.MergeMetadataFlat(uuid, map[string]string{
"sizeByte": strconv.FormatInt(size, 10),
"mtimeNano": strconv.FormatInt(mtimeNano, 10),
}); err != nil {
return uuid, false, fmt.Errorf("refresh tuple for %s: %w", uuid, err)
}
return uuid, false, nil
}
+170 -80
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"fmt" "fmt"
"log" "log"
"os"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
@@ -109,8 +110,12 @@ func (c *Client) buildIndexKey() string {
return c.buildKey("file:__index__") return c.buildKey("file:__index__")
} }
// GetMetadataFlat retrieves all metadata for a file as a flat map // GetMetadataFlat retrieves all metadata for a file as a flat map.
// Uses flat keys: SCAN file:{hashId}/* then MGET //
// Reads the record's field-name index and MGETs those keys — two round-trips,
// independent of keyspace size. Records written before the index existed fall
// back to the old SCAN and are indexed on the way out. See field_index.go for
// why the index exists and why it is safe to derive.
func (c *Client) GetMetadataFlat(hashID string) (map[string]string, error) { func (c *Client) GetMetadataFlat(hashID string) (map[string]string, error) {
c.mu.RLock() c.mu.RLock()
defer c.mu.RUnlock() defer c.mu.RUnlock()
@@ -122,41 +127,7 @@ func (c *Client) GetMetadataFlat(hashID string) (map[string]string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel() defer cancel()
prefix := c.buildKeyPrefix(hashID) return c.readMetadataLocked(ctx, hashID)
result := make(map[string]string)
// Scan for all keys with this prefix
var cursor uint64
for {
keys, nextCursor, err := c.client.Scan(ctx, cursor, prefix+"*", 1000).Result()
if err != nil {
return nil, fmt.Errorf("scan failed: %w", err)
}
if len(keys) > 0 {
values, err := c.client.MGet(ctx, keys...).Result()
if err != nil {
return nil, fmt.Errorf("mget failed: %w", err)
}
for i, key := range keys {
field := strings.TrimPrefix(key, prefix)
if values[i] != nil {
result[field] = values[i].(string)
}
}
}
cursor = nextCursor
if cursor == 0 {
break
}
}
if len(result) == 0 {
return nil, nil
}
return result, nil
} }
// SetMetadataFlat stores metadata for a file using flat keys // SetMetadataFlat stores metadata for a file using flat keys
@@ -179,9 +150,21 @@ func (c *Client) SetMetadataFlat(hashID string, metadata map[string]string) erro
prefix := c.buildKeyPrefix(hashID) prefix := c.buildKeyPrefix(hashID)
pipe := c.client.Pipeline() pipe := c.client.Pipeline()
fields := make([]string, 0, len(metadata))
for field, value := range metadata { for field, value := range metadata {
// A payload carrying the reserved name (a hand-made snapshot, a buggy
// client) would SET a string over the index SET and break every
// subsequent read of this record. Drop it — it is derived state, so
// there is never a reason to accept it from outside.
if isReservedFieldName(field) {
continue
}
pipe.Set(ctx, prefix+field, value, 0) pipe.Set(ctx, prefix+field, value, 0)
fields = append(fields, field)
} }
// Same pipeline as the SETs, so indexing the field names costs no extra
// round-trip. See field_index.go.
c.addFieldsToPipe(ctx, pipe, hashID, fields)
if _, err := pipe.Exec(ctx); err != nil { if _, err := pipe.Exec(ctx); err != nil {
return fmt.Errorf("pipeline exec failed: %w", err) return fmt.Errorf("pipeline exec failed: %w", err)
@@ -191,6 +174,7 @@ func (c *Client) SetMetadataFlat(hashID string, metadata map[string]string) erro
if err := c.client.SAdd(ctx, c.buildIndexKey(), hashID).Err(); err != nil { if err := c.client.SAdd(ctx, c.buildIndexKey(), hashID).Err(); err != nil {
return fmt.Errorf("sadd index failed: %w", err) return fmt.Errorf("sadd index failed: %w", err)
} }
c.markDirtyLocked(ctx, hashID)
// Auto-register reverse-index aliases for any cids/<cid> key-set members in // Auto-register reverse-index aliases for any cids/<cid> key-set members in
// the payload. That is the ONLY shape recognised — the legacy flat cid_* / // the payload. That is the ONLY shape recognised — the legacy flat cid_* /
@@ -256,6 +240,12 @@ func (c *Client) SetProperty(hashID, property, value string) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() defer cancel()
// See SetMetadataFlat: the reserved name addresses the derived index SET,
// not a metadata field.
if isReservedFieldName(property) {
return fmt.Errorf("field name %q is reserved", property)
}
key := c.buildKeyPrefix(hashID) + property key := c.buildKeyPrefix(hashID) + property
// Use SET to set the key // Use SET to set the key
@@ -263,10 +253,13 @@ func (c *Client) SetProperty(hashID, property, value string) error {
return fmt.Errorf("set failed: %w", err) return fmt.Errorf("set failed: %w", err)
} }
c.addFieldLocked(ctx, hashID, property)
// Add to index set // Add to index set
if err := c.client.SAdd(ctx, c.buildIndexKey(), hashID).Err(); err != nil { if err := c.client.SAdd(ctx, c.buildIndexKey(), hashID).Err(); err != nil {
return fmt.Errorf("sadd index failed: %w", err) return fmt.Errorf("sadd index failed: %w", err)
} }
c.markDirtyLocked(ctx, hashID)
// Auto-register reverse-index alias if this is a CID field. // Auto-register reverse-index alias if this is a CID field.
_ = c.maybeAddAliasFromFieldLocked(ctx, hashID, property, value) _ = c.maybeAddAliasFromFieldLocked(ctx, hashID, property, value)
@@ -335,6 +328,10 @@ func (c *Client) DeleteMetadata(hashID string) (int64, error) {
if err := c.client.SRem(ctx, c.buildIndexKey(), hashID).Err(); err != nil { if err := c.client.SRem(ctx, c.buildIndexKey(), hashID).Err(); err != nil {
return deletedCount, fmt.Errorf("srem index failed: %w", err) return deletedCount, fmt.Errorf("srem index failed: %w", err)
} }
// Marked, not unmarked: the incremental refresh reads the record, finds it
// gone, and evicts it from the search index. Skipping this would leave a
// deleted record answering searches until the next full rebuild.
c.markDirtyLocked(ctx, hashID)
return deletedCount, nil return deletedCount, nil
} }
@@ -387,32 +384,14 @@ func normalizeFilesRelativePath(p string) string {
// getMetadataFlatInternal mirrors GetMetadataFlat without re-acquiring the lock. // getMetadataFlatInternal mirrors GetMetadataFlat without re-acquiring the lock.
// Callers must already hold c.mu. // Callers must already hold c.mu.
func (c *Client) getMetadataFlatInternal(ctx context.Context, hashID string) (map[string]string, error) { func (c *Client) getMetadataFlatInternal(ctx context.Context, hashID string) (map[string]string, error) {
prefix := c.buildKeyPrefix(hashID) result, err := c.readMetadataLocked(ctx, hashID)
result := make(map[string]string) if err != nil {
return nil, err
var cursor uint64 }
for { // Unlike GetMetadataFlat, this variant hands back an empty map rather than
keys, nextCursor, err := c.client.Scan(ctx, cursor, prefix+"*", 1000).Result() // nil for a record with no fields — GetMetadataDocument writes into it.
if err != nil { if result == nil {
return nil, fmt.Errorf("scan failed: %w", err) result = make(map[string]string)
}
if len(keys) > 0 {
values, err := c.client.MGet(ctx, keys...).Result()
if err != nil {
return nil, fmt.Errorf("mget failed: %w", err)
}
for i, key := range keys {
if values[i] == nil {
continue
}
field := strings.TrimPrefix(key, prefix)
result[field] = values[i].(string)
}
}
cursor = nextCursor
if cursor == 0 {
break
}
} }
return result, nil return result, nil
} }
@@ -449,9 +428,17 @@ func (c *Client) MergeMetadataFlat(hashID string, metadata map[string]string) (i
prefix := c.buildKeyPrefix(hashID) prefix := c.buildKeyPrefix(hashID)
pipe := c.client.Pipeline() pipe := c.client.Pipeline()
fields := make([]string, 0, len(metadata))
for field, value := range metadata { for field, value := range metadata {
// See SetMetadataFlat: never let an inbound payload overwrite the
// derived index SET with a string.
if isReservedFieldName(field) {
continue
}
pipe.Set(ctx, prefix+field, value, 0) pipe.Set(ctx, prefix+field, value, 0)
fields = append(fields, field)
} }
c.addFieldsToPipe(ctx, pipe, hashID, fields)
if _, err := pipe.Exec(ctx); err != nil { if _, err := pipe.Exec(ctx); err != nil {
return 0, fmt.Errorf("pipeline exec failed: %w", err) return 0, fmt.Errorf("pipeline exec failed: %w", err)
@@ -461,6 +448,7 @@ func (c *Client) MergeMetadataFlat(hashID string, metadata map[string]string) (i
if err := c.client.SAdd(ctx, c.buildIndexKey(), hashID).Err(); err != nil { if err := c.client.SAdd(ctx, c.buildIndexKey(), hashID).Err(); err != nil {
return len(metadata), fmt.Errorf("sadd index failed: %w", err) return len(metadata), fmt.Errorf("sadd index failed: %w", err)
} }
c.markDirtyLocked(ctx, hashID)
// Auto-register reverse-index aliases for any cids/<cid> key-set members in // Auto-register reverse-index aliases for any cids/<cid> key-set members in
// the payload. That is the ONLY shape recognised — the legacy flat cid_* / // the payload. That is the ONLY shape recognised — the legacy flat cid_* /
@@ -484,7 +472,12 @@ func (c *Client) DeleteProperty(hashID, property string) error {
defer cancel() defer cancel()
key := c.buildKeyPrefix(hashID) + property key := c.buildKeyPrefix(hashID) + property
return c.client.Del(ctx, key).Err() if err := c.client.Del(ctx, key).Err(); err != nil {
return err
}
c.removeFieldLocked(ctx, hashID, property)
c.markDirtyLocked(ctx, hashID)
return nil
} }
// AddToSet adds a value to a set-type field (stored as pipe-delimited string in flat key) // AddToSet adds a value to a set-type field (stored as pipe-delimited string in flat key)
@@ -530,10 +523,14 @@ func (c *Client) AddToSet(hashID, property, value string) (bool, error) {
return false, err return false, err
} }
// The SET above may have created the key, so the field name is new.
c.addFieldLocked(ctx, hashID, property)
// Add to index set // Add to index set
if err := c.client.SAdd(ctx, c.buildIndexKey(), hashID).Err(); err != nil { if err := c.client.SAdd(ctx, c.buildIndexKey(), hashID).Err(); err != nil {
return true, fmt.Errorf("sadd index failed: %w", err) return true, fmt.Errorf("sadd index failed: %w", err)
} }
c.markDirtyLocked(ctx, hashID)
return true, nil return true, nil
} }
@@ -582,11 +579,21 @@ func (c *Client) RemoveFromSet(hashID, property, value string) (bool, error) {
// Save back (or delete key if empty) // Save back (or delete key if empty)
if len(newValues) == 0 { if len(newValues) == 0 {
return true, c.client.Del(ctx, key).Err() if err := c.client.Del(ctx, key).Err(); err != nil {
return true, err
}
// The key is gone, so the field name must leave the index too.
c.removeFieldLocked(ctx, hashID, property)
c.markDirtyLocked(ctx, hashID)
return true, nil
} }
newValue := strings.Join(newValues, "|") newValue := strings.Join(newValues, "|")
return true, c.client.Set(ctx, key, newValue, 0).Err() err = c.client.Set(ctx, key, newValue, 0).Err()
// The field survives with fewer members, so no field-index change — but the
// VALUE changed, which is exactly what the search index caches.
c.markDirtyLocked(ctx, hashID)
return true, err
} }
// FileTuple holds the (uuid, path, size, mtime, midhash) record for one // FileTuple holds the (uuid, path, size, mtime, midhash) record for one
@@ -699,6 +706,44 @@ type SearchTuple struct {
// haystack — must stay in sync with the fields the search handler documents. // haystack — must stay in sync with the fields the search handler documents.
var searchIndexFields = []string{"title", "fileName", "originaltitle", "showtitle", "filePath"} var searchIndexFields = []string{"title", "fileName", "originaltitle", "showtitle", "filePath"}
// defaultIndexExcludePrefixes are field-name prefixes kept OUT of the in-memory
// search index. Redis still stores them and every per-record read still returns
// them — this narrows the *index*, never the store.
//
// `categories/newznab/<code>` is the raw Newznab numeric category code, which
// METADATA_KEYS documents as a mirror of the canonical `categories/<label>`
// vocabulary that rides alongside it. Nothing in meta-gateway, meta-search,
// meta-watch or meta-core reads it, and it is the single largest contributor to
// the index: measured on a live 19.6k-record corpus, `categories/*` was 40% of
// the whole keyspace and the `newznab` sub-vocabulary was 59% of that — ~24% of
// all keys, for zero readers.
//
// ⚠ Excluding the WHOLE `categories/` family instead would be a security bug,
// not an optimisation. `categories/XXX` (and `categories/XXX/<leaf>`) is the
// server-side adult-content enforcement point — meta-search's
// `query_eval::fields_are_adult` and meta-gateway's `cards::fields_are_adult`
// both match on it, and the `exclude_adult` preference defaults to ON. A record
// served from the index without its categories would read as non-adult and the
// filter would fail OPEN. Only prefixes with no semantic readers belong here.
var defaultIndexExcludePrefixes = []string{"categories/newznab/"}
// indexExcludePrefixes resolves the exclusion list, allowing an operator to
// override it with a comma-separated META_CORE_INDEX_EXCLUDE_PREFIXES. An
// explicitly empty value disables exclusion entirely (index everything).
func indexExcludePrefixes() []string {
raw, set := os.LookupEnv("META_CORE_INDEX_EXCLUDE_PREFIXES")
if !set {
return defaultIndexExcludePrefixes
}
out := make([]string, 0, 4)
for _, p := range strings.Split(raw, ",") {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
// GetSearchIndexTuples reads EVERY record's full metadata in a single keyspace // GetSearchIndexTuples reads EVERY record's full metadata in a single keyspace
// SCAN pass over the flat `file:{hashID}/{field}` keys plus batched MGETs, // SCAN pass over the flat `file:{hashID}/{field}` keys plus batched MGETs,
// grouped by record — one bulk pass instead of a SCAN+MGET per record (which is // grouped by record — one bulk pass instead of a SCAN+MGET per record (which is
@@ -720,6 +765,7 @@ func (c *Client) GetSearchIndexTuples() ([]SearchTuple, error) {
indexKey := c.buildIndexKey() // {filePrefix}__index__ (a SET, skip it) indexKey := c.buildIndexKey() // {filePrefix}__index__ (a SET, skip it)
pattern := filePrefix + "*" pattern := filePrefix + "*"
excluded := indexExcludePrefixes()
records := make(map[string]map[string]string) records := make(map[string]map[string]string)
keyBatch := make([]string, 0, 1000) keyBatch := make([]string, 0, 1000)
@@ -764,6 +810,17 @@ func (c *Client) GetSearchIndexTuples() ([]SearchTuple, error) {
if k == indexKey { if k == indexKey {
continue continue
} }
// Per-record field indexes live under the same prefix and are SETs
// — MGET would error on them, and they are bookkeeping, not
// searchable metadata.
if strings.HasSuffix(k, "/"+FieldsField) {
continue
}
// Index-excluded fields are skipped here rather than after the
// MGET, so an excluded key costs neither the fetch nor the memory.
if _, field, ok := c.splitRecordKey(k); ok && hasAnyPrefix(field, excluded) {
continue
}
keyBatch = append(keyBatch, k) keyBatch = append(keyBatch, k)
if len(keyBatch) >= 1000 { if len(keyBatch) >= 1000 {
if err := flush(); err != nil { if err := flush(); err != nil {
@@ -782,18 +839,23 @@ func (c *Client) GetSearchIndexTuples() ([]SearchTuple, error) {
out := make([]SearchTuple, 0, len(records)) out := make([]SearchTuple, 0, len(records))
for hashID, fields := range records { for hashID, fields := range records {
var sb strings.Builder // Shared with the incremental path (search_dirty.go) so the two can
for _, f := range searchIndexFields { // never disagree about what is searchable.
if v, ok := fields[f]; ok && v != "" { out = append(out, SearchTuple{HashID: hashID, Haystack: buildHaystack(fields), Fields: fields})
sb.WriteString(strings.ToLower(v))
sb.WriteByte('\n')
}
}
out = append(out, SearchTuple{HashID: hashID, Haystack: sb.String(), Fields: fields})
} }
return out, nil return out, nil
} }
// hasAnyPrefix reports whether s starts with any of the given prefixes.
func hasAnyPrefix(s string, prefixes []string) bool {
for _, p := range prefixes {
if strings.HasPrefix(s, p) {
return true
}
}
return false
}
// GetMemoryInfo returns Redis memory usage information // GetMemoryInfo returns Redis memory usage information
func (c *Client) GetMemoryInfo() (string, error) { func (c *Client) GetMemoryInfo() (string, error) {
c.mu.RLock() c.mu.RLock()
@@ -1039,6 +1101,12 @@ func (c *Client) FindByValue(contains string, fields []string, limit int) ([]Fin
if field == "" { if field == "" {
continue continue
} }
// The per-record field index is a SET, so an MGET over those keys
// would fail the whole query with WRONGTYPE. It holds field names,
// not values, so there is nothing here to search anyway.
if isReservedFieldName(field) {
continue
}
if len(matches) >= limit { if len(matches) >= limit {
truncated = true truncated = true
break break
@@ -1207,7 +1275,17 @@ func (c *Client) SetRawValue(key, value string) error {
if t != "none" && t != "string" { if t != "none" && t != "string" {
return fmt.Errorf("key has incompatible type %q (only string is editable)", t) return fmt.Errorf("key has incompatible type %q (only string is editable)", t)
} }
return c.client.Set(ctx, full, value, 0).Err() if err := c.client.Set(ctx, full, value, 0).Err(); err != nil {
return err
}
// The raw editor can mint a record property out of band, so it has to keep
// the field index honest like every other write path. Non-record keys
// (`cid:*`, `udl:*`, `file:__index__`, the index set itself) split false
// and are left alone.
if hashID, field, ok := c.splitRecordKey(full); ok {
c.addFieldLocked(ctx, hashID, field)
}
return nil
} }
// DeleteRawKey deletes the exact key. Returns true if the key existed. // DeleteRawKey deletes the exact key. Returns true if the key existed.
@@ -1222,10 +1300,16 @@ func (c *Client) DeleteRawKey(key string) (bool, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() defer cancel()
n, err := c.client.Del(ctx, c.buildKey(key)).Result() full := c.buildKey(key)
n, err := c.client.Del(ctx, full).Result()
if err != nil { if err != nil {
return false, err return false, err
} }
if n > 0 {
if hashID, field, ok := c.splitRecordKey(full); ok {
c.removeFieldLocked(ctx, hashID, field)
}
}
return n > 0, nil return n > 0, nil
} }
@@ -1257,14 +1341,20 @@ func (c *Client) IndexRemoveIfEmpty(hashID string) (bool, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() defer cancel()
prefix := c.buildKeyPrefix(hashID) nonEmpty, err := c.recordHasFieldsLocked(ctx, hashID)
keys, _, err := c.client.Scan(ctx, 0, prefix+"*", 1).Result()
if err != nil { if err != nil {
return false, fmt.Errorf("scan failed: %w", err) return false, err
} }
if len(keys) > 0 { if nonEmpty {
return false, nil return false, nil
} }
// Nothing left but (possibly) an empty field index — drop it too, so a
// de-indexed root leaves no orphan key behind.
if err := c.client.Del(ctx, c.buildFieldsKey(hashID)).Err(); err != nil {
return false, fmt.Errorf("del field index: %w", err)
}
removed, err := c.client.SRem(ctx, c.buildIndexKey(), hashID).Result() removed, err := c.client.SRem(ctx, c.buildIndexKey(), hashID).Result()
if err != nil { if err != nil {
return false, fmt.Errorf("srem failed: %w", err) return false, fmt.Errorf("srem failed: %w", err)
+193
View File
@@ -0,0 +1,193 @@
package storage
import (
"context"
"fmt"
"time"
)
// The one-shot corpus sweep for METADATA_KEYS.md §14.17: the `film` + `tv`
// domains merged into `screen`, and the standalone-vs-serial split they were
// really carrying moved to the new `workForm` key.
//
// # Why a sweep and not a read-side alias
//
// `domain` is **stored, not derived on read** — that is what lets the
// `contentKind` vocabulary stay open (a peer that has never heard of a kind
// still routes the record correctly). The price of storing it is that a
// vocabulary change has to be paid once, in the data: every consumer's filter
// is an exact match, so a record left saying `film` is invisible to a
// `domain:screen` wall, and teaching every consumer to accept both spellings
// forever is exactly the alternation the merge exists to delete.
//
// So the values are rewritten at rest, here, and no alias vocabulary survives
// in query filters. (meta-search additionally normalises on the way into its
// index, because a record can still arrive from a peer that has not been swept
// — that is a courtesy for un-swept peers, not a second source of truth.)
//
// # Contract
//
// Idempotent: a record already carrying `screen` and a `workForm` is skipped,
// so the endpoint is safe to call repeatedly and safe to call on a fresh box.
// Returns the number of records **changed**, not the number examined.
// legacyDomainReplacements maps a retired `domain` value to its replacement.
var legacyDomainReplacements = map[string]string{
"film": "screen",
"tv": "screen",
}
// workFormByContentKind is the `contentKind` → `workForm` table from
// METADATA_KEYS.md §1.
//
// ⚠ Mirror of the Rust table in `meta-feeder-sdk::domain` (and of
// meta-search's copy). It exists here only to backfill the pre-merge corpus —
// meta-core never classifies a record it is given, and nothing else in Go reads
// it. If the tables ever disagree, the writers win: this one is a one-shot.
var workFormByContentKind = map[string]string{
// screen
"movie": "standalone",
"series": "serial",
"episode": "serial",
// music — an album is a closed work, not an ongoing run.
"track": "standalone",
"album": "standalone",
"artist": "standalone",
"musicVideo": "standalone",
"djMix": "standalone",
"liveSet": "standalone",
// literature
"book": "standalone",
"audiobook": "standalone",
"comic": "serial",
"manga": "serial",
"magazine": "serial",
// science
"paper": "standalone",
// ⚠ `pack` is deliberately absent: a season pack is `serial`, an album
// release `standalone`, and only the writer knows which. A pack that
// reaches this sweep without a `workForm` keeps none — inventing one would
// be a guess written permanently into the record.
}
// domainScreenPlan is one record's pending rewrite. Empty strings mean "leave
// this field alone".
type domainScreenPlan struct {
hashID string
domain string
workForm string
}
// MigrateDomainScreen rewrites `domain=film|tv` to `domain=screen` and
// backfills `workForm` from `contentKind` across the whole corpus.
//
// Reads in two batched MGET passes (never a per-record SCAN — a single record
// read costs a full keyspace sweep on this store), then writes only the records
// that actually change.
func (c *Client) MigrateDomainScreen(ctx context.Context) (int, error) {
plans, err := c.planDomainScreen(ctx)
if err != nil {
return 0, err
}
// Writes take the client lock themselves, so they happen after the read
// phase has released it.
fixed := 0
for _, p := range plans {
if p.domain != "" {
if err := c.SetProperty(p.hashID, "domain", p.domain); err != nil {
return fixed, fmt.Errorf("set domain on %s: %w", p.hashID, err)
}
}
if p.workForm != "" {
if err := c.SetProperty(p.hashID, "workForm", p.workForm); err != nil {
return fixed, fmt.Errorf("set workForm on %s: %w", p.hashID, err)
}
}
fixed++
}
return fixed, nil
}
// planDomainScreen is the read half: what would change, without changing it.
func (c *Client) planDomainScreen(ctx context.Context) ([]domainScreenPlan, error) {
c.mu.RLock()
defer c.mu.RUnlock()
if c.client == nil {
return nil, fmt.Errorf("not connected")
}
hashIDs, err := c.client.SMembers(ctx, c.buildIndexKey()).Result()
if err != nil {
return nil, fmt.Errorf("smembers index: %w", err)
}
if len(hashIDs) == 0 {
return nil, nil
}
// Three keys per record, in a fixed order so the MGET response decodes by
// position: keys[i*3+n] belongs to hashIDs[i].
const fieldsPerFile = 3
fields := [fieldsPerFile]string{"domain", "workForm", "contentKind"}
keys := make([]string, 0, len(hashIDs)*fieldsPerFile)
for _, h := range hashIDs {
prefix := c.buildKeyPrefix(h)
for _, f := range fields {
keys = append(keys, prefix+f)
}
}
// Chunked so one MGET never carries the whole corpus.
const chunk = 3000
values := make([]interface{}, 0, len(keys))
for start := 0; start < len(keys); start += chunk {
end := start + chunk
if end > len(keys) {
end = len(keys)
}
got, err := c.client.MGet(ctx, keys[start:end]...).Result()
if err != nil {
return nil, fmt.Errorf("mget: %w", err)
}
values = append(values, got...)
}
str := func(i int) string {
if i >= len(values) {
return ""
}
s, _ := values[i].(string)
return s
}
plans := make([]domainScreenPlan, 0)
for i, h := range hashIDs {
base := i * fieldsPerFile
domain, workForm, contentKind := str(base), str(base+1), str(base+2)
plan := domainScreenPlan{hashID: h}
if replacement, ok := legacyDomainReplacements[domain]; ok {
plan.domain = replacement
}
// A stored `workForm` is authoritative — a `pack` writer knew something
// the table cannot. Only an absent one is filled in.
if workForm == "" {
if form, ok := workFormByContentKind[contentKind]; ok {
plan.workForm = form
}
}
if plan.domain != "" || plan.workForm != "" {
plans = append(plans, plan)
}
}
return plans, nil
}
// MigrateDomainScreenWithTimeout is the convenience wrapper used by the API
// handler. Generous timeout: the read phase is two batched passes, but the
// write phase is one round-trip per changed field.
func (c *Client) MigrateDomainScreenWithTimeout() (int, error) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
return c.MigrateDomainScreen(ctx)
}
@@ -0,0 +1,140 @@
package storage
import (
"context"
"testing"
)
// seedRecord writes one record's classification fields straight through
// SetProperty, which is what a real writer uses (it maintains the field index
// and the root index the sweep reads from).
func seedRecord(t *testing.T, c *Client, hashID string, fields map[string]string) {
t.Helper()
for k, v := range fields {
if err := c.SetProperty(hashID, k, v); err != nil {
t.Fatalf("seed %s/%s: %v", hashID, k, err)
}
}
}
// The merge (METADATA_KEYS.md §14.17) is paid in the data: `domain` is stored,
// not derived on read, so a record left saying `film` is invisible to a
// `domain:screen` wall. This is the sweep that pays it.
func TestMigrateDomainScreen_RewritesLegacyDomainsAndBackfillsWorkForm(t *testing.T) {
c, _ := newTestClient(t, "")
seedRecord(t, c, "rec-movie", map[string]string{
"domain": "film",
"contentKind": "movie",
})
seedRecord(t, c, "rec-episode", map[string]string{
"domain": "tv",
"contentKind": "episode",
})
// Already current, and already carries both keys: must be left alone.
seedRecord(t, c, "rec-current", map[string]string{
"domain": "screen",
"contentKind": "series",
"workForm": "serial",
})
// A different domain entirely — the sweep must not touch it, but it is
// still owed the `workForm` backfill.
seedRecord(t, c, "rec-book", map[string]string{
"domain": "literature",
"contentKind": "book",
})
fixed, err := c.MigrateDomainScreen(context.Background())
if err != nil {
t.Fatalf("migrate: %v", err)
}
if fixed != 3 {
t.Errorf("fixed = %d, want 3 (the two legacy rows + the book's backfill)", fixed)
}
for _, tc := range []struct {
hashID string
domain string
workForm string
}{
{"rec-movie", "screen", "standalone"},
{"rec-episode", "screen", "serial"},
{"rec-current", "screen", "serial"},
{"rec-book", "literature", "standalone"},
} {
m, err := c.GetMetadataFlat(tc.hashID)
if err != nil {
t.Fatalf("read %s: %v", tc.hashID, err)
}
if m["domain"] != tc.domain {
t.Errorf("%s domain = %q, want %q", tc.hashID, m["domain"], tc.domain)
}
if m["workForm"] != tc.workForm {
t.Errorf("%s workForm = %q, want %q", tc.hashID, m["workForm"], tc.workForm)
}
}
}
// A second pass must find nothing to do — the endpoint is called by hand on
// each box, and "run it twice" must not mean "write the whole corpus twice".
func TestMigrateDomainScreen_IsIdempotent(t *testing.T) {
c, _ := newTestClient(t, "mm:")
seedRecord(t, c, "rec-1", map[string]string{
"domain": "tv",
"contentKind": "episode",
})
if _, err := c.MigrateDomainScreen(context.Background()); err != nil {
t.Fatalf("first pass: %v", err)
}
fixed, err := c.MigrateDomainScreen(context.Background())
if err != nil {
t.Fatalf("second pass: %v", err)
}
if fixed != 0 {
t.Errorf("second pass fixed = %d, want 0", fixed)
}
}
// `pack` is the one kind neither routing table can decide — a season pack is
// `serial`, an album release `standalone`. A pack that reaches the sweep
// without a `workForm` must keep none: inventing one writes a guess into the
// record permanently.
func TestMigrateDomainScreen_NeverGuessesAPacksWorkForm(t *testing.T) {
c, _ := newTestClient(t, "")
seedRecord(t, c, "rec-pack", map[string]string{
"domain": "tv",
"contentKind": "pack",
})
// A writer that DID know keeps what it wrote.
seedRecord(t, c, "rec-album", map[string]string{
"domain": "music",
"contentKind": "pack",
"workForm": "standalone",
})
if _, err := c.MigrateDomainScreen(context.Background()); err != nil {
t.Fatalf("migrate: %v", err)
}
m, err := c.GetMetadataFlat("rec-pack")
if err != nil {
t.Fatalf("read: %v", err)
}
if m["domain"] != "screen" {
t.Errorf("domain = %q, want screen (the domain WAS knowable)", m["domain"])
}
if _, ok := m["workForm"]; ok {
t.Errorf("workForm = %q, want absent", m["workForm"])
}
a, err := c.GetMetadataFlat("rec-album")
if err != nil {
t.Fatalf("read: %v", err)
}
if a["workForm"] != "standalone" {
t.Errorf("workForm = %q, want standalone (writer-supplied, never overwritten)", a["workForm"])
}
}
+486
View File
@@ -0,0 +1,486 @@
package storage
import (
"context"
"fmt"
"log"
"sort"
"strings"
"time"
"github.com/redis/go-redis/v9"
)
// The per-record field-name index.
//
// A record is stored as one flat Redis STRING per leaf
// (`file:<uuid>/<field>`), and that shape is deliberate — it is what lets
// keyspace notifications fire per *field*, so meta-fuse / meta-editor /
// meta-search can react to one field changing instead of diffing a whole hash
// (see docs/project-architecture/persistent-storage.md, and
// dev/test/suites/flat-keys.bats which asserts records are never hashes).
//
// The cost of that shape was that reading one record meant asking Redis "which
// keys start with `file:<uuid>/`?", and Redis has no prefix index — `SCAN
// MATCH` walks the ENTIRE keyspace. On a real store (445k keys, ~36 fields per
// record) a single record read cost ~670 SCAN round-trips and ~0.5s of Redis
// CPU; SCAN accounted for ~97.5% of all Redis CPU time on the box. That sat on
// the byte path for every poster meta-share served, which is how it surfaced.
//
// So we keep the flat keys and index their *names*:
//
// file:<uuid>/__fields__ SET{ "filePath", "sizeByte", "cids/bafk…", … }
//
// A read becomes SMEMBERS + a chunked MGET — two round-trips instead of ~670.
// This mirrors `file:__index__` (the set of every root) one level down, and the
// bulk reads already worked this way: `GetTuplesForAllFiles` composes its keys
// from the index rather than scanning per root.
//
// **The set is derived, never authoritative.** The flat keys remain the source
// of truth. A record whose set is missing (anything written before this landed)
// falls back to the old SCAN and populates the set on the way out, so existing
// deployments heal themselves on first touch with no migration and no
// SchemaVersion bump — which matters, because a bump is a flag day that makes
// operators wipe Redis (see schema_version.go).
//
// The consistency argument that makes a derived index safe here is meta-core's
// single-writer rule: leader election publishes an empty `redisUrl` on purpose
// (internal/leader/election.go), so nothing outside this process can write a
// `file:` key behind the index's back.
// FieldsField is the reserved field name holding a record's field-name index.
//
// It is a Redis SET living at `file:<uuid>/__fields__`, i.e. inside the same
// prefix as the record's own fields. That keeps every existing sweep correct
// for free — `DeleteMetadata`, `DeleteRoot` and `ClearAllMetadata` all
// SCAN+DEL `file:<uuid>/*` and therefore drop the index along with the record.
// The flip side is that every reader which enumerates that prefix has to skip
// it, exactly as they already skip `file:__index__`.
const FieldsField = "__fields__"
// mgetBatch caps how many keys go into one MGET. Redis limits arguments per
// command; 1000 is the batch size `GetTuplesForAllFiles` already uses.
const mgetBatch = 1000
// buildFieldsKey constructs the key for a record's field-name index set.
func (c *Client) buildFieldsKey(hashID string) string {
return c.buildKeyPrefix(hashID) + FieldsField
}
// isReservedFieldName reports whether a field name is bookkeeping rather than
// metadata, and so must never be returned to an API caller, exported into a
// snapshot, or folded into the search haystack.
func isReservedFieldName(field string) bool {
return field == FieldsField
}
// readMetadataLocked returns a record's full flat metadata. Caller must hold
// c.mu (either mode).
//
// Fast path: SMEMBERS the field index, then MGET those keys. Fallback: the
// legacy full-keyspace SCAN, whose result is used to populate the index so the
// next read takes the fast path.
//
// Returns (nil, nil) for a record with no fields — callers treat that as 404.
func (c *Client) readMetadataLocked(ctx context.Context, hashID string) (map[string]string, error) {
fields, err := c.client.SMembers(ctx, c.buildFieldsKey(hashID)).Result()
if err != nil && err != redis.Nil {
return nil, fmt.Errorf("smembers fields: %w", err)
}
if len(fields) > 0 {
return c.mgetFieldsLocked(ctx, hashID, fields)
}
// No index yet: a record written before the index existed, or one that
// genuinely has no fields. Fall back to the scan, then backfill.
result, scanned, err := c.scanMetadataLocked(ctx, hashID)
if err != nil {
return nil, err
}
if len(scanned) > 0 {
// Best-effort: a failed backfill costs the next read a scan, it does
// not make the read wrong. Never fail the caller's read for it.
if err := c.client.SAdd(ctx, c.buildFieldsKey(hashID), toAny(scanned)...).Err(); err != nil {
logFieldIndexBackfillFailure(hashID, err)
}
}
if len(result) == 0 {
return nil, nil
}
return result, nil
}
// mgetFieldsLocked reads the named fields of a record in chunked MGETs.
//
// A field named in the index whose key is gone reads back as nil and is simply
// skipped: a *stale* index entry is harmless. (A *missing* one would silently
// drop data, which is why every write path maintains the set.)
func (c *Client) mgetFieldsLocked(ctx context.Context, hashID string, fields []string) (map[string]string, error) {
prefix := c.buildKeyPrefix(hashID)
// Keep the reserved name out of the key list AND out of the result — it is
// a SET, so MGET would error on it in real Redis rather than return nil.
wanted := make([]string, 0, len(fields))
for _, f := range fields {
if !isReservedFieldName(f) {
wanted = append(wanted, f)
}
}
if len(wanted) == 0 {
return nil, nil
}
result := make(map[string]string, len(wanted))
for start := 0; start < len(wanted); start += mgetBatch {
end := start + mgetBatch
if end > len(wanted) {
end = len(wanted)
}
chunk := wanted[start:end]
keys := make([]string, len(chunk))
for i, f := range chunk {
keys[i] = prefix + f
}
values, err := c.client.MGet(ctx, keys...).Result()
if err != nil {
return nil, fmt.Errorf("mget failed: %w", err)
}
for i, v := range values {
if s, ok := v.(string); ok {
result[chunk[i]] = s
}
}
}
if len(result) == 0 {
return nil, nil
}
return result, nil
}
// scanMetadataLocked is the pre-index read: SCAN the record's key prefix, then
// MGET. Retained as the fallback for un-indexed records and as the source for
// the backfill. Returns the metadata plus the field names it saw (which
// includes fields whose value came back nil, so the index stays complete).
func (c *Client) scanMetadataLocked(ctx context.Context, hashID string) (map[string]string, []string, error) {
prefix := c.buildKeyPrefix(hashID)
result := make(map[string]string)
var fields []string
var cursor uint64
for {
keys, nextCursor, err := c.client.Scan(ctx, cursor, prefix+"*", 1000).Result()
if err != nil {
return nil, nil, fmt.Errorf("scan failed: %w", err)
}
if len(keys) > 0 {
wanted := make([]string, 0, len(keys))
for _, key := range keys {
field := strings.TrimPrefix(key, prefix)
if isReservedFieldName(field) {
continue
}
fields = append(fields, field)
wanted = append(wanted, key)
}
if len(wanted) > 0 {
values, err := c.client.MGet(ctx, wanted...).Result()
if err != nil {
return nil, nil, fmt.Errorf("mget failed: %w", err)
}
for i, key := range wanted {
if s, ok := values[i].(string); ok {
result[strings.TrimPrefix(key, prefix)] = s
}
}
}
}
cursor = nextCursor
if cursor == 0 {
break
}
}
return result, fields, nil
}
// addFieldsToPipe records field names in the index as part of an existing
// pipeline, so a multi-field write stays one round-trip.
func (c *Client) addFieldsToPipe(ctx context.Context, pipe redis.Pipeliner, hashID string, fields []string) {
names := filterReserved(fields)
if len(names) == 0 {
return
}
pipe.SAdd(ctx, c.buildFieldsKey(hashID), toAny(names)...)
}
// addFieldLocked records a single field name in the index. Caller must hold
// c.mu. Best-effort by the same argument as the backfill: the flat key is the
// source of truth, and a lost index entry is repaired by the next scan
// fallback — so it must not fail the write it accompanies.
func (c *Client) addFieldLocked(ctx context.Context, hashID, field string) {
if isReservedFieldName(field) {
return
}
if err := c.client.SAdd(ctx, c.buildFieldsKey(hashID), field).Err(); err != nil {
logFieldIndexWriteFailure(hashID, field, err)
}
}
// removeFieldLocked drops a field name from the index. Caller must hold c.mu.
//
// A stale entry left behind by a failure is harmless (the MGET reads nil and
// skips it), so this is best-effort too.
func (c *Client) removeFieldLocked(ctx context.Context, hashID, field string) {
if isReservedFieldName(field) {
return
}
if err := c.client.SRem(ctx, c.buildFieldsKey(hashID), field).Err(); err != nil {
logFieldIndexWriteFailure(hashID, field, err)
}
}
// fieldsWithPrefixLocked returns the record's indexed field names under a
// field-name prefix (e.g. "cids/"), falling back to a SCAN when the record has
// no index yet. Caller must hold c.mu.
func (c *Client) fieldsWithPrefixLocked(ctx context.Context, hashID, fieldPrefix string) ([]string, error) {
fields, err := c.client.SMembers(ctx, c.buildFieldsKey(hashID)).Result()
if err != nil && err != redis.Nil {
return nil, fmt.Errorf("smembers fields: %w", err)
}
if len(fields) == 0 {
// Un-indexed record: scan the narrower `<prefix><fieldPrefix>*` glob
// rather than the whole record, and do not backfill from it — a
// partial view must never be mistaken for a complete index.
return c.scanFieldsWithPrefixLocked(ctx, hashID, fieldPrefix)
}
var out []string
for _, f := range fields {
if isReservedFieldName(f) {
continue
}
if strings.HasPrefix(f, fieldPrefix) {
out = append(out, strings.TrimPrefix(f, fieldPrefix))
}
}
// SMEMBERS order is arbitrary and SCAN order was too, so the CID array in
// GetMetadataDocument used to shuffle between calls. Sorting costs nothing
// at these sizes and makes the response byte-stable, which matters for
// anything diffing or caching it.
sort.Strings(out)
return out, nil
}
func (c *Client) scanFieldsWithPrefixLocked(ctx context.Context, hashID, fieldPrefix string) ([]string, error) {
prefix := c.buildKeyPrefix(hashID) + fieldPrefix
var out []string
var cursor uint64
for {
keys, next, err := c.client.Scan(ctx, cursor, prefix+"*", 1000).Result()
if err != nil {
return nil, fmt.Errorf("scan fields: %w", err)
}
for _, k := range keys {
out = append(out, strings.TrimPrefix(k, prefix))
}
cursor = next
if cursor == 0 {
break
}
}
sort.Strings(out)
return out, nil
}
// WarmFieldIndexes builds every missing field index in a SINGLE keyspace pass.
//
// Without it the indexes still appear — each record's first read falls back to
// a scan and backfills — but that transition is O(records x keyspace) and it
// runs again from scratch after any wipe. Measured on the real store (12,345
// records / 445k keys) the lazy path warmed at ~1.3 records/s: a ~2.6 HOUR tail
// during which meta-core stays pinned exactly as it was before the fix. Worse,
// `RepublishMetadata` walks every record at startup, so that tail is paid on
// every restart.
//
// One bulk `file:*` scan grouped by record collapses it to a few seconds. This
// is the same shape `GetSearchIndexTuples` already uses for the bulk read, and
// for the same reason.
//
// Idempotent, and safe to run against a partially-indexed store: SADD of a
// member that is already there is a no-op. Returns the number of records it
// wrote an index for.
func (c *Client) WarmFieldIndexes() (int, error) {
c.mu.RLock()
defer c.mu.RUnlock()
if c.client == nil {
return 0, fmt.Errorf("not connected")
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
filePrefix := c.buildKey("file:") // {filePrefix}{hashID}/{field}
indexKey := c.buildIndexKey()
byRecord := make(map[string][]string)
var cursor uint64
for {
keys, next, err := c.client.Scan(ctx, cursor, filePrefix+"*", 1000).Result()
if err != nil {
return 0, fmt.Errorf("scan: %w", err)
}
for _, k := range keys {
if k == indexKey {
continue
}
// hashID has no '/'; field may contain them.
rest := strings.TrimPrefix(k, filePrefix)
slash := strings.IndexByte(rest, '/')
if slash < 0 {
continue
}
hashID, field := rest[:slash], rest[slash+1:]
if isReservedFieldName(field) {
// Already indexed — remember the record so we don't treat it
// as missing, but there is nothing to add for this key.
if _, seen := byRecord[hashID]; !seen {
byRecord[hashID] = nil
}
continue
}
byRecord[hashID] = append(byRecord[hashID], field)
}
cursor = next
if cursor == 0 {
break
}
}
written := 0
pipe := c.client.Pipeline()
pending := 0
for hashID, fields := range byRecord {
if len(fields) == 0 {
continue
}
pipe.SAdd(ctx, c.buildFieldsKey(hashID), toAny(fields)...)
written++
pending++
// Bound the pipeline so a large store doesn't buffer every command.
if pending >= 500 {
if _, err := pipe.Exec(ctx); err != nil {
return written, fmt.Errorf("sadd pipeline: %w", err)
}
pipe = c.client.Pipeline()
pending = 0
}
}
if pending > 0 {
if _, err := pipe.Exec(ctx); err != nil {
return written, fmt.Errorf("sadd pipeline: %w", err)
}
}
return written, nil
}
// recordHasFieldsLocked reports whether a record still has at least one
// property key. Caller must hold c.mu.
//
// `__fields__` must not count — it lives under the record's own prefix, so a
// naive "does any `file:<id>/*` key exist?" probe would answer yes for a record
// whose every property has been deleted, and the root could never be
// de-indexed.
func (c *Client) recordHasFieldsLocked(ctx context.Context, hashID string) (bool, error) {
n, err := c.client.SCard(ctx, c.buildFieldsKey(hashID)).Result()
if err != nil && err != redis.Nil {
return false, fmt.Errorf("scard fields: %w", err)
}
if n > 0 {
return true, nil
}
// Either genuinely empty, or an un-indexed record from before the index
// existed — the scan is the only way to tell those apart.
prefix := c.buildKeyPrefix(hashID)
var cursor uint64
for {
keys, next, err := c.client.Scan(ctx, cursor, prefix+"*", 1000).Result()
if err != nil {
return false, fmt.Errorf("scan failed: %w", err)
}
for _, k := range keys {
if !isReservedFieldName(strings.TrimPrefix(k, prefix)) {
return true, nil
}
}
cursor = next
if cursor == 0 {
return false, nil
}
}
}
// splitRecordKey decomposes a full Redis key into (hashID, field) when it is a
// record property key. Reports ok=false for anything else — the index set
// itself, `file:__index__`, and any key outside the `file:` namespace.
//
// This is what lets the raw KV escape hatch (SetRawValue / DeleteRawKey) keep
// the index honest without its callers knowing the layout.
func (c *Client) splitRecordKey(key string) (hashID, field string, ok bool) {
rest := key
if c.prefix != "" {
var found bool
rest, found = strings.CutPrefix(rest, c.prefix)
if !found {
return "", "", false
}
}
rest, found := strings.CutPrefix(rest, "file:")
if !found {
return "", "", false
}
hashID, field, found = strings.Cut(rest, "/")
if !found || hashID == "" || field == "" {
// `file:__index__` has no slash and lands here.
return "", "", false
}
if isReservedFieldName(field) {
return "", "", false
}
return hashID, field, true
}
// The index is derived and self-healing, so a write failure degrades
// performance (the next read falls back to a scan and re-populates) rather
// than correctness. Log it rather than failing the caller's write — but log it
// loudly, because a *persistent* failure means every read is scanning again.
func logFieldIndexWriteFailure(hashID, field string, err error) {
log.Printf("[Storage] field index update failed for %s/%s: %v (reads fall back to SCAN)", hashID, field, err)
}
func logFieldIndexBackfillFailure(hashID string, err error) {
log.Printf("[Storage] field index backfill failed for %s: %v (reads keep falling back to SCAN)", hashID, err)
}
func filterReserved(fields []string) []string {
out := make([]string, 0, len(fields))
for _, f := range fields {
if !isReservedFieldName(f) {
out = append(out, f)
}
}
return out
}
func toAny(ss []string) []interface{} {
out := make([]interface{}, len(ss))
for i, s := range ss {
out[i] = s
}
return out
}
+464
View File
@@ -0,0 +1,464 @@
package storage
import (
"context"
"reflect"
"sort"
"testing"
)
// Tests for the per-record field-name index (field_index.go).
//
// The index is what makes a record read O(fields) instead of O(keyspace). It is
// derived state, so the properties worth pinning are: (1) a read served from
// the index returns exactly what a scan would have, (2) a record with no index
// still reads correctly and gets one, and (3) every write path keeps it in
// sync — because a *missing* entry silently drops a field from every
// subsequent read, which is the one failure mode that would be invisible.
//
// Every case runs with and without a namespace prefix, matching clear_test.go.
func forEachPrefix(t *testing.T, fn func(t *testing.T, c *Client)) {
t.Helper()
for _, prefix := range []string{"", "mm:"} {
name := "no-prefix"
if prefix != "" {
name = "prefix-" + prefix
}
t.Run(name, func(t *testing.T) {
c, _ := newTestClient(t, prefix)
fn(t, c)
})
}
}
func fieldNames(t *testing.T, c *Client, hashID string) []string {
t.Helper()
got, err := c.client.SMembers(context.Background(), c.buildFieldsKey(hashID)).Result()
if err != nil {
t.Fatalf("smembers fields: %v", err)
}
sort.Strings(got)
return got
}
func mustGetMetadata(t *testing.T, c *Client, hashID string) map[string]string {
t.Helper()
got, err := c.GetMetadataFlat(hashID)
if err != nil {
t.Fatalf("GetMetadataFlat: %v", err)
}
return got
}
// SetMetadataFlat must index every field it writes, and the read must come back
// through the index rather than a scan.
func TestFieldIndex_SetMetadataFlatIndexesFields(t *testing.T) {
forEachPrefix(t, func(t *testing.T, c *Client) {
want := map[string]string{
"filePath": "/files/watch/a.mkv",
"sizeByte": "123",
"cids/bafkreiaaa": "true",
"titles/jpn": "ナルト",
"categories/TV/UHD": "true",
"info/files/0/path": "a.mkv",
"provenance": `{"source":"gateway"}`,
"plot/eng": "a plot with / slashes",
"stream/3": "eng",
"genres/action": "true",
"originaltitle": "Naruto",
"deeply/nested/field/name/here": "x",
}
if err := c.SetMetadataFlat("root1", want); err != nil {
t.Fatalf("SetMetadataFlat: %v", err)
}
if got := mustGetMetadata(t, c, "root1"); !reflect.DeepEqual(got, want) {
t.Errorf("GetMetadataFlat mismatch\n got %v\nwant %v", got, want)
}
// The index must name every field — including the ones containing '/',
// which is where a naive "split on the first slash" would go wrong.
wantNames := make([]string, 0, len(want))
for k := range want {
wantNames = append(wantNames, k)
}
sort.Strings(wantNames)
if got := fieldNames(t, c, "root1"); !reflect.DeepEqual(got, wantNames) {
t.Errorf("field index mismatch\n got %v\nwant %v", got, wantNames)
}
})
}
// A record written before the index existed has flat keys but no set. The read
// must still be correct, and must leave an index behind so the next read takes
// the fast path. This is the migration path for the ~12k records already on
// disk: no flag day, no SchemaVersion bump.
func TestFieldIndex_LazyBackfillForUnindexedRecord(t *testing.T) {
forEachPrefix(t, func(t *testing.T, c *Client) {
ctx := context.Background()
prefix := c.buildKeyPrefix("legacy")
want := map[string]string{
"filePath": "/files/watch/old.mkv",
"sizeByte": "42",
"cids/bafkreiold": "true",
}
// Write the flat keys directly, bypassing every index-maintaining path
// — exactly the shape a pre-upgrade store is in.
for f, v := range want {
if err := c.client.Set(ctx, prefix+f, v, 0).Err(); err != nil {
t.Fatalf("seed %s: %v", f, err)
}
}
if names := fieldNames(t, c, "legacy"); len(names) != 0 {
t.Fatalf("precondition: expected no index, got %v", names)
}
if got := mustGetMetadata(t, c, "legacy"); !reflect.DeepEqual(got, want) {
t.Errorf("scan-fallback read mismatch\n got %v\nwant %v", got, want)
}
wantNames := []string{"cids/bafkreiold", "filePath", "sizeByte"}
if got := fieldNames(t, c, "legacy"); !reflect.DeepEqual(got, wantNames) {
t.Errorf("backfill mismatch\n got %v\nwant %v", got, wantNames)
}
// Second read, now served from the index, must agree with the first.
if got := mustGetMetadata(t, c, "legacy"); !reflect.DeepEqual(got, want) {
t.Errorf("indexed read disagrees with scan read\n got %v\nwant %v", got, want)
}
})
}
// Each single-field write path must add to the index, and each delete path must
// remove from it. A field written but not indexed vanishes from every later
// read — silently — so this is the property that matters most.
func TestFieldIndex_SingleFieldWritePathsStayInSync(t *testing.T) {
forEachPrefix(t, func(t *testing.T, c *Client) {
if err := c.SetProperty("root2", "title", "Naruto"); err != nil {
t.Fatalf("SetProperty: %v", err)
}
if _, err := c.AddToSet("root2", "tags", "anime"); err != nil {
t.Fatalf("AddToSet: %v", err)
}
if _, err := c.MergeMetadataFlat("root2", map[string]string{"year": "2002"}); err != nil {
t.Fatalf("MergeMetadataFlat: %v", err)
}
want := map[string]string{"title": "Naruto", "tags": "anime", "year": "2002"}
if got := mustGetMetadata(t, c, "root2"); !reflect.DeepEqual(got, want) {
t.Errorf("after writes\n got %v\nwant %v", got, want)
}
// DeleteProperty drops the key and the index entry.
if err := c.DeleteProperty("root2", "title"); err != nil {
t.Fatalf("DeleteProperty: %v", err)
}
// RemoveFromSet deletes the key once its last member is gone.
if _, err := c.RemoveFromSet("root2", "tags", "anime"); err != nil {
t.Fatalf("RemoveFromSet: %v", err)
}
want = map[string]string{"year": "2002"}
if got := mustGetMetadata(t, c, "root2"); !reflect.DeepEqual(got, want) {
t.Errorf("after deletes\n got %v\nwant %v", got, want)
}
if got := fieldNames(t, c, "root2"); !reflect.DeepEqual(got, []string{"year"}) {
t.Errorf("index after deletes = %v, want [year]", got)
}
})
}
// The raw KV editor can mint or drop a record property out of band. It is the
// one write path whose caller does not know the record layout, so the storage
// layer has to keep the index honest for it.
func TestFieldIndex_RawKeyEditorStaysInSync(t *testing.T) {
forEachPrefix(t, func(t *testing.T, c *Client) {
if err := c.SetRawValue("file:root3/hand-written", "v"); err != nil {
t.Fatalf("SetRawValue: %v", err)
}
if got := fieldNames(t, c, "root3"); !reflect.DeepEqual(got, []string{"hand-written"}) {
t.Errorf("index after SetRawValue = %v", got)
}
if got := mustGetMetadata(t, c, "root3"); !reflect.DeepEqual(got, map[string]string{"hand-written": "v"}) {
t.Errorf("read after SetRawValue = %v", got)
}
if _, err := c.DeleteRawKey("file:root3/hand-written"); err != nil {
t.Fatalf("DeleteRawKey: %v", err)
}
if got := fieldNames(t, c, "root3"); len(got) != 0 {
t.Errorf("index after DeleteRawKey = %v, want empty", got)
}
// Keys outside the record namespace must be left alone entirely.
if err := c.SetRawValue("cid:bafkreiaaa", "root3"); err != nil {
t.Fatalf("SetRawValue non-record: %v", err)
}
if _, _, ok := c.splitRecordKey(c.buildKey("cid:bafkreiaaa")); ok {
t.Error("cid: key was treated as a record property")
}
if _, _, ok := c.splitRecordKey(c.buildIndexKey()); ok {
t.Error("file:__index__ was treated as a record property")
}
})
}
// Mint and AddAlias write fields through their own pipelines, so they need the
// index too. AddAlias in particular is how `cids/<cid>` members appear — miss
// it and a record's CID key-set becomes invisible to every read.
func TestFieldIndex_MintAndAliasIndexTheirFields(t *testing.T) {
forEachPrefix(t, func(t *testing.T, c *Client) {
uuid, err := c.Mint("/files/watch/x.mkv", 10, 20)
if err != nil {
t.Fatalf("Mint: %v", err)
}
if got := fieldNames(t, c, uuid); !reflect.DeepEqual(got, []string{"filePath", "mtimeNano", "sizeByte"}) {
t.Errorf("index after Mint = %v", got)
}
if err := c.AddAlias(uuid, "bafkreiaaa"); err != nil {
t.Fatalf("AddAlias: %v", err)
}
got := mustGetMetadata(t, c, uuid)
if got["cids/bafkreiaaa"] != "true" {
t.Errorf("alias key-set member missing from read: %v", got)
}
// And the CID enumeration, which now reads the index instead of
// scanning, must see it.
cids, err := c.cidsForRootLocked(context.Background(), uuid)
if err != nil {
t.Fatalf("cidsForRootLocked: %v", err)
}
if !reflect.DeepEqual(cids, []string{"bafkreiaaa"}) {
t.Errorf("cidsForRoot = %v, want [bafkreiaaa]", cids)
}
})
}
// The index lives under the record's own prefix, so the "is this record empty?"
// probe would otherwise always find it and the root could never be de-indexed.
func TestFieldIndex_IndexRemoveIfEmptyIgnoresTheIndexItself(t *testing.T) {
forEachPrefix(t, func(t *testing.T, c *Client) {
if err := c.SetProperty("root4", "title", "x"); err != nil {
t.Fatalf("SetProperty: %v", err)
}
removed, err := c.IndexRemoveIfEmpty("root4")
if err != nil {
t.Fatalf("IndexRemoveIfEmpty: %v", err)
}
if removed {
t.Error("de-indexed a record that still has a field")
}
if err := c.DeleteProperty("root4", "title"); err != nil {
t.Fatalf("DeleteProperty: %v", err)
}
removed, err = c.IndexRemoveIfEmpty("root4")
if err != nil {
t.Fatalf("IndexRemoveIfEmpty: %v", err)
}
if !removed {
t.Fatal("record with no fields left was not de-indexed — the field index masked emptiness")
}
// ...and the now-empty index key must not be left orphaned.
n, err := c.client.Exists(context.Background(), c.buildFieldsKey("root4")).Result()
if err != nil {
t.Fatalf("exists: %v", err)
}
if n != 0 {
t.Error("orphaned field index left behind after de-indexing")
}
})
}
// The same probe must still work for a record that predates the index.
func TestFieldIndex_IndexRemoveIfEmptyHandlesUnindexedRecord(t *testing.T) {
forEachPrefix(t, func(t *testing.T, c *Client) {
ctx := context.Background()
if err := c.client.Set(ctx, c.buildKeyPrefix("legacy2")+"title", "x", 0).Err(); err != nil {
t.Fatalf("seed: %v", err)
}
if err := c.IndexAdd("legacy2"); err != nil {
t.Fatalf("IndexAdd: %v", err)
}
removed, err := c.IndexRemoveIfEmpty("legacy2")
if err != nil {
t.Fatalf("IndexRemoveIfEmpty: %v", err)
}
if removed {
t.Error("de-indexed an un-indexed record that still has a field")
}
})
}
// The reserved name is bookkeeping. It must never reach an API caller as a
// metadata field, and an inbound payload must never be able to overwrite the
// SET with a string — that would break every subsequent read of the record.
func TestFieldIndex_ReservedNameIsNeverMetadata(t *testing.T) {
forEachPrefix(t, func(t *testing.T, c *Client) {
err := c.SetMetadataFlat("root5", map[string]string{
"title": "x",
FieldsField: "malicious",
})
if err != nil {
t.Fatalf("SetMetadataFlat: %v", err)
}
got := mustGetMetadata(t, c, "root5")
if _, present := got[FieldsField]; present {
t.Errorf("%s surfaced as a metadata field: %v", FieldsField, got)
}
if !reflect.DeepEqual(got, map[string]string{"title": "x"}) {
t.Errorf("read = %v, want just title", got)
}
// The index must still be a SET — i.e. not clobbered by the payload.
if got := fieldNames(t, c, "root5"); !reflect.DeepEqual(got, []string{"title"}) {
t.Errorf("index = %v, want [title]", got)
}
if err := c.SetProperty("root5", FieldsField, "malicious"); err == nil {
t.Error("SetProperty accepted the reserved field name")
}
})
}
// Deleting a record must take its index with it, or a recreated root would
// inherit phantom field names.
func TestFieldIndex_DeleteMetadataRemovesTheIndex(t *testing.T) {
forEachPrefix(t, func(t *testing.T, c *Client) {
if err := c.SetMetadataFlat("root6", map[string]string{"title": "x", "year": "2002"}); err != nil {
t.Fatalf("SetMetadataFlat: %v", err)
}
if _, err := c.DeleteMetadata("root6"); err != nil {
t.Fatalf("DeleteMetadata: %v", err)
}
if got := fieldNames(t, c, "root6"); len(got) != 0 {
t.Errorf("index survived DeleteMetadata: %v", got)
}
got, err := c.GetMetadataFlat("root6")
if err != nil {
t.Fatalf("GetMetadataFlat: %v", err)
}
if got != nil {
t.Errorf("deleted record still reads back: %v", got)
}
})
}
// A stale index entry (name present, key gone) is the benign direction: the
// MGET reads nil and the field is skipped rather than erroring.
func TestFieldIndex_StaleEntryIsHarmless(t *testing.T) {
forEachPrefix(t, func(t *testing.T, c *Client) {
ctx := context.Background()
if err := c.SetMetadataFlat("root7", map[string]string{"title": "x"}); err != nil {
t.Fatalf("SetMetadataFlat: %v", err)
}
// Name a field that has no key behind it.
if err := c.client.SAdd(ctx, c.buildFieldsKey("root7"), "ghost").Err(); err != nil {
t.Fatalf("sadd ghost: %v", err)
}
got := mustGetMetadata(t, c, "root7")
if !reflect.DeepEqual(got, map[string]string{"title": "x"}) {
t.Errorf("read = %v, want just title", got)
}
})
}
// WarmFieldIndexes is what keeps an upgrade from spending hours reading
// un-indexed records one full-keyspace scan at a time. It must index every
// record in one pass, be idempotent, and leave already-indexed records alone.
func TestFieldIndex_WarmBuildsEveryMissingIndex(t *testing.T) {
forEachPrefix(t, func(t *testing.T, c *Client) {
ctx := context.Background()
// Two legacy records seeded straight into Redis (no index), plus one
// written through the normal path (already indexed).
legacy := map[string]map[string]string{
"old1": {"filePath": "/a.mkv", "cids/bafkreiaaa": "true"},
"old2": {"filePath": "/b.mkv", "titles/jpn": "ナルト", "deep/a/b": "x"},
}
for root, fields := range legacy {
for f, v := range fields {
if err := c.client.Set(ctx, c.buildKeyPrefix(root)+f, v, 0).Err(); err != nil {
t.Fatalf("seed: %v", err)
}
}
if err := c.IndexAdd(root); err != nil {
t.Fatalf("IndexAdd: %v", err)
}
}
if err := c.SetMetadataFlat("fresh", map[string]string{"title": "x"}); err != nil {
t.Fatalf("SetMetadataFlat: %v", err)
}
n, err := c.WarmFieldIndexes()
if err != nil {
t.Fatalf("WarmFieldIndexes: %v", err)
}
if n < 2 {
t.Errorf("warmed %d records, want at least the 2 legacy ones", n)
}
if got := fieldNames(t, c, "old1"); !reflect.DeepEqual(got, []string{"cids/bafkreiaaa", "filePath"}) {
t.Errorf("old1 index = %v", got)
}
if got := fieldNames(t, c, "old2"); !reflect.DeepEqual(got, []string{"deep/a/b", "filePath", "titles/jpn"}) {
t.Errorf("old2 index = %v", got)
}
if got := fieldNames(t, c, "fresh"); !reflect.DeepEqual(got, []string{"title"}) {
t.Errorf("fresh index disturbed = %v", got)
}
// Reads must now agree with what the records actually hold.
for root, want := range legacy {
if got := mustGetMetadata(t, c, root); !reflect.DeepEqual(got, want) {
t.Errorf("%s read = %v, want %v", root, got, want)
}
}
// Idempotent: a second pass changes nothing.
if _, err := c.WarmFieldIndexes(); err != nil {
t.Fatalf("second WarmFieldIndexes: %v", err)
}
if got := fieldNames(t, c, "old2"); !reflect.DeepEqual(got, []string{"deep/a/b", "filePath", "titles/jpn"}) {
t.Errorf("old2 index after second warm = %v", got)
}
})
}
// The bulk search read enumerates the whole file: keyspace and MGETs what it
// finds. The index keys are SETs living in that same namespace, so it has to
// skip them or the MGET fails with WRONGTYPE and search dies wholesale.
func TestFieldIndex_SearchIndexTuplesSkipTheIndexKeys(t *testing.T) {
forEachPrefix(t, func(t *testing.T, c *Client) {
if err := c.SetMetadataFlat("root8", map[string]string{
"title": "Naruto Shippuden",
"fileName": "naruto.mkv",
}); err != nil {
t.Fatalf("SetMetadataFlat: %v", err)
}
tuples, err := c.GetSearchIndexTuples()
if err != nil {
t.Fatalf("GetSearchIndexTuples: %v", err)
}
var found bool
for _, tu := range tuples {
if tu.HashID != "root8" {
continue
}
found = true
if _, present := tu.Fields[FieldsField]; present {
t.Errorf("%s leaked into the search tuple: %v", FieldsField, tu.Fields)
}
}
if !found {
t.Fatal("record missing from search tuples")
}
})
}
+326
View File
@@ -0,0 +1,326 @@
package storage
import (
"context"
"fmt"
"strings"
"time"
"github.com/redis/go-redis/v9"
"github.com/metazla/meta-core/internal/cid"
)
// The one-shot corpus sweep for meta-read `docs/reading-model.md` §7:
// identity and position keys are resolved, never copied from a query, and a
// card carries no position keys and no `workcid`.
//
// # Why a sweep
//
// A feeder answering a `workcid:` / `anilistid:` query could stamp the query's
// own filter values onto what it returned. The gateway persists whatever passes
// the filter, so the copy became a permanent binding — and stored records never
// pick up the writer fix: a covered `(upstream, query)` never reaches the
// feeder again, and the streaming dedup keeps meta-core's copy over a fresh
// one. So the copies already at rest are removed here, once.
//
// What the copies cost: an `anilistid` on a MangaDex site card claims an
// AniList binding nobody resolved, and meta-read reads a bare `anilistid` as an
// anchor — the site card then competes with (and can hide) the real AniList
// card for the same work. A `workcid` or `chapterNumber` on a card makes a work
// join and sort like a position inside one.
//
// # What is swept, and what deliberately is not
//
// - Only `domain=literature` + `fileType=card` records. Nothing else is read
// for writing, whatever keys it carries.
// - `workcid`, `chapterNumber`, `volumeNumber`, `chapterStart`, `chapterEnd`
// go from **every** such card: a card is a work, never a position in one,
// so no legitimate writer puts them there.
// - `anilistid` goes only from a **site card** — a card whose own `cids/`
// key-set holds a site-form card locator and no locator of any other form.
// On an anchor card the id *is* the card's identity (its locator is
// `("anilist", …)`). A card holding both forms (a cross-source merge) keeps
// it: which half the id belongs to is not recoverable at rest, and leaving
// a stamp is the safe direction.
// - **Chapters are never swept.** A bare `anilistid` on a chapter is graded
// Matched by rule, and a query-copied stamp is indistinguishable at rest
// from a title-matched one — deleting would drop real bindings with the
// fake ones.
//
// # Contract
//
// Dry-run unless `apply`: the sweep deletes keys, so the default call only
// reports. Idempotent: a swept record plans nothing on the next pass, so the
// endpoint is safe to call repeatedly and on a fresh box.
// literatureEchoAnchorSources are the card-locator sources that name an id-bag
// work (an anchor), never a site.
//
// ⚠ Mirror of meta-read `card_cid::is_host_form`. meta-read spells out only
// `anilist` and `openlibrary`; the others carry no '.', so the '.' test already
// excludes them and the two rules agree on every locator minted today. They are
// listed so the rule reads on its own here.
var literatureEchoAnchorSources = map[string]bool{
"anilist": true,
"openlibrary": true,
"tmdb": true,
"imdb": true,
"musicbrainz": true,
"mal": true,
}
// literatureCardEchoFields are removed from every literature card carrying
// them — position keys and the work reference, none of which a card may hold.
var literatureCardEchoFields = []string{
"workcid",
"chapterNumber",
"volumeNumber",
"chapterStart",
"chapterEnd",
}
// literatureEchoReadFields is the per-record MGET layout: the two gate fields,
// then `anilistid`, then the echo fields in literatureCardEchoFields order.
var literatureEchoReadFields = append(
[]string{"domain", "fileType", "anilistid"},
literatureCardEchoFields...,
)
// LiteratureEchoSweep is the result of one sweep pass. Planned and Fixed count
// records; ByKey counts fields per key — removed on an apply pass, that would
// be removed on a dry run. ByKey is never nil so it serialises as `{}`.
type LiteratureEchoSweep struct {
Planned int
Fixed int
ByKey map[string]int
}
// literatureEchoPlan is one record's pending deletes, in a fixed order
// (`anilistid` first, then literatureCardEchoFields order).
type literatureEchoPlan struct {
hashID string
fields []string
}
// isSiteFormCardSource is the site-form rule on an already-decoded source: not
// an anchor source, and shaped like a host.
func isSiteFormCardSource(source string) bool {
return !literatureEchoAnchorSources[source] && strings.Contains(source, ".")
}
// isSiteFormCardLocator reports whether a bare CID is a card locator (0x1007)
// whose source is a site (`mangadex.org`), not an anchor (`anilist`).
func isSiteFormCardLocator(cidStr string) bool {
source, _, err := cid.DecodeCard(cidStr)
return err == nil && isSiteFormCardSource(source)
}
// isSiteCard reports whether a card's field names prove it a site card: at
// least one site-form `cids/` card locator and no card locator of another form.
// Non-card members (a content CID) say nothing about which work the card is and
// are ignored. An empty field list — a record with no field-name index — is
// never a site card, which keeps its `anilistid`; see planLiteratureEchoes.
func isSiteCard(fields []string) bool {
site := false
for _, f := range fields {
member := cidFromKeysetField(f)
if member == "" {
continue
}
source, _, err := cid.DecodeCard(member)
if err != nil {
continue
}
if !isSiteFormCardSource(source) {
return false
}
site = true
}
return site
}
// SweepLiteratureEchoes removes query echoes stored on literature cards (see
// the file comment). With apply=false it only plans.
//
// Reads in batched passes (never a per-record SCAN — a single record read
// costs a full keyspace sweep on this store), then deletes only the planned
// fields, through DeleteProperty so the field-name index and the search-dirty
// set stay consistent.
func (c *Client) SweepLiteratureEchoes(ctx context.Context, apply bool) (LiteratureEchoSweep, error) {
res := LiteratureEchoSweep{ByKey: map[string]int{}}
plans, err := c.planLiteratureEchoes(ctx)
if err != nil {
return res, err
}
res.Planned = len(plans)
if !apply {
for _, p := range plans {
for _, f := range p.fields {
res.ByKey[f]++
}
}
return res, nil
}
// Writes take the client lock themselves, so they happen after the read
// phase has released it. ByKey counts what was actually deleted, so a
// failure midway reports the true partial state.
for _, p := range plans {
// DeleteProperty carries its own per-call timeout; checking ctx here is
// what makes the wrapper's deadline bound the whole write phase.
if err := ctx.Err(); err != nil {
return res, err
}
for _, f := range p.fields {
if err := c.DeleteProperty(p.hashID, f); err != nil {
return res, fmt.Errorf("delete %s on %s: %w", f, p.hashID, err)
}
res.ByKey[f]++
}
res.Fixed++
}
return res, nil
}
// planLiteratureEchoes is the read half: what would be deleted, without
// deleting it.
func (c *Client) planLiteratureEchoes(ctx context.Context) ([]literatureEchoPlan, error) {
c.mu.RLock()
defer c.mu.RUnlock()
if c.client == nil {
return nil, fmt.Errorf("not connected")
}
hashIDs, err := c.client.SMembers(ctx, c.buildIndexKey()).Result()
if err != nil {
return nil, fmt.Errorf("smembers index: %w", err)
}
if len(hashIDs) == 0 {
return nil, nil
}
// Pass 1: the gate and echo fields for every record, in a fixed order so
// the MGET response decodes by position: keys[i*per+n] belongs to
// hashIDs[i].
per := len(literatureEchoReadFields)
keys := make([]string, 0, len(hashIDs)*per)
for _, h := range hashIDs {
prefix := c.buildKeyPrefix(h)
for _, f := range literatureEchoReadFields {
keys = append(keys, prefix+f)
}
}
// Chunked so one MGET never carries the whole corpus.
const chunk = 3000
values := make([]interface{}, 0, len(keys))
for start := 0; start < len(keys); start += chunk {
end := start + chunk
if end > len(keys) {
end = len(keys)
}
got, err := c.client.MGet(ctx, keys[start:end]...).Result()
if err != nil {
return nil, fmt.Errorf("mget: %w", err)
}
values = append(values, got...)
}
// A nil slot is an absent key. A present key holding "" is still an echo
// and still goes, so presence — not the value — decides.
get := func(i int) (string, bool) {
if i >= len(values) || values[i] == nil {
return "", false
}
s, ok := values[i].(string)
return s, ok
}
type candidate struct {
hashID string
hasAnilist bool
echoes []string
}
var cands []candidate
var probe []string // candidates whose anilistid needs their cids/ key-set
for i, h := range hashIDs {
base := i * per
domain, _ := get(base)
fileType, _ := get(base + 1)
if domain != "literature" || fileType != "card" {
continue
}
cand := candidate{hashID: h}
_, cand.hasAnilist = get(base + 2)
for j, f := range literatureCardEchoFields {
if _, ok := get(base + 3 + j); ok {
cand.echoes = append(cand.echoes, f)
}
}
if !cand.hasAnilist && len(cand.echoes) == 0 {
continue
}
if cand.hasAnilist {
probe = append(probe, h)
}
cands = append(cands, cand)
}
// Pass 2: the field-name index of only the cards carrying an `anilistid`
// — the `cids/` members are field NAMES, so the index is where they are
// listed. Pipelined per chunk; still no SCAN.
//
// A card with no index (never touched since before the index existed, and
// missed by the startup WarmFieldIndexes pass) reads as an empty set and
// keeps its `anilistid`: proving it a site card would need exactly the
// per-record SCAN this sweep refuses, and a stamp left in place is the
// safe direction.
siteCard := make(map[string]bool, len(probe))
for start := 0; start < len(probe); start += mgetBatch {
end := start + mgetBatch
if end > len(probe) {
end = len(probe)
}
pipe := c.client.Pipeline()
cmds := make([]*redis.StringSliceCmd, 0, end-start)
for _, h := range probe[start:end] {
cmds = append(cmds, pipe.SMembers(ctx, c.buildFieldsKey(h)))
}
if _, err := pipe.Exec(ctx); err != nil && err != redis.Nil {
return nil, fmt.Errorf("smembers fields: %w", err)
}
for k, cmd := range cmds {
fields, err := cmd.Result()
if err != nil && err != redis.Nil {
return nil, fmt.Errorf("smembers fields %s: %w", probe[start+k], err)
}
siteCard[probe[start+k]] = isSiteCard(fields)
}
}
plans := make([]literatureEchoPlan, 0, len(cands))
for _, cand := range cands {
var fields []string
if cand.hasAnilist && siteCard[cand.hashID] {
fields = append(fields, "anilistid")
}
fields = append(fields, cand.echoes...)
if len(fields) > 0 {
plans = append(plans, literatureEchoPlan{hashID: cand.hashID, fields: fields})
}
}
return plans, nil
}
// SweepLiteratureEchoesWithTimeout is the convenience wrapper used by the API
// handler. Generous timeout: the read phase is batched, but the write phase is
// one round-trip per deleted field.
func (c *Client) SweepLiteratureEchoesWithTimeout(apply bool) (LiteratureEchoSweep, error) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
return c.SweepLiteratureEchoes(ctx, apply)
}
@@ -0,0 +1,249 @@
package storage
import (
"context"
"encoding/base32"
"encoding/binary"
"reflect"
"strings"
"testing"
)
// Pinned from meta-read `src/card_cid.rs` — literals, not round-trips.
const (
literatureAnchorCardCID = "bagdsaaaua5qw42lmnfzxi3lbnztwcorrga2tonzy" // ("anilist", "manga:105778")
literatureSiteCardCID = "bagdsaabybrwwc3thmfsgk6bon5zgol3nmfxgoyjpmiygenzsgftgmlldgm4dqljugq4dmllbmeygmlldgjrdaytcgmzdcnjrgi"
// A non-card CID a card may also carry.
literatureContentCID = "bagacbabaec7v3fu2ygzh3e2sybg3fbzmisry2hbtpmck6vx3yftea6vzq35r4"
)
// mintCardCID encodes a card locator the way meta-feeder-sdk
// `compute_card_cid` does. Asserted against the pinned literals before use.
func mintCardCID(source, id string) string {
digest := binary.AppendUvarint(nil, uint64(len(source)))
digest = append(digest, source...)
digest = append(digest, id...)
wire := []byte{0x01}
wire = binary.AppendUvarint(wire, 0x1007)
wire = append(wire, 0x00)
wire = binary.AppendUvarint(wire, uint64(len(digest)))
wire = append(wire, digest...)
return "b" + strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(wire))
}
func TestIsSiteFormCardLocator(t *testing.T) {
if got := mintCardCID("anilist", "manga:105778"); got != literatureAnchorCardCID {
t.Fatalf("test encoder drifted: %s != %s", got, literatureAnchorCardCID)
}
if got := mintCardCID("mangadex.org", "/manga/b0b721ff-c388-4486-aa0f-c2b0bb321512"); got != literatureSiteCardCID {
t.Fatalf("test encoder drifted: %s != %s", got, literatureSiteCardCID)
}
for _, tc := range []struct {
name string
cid string
want bool
}{
{"anilist anchor (golden)", literatureAnchorCardCID, false},
{"mangadex site (golden)", literatureSiteCardCID, true},
{"openlibrary anchor", mintCardCID("openlibrary", "OL45804W"), false},
{"another site", mintCardCID("weebcentral.com", "/series/01J76XY/chainsaw-man"), true},
// Not an anchor source, but not host-shaped either: meta-read reads it
// as an id-bag key, so it must not be treated as a site.
{"dotless unknown source", mintCardCID("suwayomi", "42"), false},
{"content CID", literatureContentCID, false},
{"title key", "title:chainsaw man", false},
} {
if got := isSiteFormCardLocator(tc.cid); got != tc.want {
t.Errorf("%s: isSiteFormCardLocator = %v, want %v", tc.name, got, tc.want)
}
}
}
// seedLiteratureCorpus writes one record per rule the sweep has to honour.
func seedLiteratureCorpus(t *testing.T, c *Client) {
t.Helper()
// A site card carrying every echo: all of it goes.
seedRecord(t, c, "site-card", map[string]string{
"domain": "literature",
"fileType": "card",
"title": "Chainsaw Man",
"cids/" + literatureSiteCardCID: "true",
"anilistid": "105778",
"workcid": literatureSiteCardCID,
"chapterNumber": "12",
"volumeNumber": "2",
"chapterStart": "1",
"chapterEnd": "20",
})
// An anchor card: its anilistid is its identity and stays; the position
// echo and the workcid still go.
seedRecord(t, c, "anchor-card", map[string]string{
"domain": "literature",
"fileType": "card",
"title": "Chainsaw Man",
"cids/" + literatureAnchorCardCID: "true",
"anilistid": "105778",
"workcid": literatureAnchorCardCID,
"chapterNumber": "3",
})
// A site card that is already clean: nothing planned.
seedRecord(t, c, "clean-site-card", map[string]string{
"domain": "literature",
"fileType": "card",
"cids/" + literatureSiteCardCID: "true",
})
// Both locator forms: which one the id belongs to is unknowable, so it
// stays.
seedRecord(t, c, "merged-card", map[string]string{
"domain": "literature",
"fileType": "card",
"cids/" + literatureSiteCardCID: "true",
"cids/" + literatureAnchorCardCID: "true",
"anilistid": "105778",
})
// No card locator at all: not provably a site card.
seedRecord(t, c, "unlocated-card", map[string]string{
"domain": "literature",
"fileType": "card",
"cids/" + literatureContentCID: "true",
"anilistid": "105778",
})
// A stored empty value is still an echo.
seedRecord(t, c, "blank-echo-card", map[string]string{
"domain": "literature",
"fileType": "card",
"volumeNumber": "",
})
// A chapter is never swept, whatever it carries.
seedRecord(t, c, "chapter", map[string]string{
"domain": "literature",
"fileType": "archive",
"cids/" + literatureContentCID: "true",
"anilistid": "105778",
"workcid": literatureSiteCardCID,
"chapterNumber": "12",
})
// Not literature: out of scope even though it is a card.
seedRecord(t, c, "screen-card", map[string]string{
"domain": "screen",
"fileType": "card",
"workcid": literatureSiteCardCID,
"chapterNumber": "1",
})
}
func TestPlanLiteratureEchoes_ScopesToLiteratureCards(t *testing.T) {
c, _ := newTestClient(t, "")
seedLiteratureCorpus(t, c)
plans, err := c.planLiteratureEchoes(context.Background())
if err != nil {
t.Fatalf("plan: %v", err)
}
got := map[string][]string{}
for _, p := range plans {
got[p.hashID] = p.fields
}
want := map[string][]string{
"site-card": {"anilistid", "workcid", "chapterNumber", "volumeNumber", "chapterStart", "chapterEnd"},
"anchor-card": {"workcid", "chapterNumber"},
"blank-echo-card": {"volumeNumber"},
}
if !reflect.DeepEqual(got, want) {
t.Errorf("plan =\n %v\nwant\n %v", got, want)
}
}
func TestSweepLiteratureEchoes_DryRunThenApply(t *testing.T) {
c, s := newTestClient(t, "mm:")
seedLiteratureCorpus(t, c)
wantByKey := map[string]int{
"anilistid": 1,
"workcid": 2,
"chapterNumber": 2,
"volumeNumber": 2,
"chapterStart": 1,
"chapterEnd": 1,
}
// Dry run: counts, and nothing written.
dry, err := c.SweepLiteratureEchoes(context.Background(), false)
if err != nil {
t.Fatalf("dry run: %v", err)
}
if dry.Planned != 3 || dry.Fixed != 0 {
t.Errorf("dry run planned/fixed = %d/%d, want 3/0", dry.Planned, dry.Fixed)
}
if !reflect.DeepEqual(dry.ByKey, wantByKey) {
t.Errorf("dry run byKey = %v, want %v", dry.ByKey, wantByKey)
}
if m, _ := c.GetMetadataFlat("site-card"); m["anilistid"] != "105778" {
t.Fatalf("dry run deleted anilistid: %v", m)
}
applied, err := c.SweepLiteratureEchoes(context.Background(), true)
if err != nil {
t.Fatalf("apply: %v", err)
}
if applied.Planned != 3 || applied.Fixed != 3 {
t.Errorf("apply planned/fixed = %d/%d, want 3/3", applied.Planned, applied.Fixed)
}
if !reflect.DeepEqual(applied.ByKey, wantByKey) {
t.Errorf("apply byKey = %v, want %v", applied.ByKey, wantByKey)
}
site, err := c.GetMetadataFlat("site-card")
if err != nil {
t.Fatalf("read site-card: %v", err)
}
for _, k := range []string{"anilistid", "workcid", "chapterNumber", "volumeNumber", "chapterStart", "chapterEnd"} {
if _, ok := site[k]; ok {
t.Errorf("site-card still carries %s", k)
}
// The flat key and its field-name index entry must both be gone, or the
// next read would MGET a ghost field.
if s.Exists("mm:file:site-card/" + k) {
t.Errorf("site-card flat key %s survived", k)
}
if member, _ := s.IsMember("mm:file:site-card/"+FieldsField, k); member {
t.Errorf("site-card field index still lists %s", k)
}
}
if site["title"] != "Chainsaw Man" || site["cids/"+literatureSiteCardCID] != "true" {
t.Errorf("site-card lost fields it should keep: %v", site)
}
anchor, _ := c.GetMetadataFlat("anchor-card")
if anchor["anilistid"] != "105778" {
t.Errorf("anchor-card anilistid = %q, want kept", anchor["anilistid"])
}
if _, ok := anchor["workcid"]; ok {
t.Errorf("anchor-card still carries workcid")
}
chapter, _ := c.GetMetadataFlat("chapter")
if chapter["anilistid"] != "105778" || chapter["workcid"] != literatureSiteCardCID || chapter["chapterNumber"] != "12" {
t.Errorf("chapter was touched: %v", chapter)
}
screen, _ := c.GetMetadataFlat("screen-card")
if screen["workcid"] != literatureSiteCardCID {
t.Errorf("screen-card was touched: %v", screen)
}
merged, _ := c.GetMetadataFlat("merged-card")
if merged["anilistid"] != "105778" {
t.Errorf("merged-card anilistid = %q, want kept", merged["anilistid"])
}
// Idempotent: the endpoint is called by hand, and a second call must find
// nothing — with byKey still an empty map, not nil.
again, err := c.SweepLiteratureEchoes(context.Background(), true)
if err != nil {
t.Fatalf("second pass: %v", err)
}
if again.Planned != 0 || again.Fixed != 0 || again.ByKey == nil || len(again.ByKey) != 0 {
t.Errorf("second pass = %+v, want nothing planned and an empty byKey", again)
}
}
+214
View File
@@ -0,0 +1,214 @@
package storage
import (
"context"
"fmt"
"log"
"strings"
"time"
)
// Incremental search-index maintenance.
//
// # Why this exists
//
// The API's in-memory search index used to be rebuilt in full every 60 s:
// GetSearchIndexTuples does a whole-keyspace SCAN plus batched MGETs, builds an
// intermediate map of every record, then allocates a fresh []SearchTuple — all
// while the previous index is still live serving reads. That cost is O(corpus),
// not O(change): adding 200 records in a minute re-read every one of the other
// ~620 000 field keys to notice them.
//
// Measured on a 19 622-record staging corpus (2026-08-30): SCAN was 57.8 % and
// MGET 19.1 % of ALL Redis CPU, and ~90 % of the MGET calls plus ~48 % of the
// SCAN calls were this loop — roughly 45 % of Redis's total work, spent
// continuously whether or not anything changed. The Go side held 783 MB for
// data Redis stores in 135 MB, largely because of the double copy.
//
// That was tolerable while the corpus was small and mostly static. It stops
// being tolerable now that meta-core is the platform's permanent, additive
// store: every search hit on every gateway is retained forever, so the rebuild
// cost is a ratchet.
//
// # The shape
//
// Every write path marks its record in a dirty set; the refresh drains that set
// and re-reads only those records. Cost becomes O(change).
//
// Marking rides along with the field-index maintenance every write path already
// performs, so there is no new bookkeeping discipline to remember — if you are
// adding a write path, the same line that keeps `__fields__` correct keeps this
// correct.
//
// # Correctness
//
// The drain is a destructive SPOP, so a write that lands *during* a refresh is
// either already in the drained batch or still in the set for the next tick —
// never dropped. The cost of the race is one tick of staleness, which is well
// inside the interval the index was already stale by.
//
// A refresh that fails after draining WOULD lose its batch, so drainDirty's
// caller re-marks on error (see MarkDirty / the API's refreshIncremental).
//
// ⚠ **The destructive drain assumes ONE index consumer per Redis.** Two
// meta-core processes sharing a keyspace would steal each other's marks, and
// each would end up with an index missing whatever the other drained. That is
// fine today — multi-instance leader election was deprecated and the dev/prod
// stacks run a single meta-core — but it is the assumption to revisit first if
// a second instance is ever pointed at the same Redis. A per-consumer dirty key
// (`file:__dirty__:<consumer>`) is the shape that would fix it.
//
// The API's periodic full reconcile (searchIndexReconcileEvery) is the backstop
// for both this and for a write path that forgets to mark: a missed mark costs
// staleness until the next reconcile, never permanent divergence.
// dirtyField is the reserved key holding the set of record ids whose metadata
// changed since the last search-index refresh. It sits under the same `file:`
// namespace as `__index__` and is likewise not a record.
const dirtyField = "file:__dirty__"
// dirtyDrainCap bounds one incremental refresh. A burst larger than this is
// drained over several ticks rather than in one long-held pass — the point of
// the change is to keep any single refresh cheap. Sized well above a normal
// search-persist flush (which writes at most a few hundred records).
const dirtyDrainCap = 5000
// buildDirtyKey constructs the key for the dirty-record set.
func (c *Client) buildDirtyKey() string {
return c.buildKey(dirtyField)
}
// markDirtyLocked records that hashID's metadata changed. Caller must hold c.mu.
//
// Best-effort by design: a failure here costs one record its incremental
// refresh, and the periodic full rebuild still repairs it. Failing the caller's
// write instead would trade a stale index entry for a lost write, which is
// strictly worse.
func (c *Client) markDirtyLocked(ctx context.Context, hashID string) {
if hashID == "" {
return
}
if err := c.client.SAdd(ctx, c.buildDirtyKey(), hashID).Err(); err != nil {
log.Printf("[STORAGE] dirty-mark failed for %s: %v (index repairs on the next full rebuild)", hashID, err)
}
}
// MarkDirty records that hashID needs re-indexing. Exported for the API layer,
// which re-marks a batch whose refresh failed after the drain.
func (c *Client) MarkDirty(hashIDs ...string) {
c.mu.RLock()
defer c.mu.RUnlock()
if c.client == nil || len(hashIDs) == 0 {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
members := make([]interface{}, 0, len(hashIDs))
for _, h := range hashIDs {
if h != "" {
members = append(members, h)
}
}
if len(members) == 0 {
return
}
if err := c.client.SAdd(ctx, c.buildDirtyKey(), members...).Err(); err != nil {
log.Printf("[STORAGE] dirty re-mark failed for %d records: %v", len(members), err)
}
}
// DrainDirty removes and returns up to dirtyDrainCap record ids from the dirty
// set. `more` reports whether entries remain, so the caller can schedule
// another pass instead of waiting a full interval to catch up on a burst.
//
// Destructive on purpose: SPOP makes concurrent refreshes disjoint and stops a
// record being re-read every tick forever. The caller owns the drained ids
// until it has indexed them — on failure it must MarkDirty them again.
func (c *Client) DrainDirty() (ids []string, more bool, err error) {
c.mu.RLock()
defer c.mu.RUnlock()
if c.client == nil {
return nil, false, fmt.Errorf("not connected")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
key := c.buildDirtyKey()
ids, err = c.client.SPopN(ctx, key, dirtyDrainCap).Result()
if err != nil {
return nil, false, fmt.Errorf("spop dirty: %w", err)
}
if len(ids) < dirtyDrainCap {
return ids, false, nil
}
remaining, err := c.client.SCard(ctx, key).Result()
if err != nil {
// We have the batch; not knowing the remainder just means the next
// tick picks it up on schedule.
return ids, false, nil
}
return ids, remaining > 0, nil
}
// GetSearchIndexTuplesFor reads the search tuples for a specific set of records
// — the incremental counterpart to GetSearchIndexTuples.
//
// Reads go through the per-record field index (GetMetadataFlat), so this is two
// round-trips per record rather than a keyspace scan. That trade is what makes
// it O(change): it beats the full pass whenever the changed set is a small
// fraction of the corpus, and the caller bounds the batch (dirtyDrainCap).
//
// A record present in the dirty set but absent from storage was deleted; it is
// reported in `gone` so the caller can evict it from the index rather than
// leaving a tombstone that still answers searches.
func (c *Client) GetSearchIndexTuplesFor(hashIDs []string) (tuples []SearchTuple, gone []string, err error) {
if len(hashIDs) == 0 {
return nil, nil, nil
}
excluded := indexExcludePrefixes()
tuples = make([]SearchTuple, 0, len(hashIDs))
for _, hashID := range hashIDs {
fields, ferr := c.GetMetadataFlat(hashID)
if ferr != nil {
// Re-mark so the record is retried rather than silently missing
// from the index until the next full rebuild.
c.MarkDirty(hashID)
log.Printf("[STORAGE] incremental index read failed for %s: %v", hashID, ferr)
continue
}
if len(fields) == 0 {
gone = append(gone, hashID)
continue
}
for field := range fields {
if hasAnyPrefix(field, excluded) {
delete(fields, field)
}
}
tuples = append(tuples, SearchTuple{
HashID: hashID,
Haystack: buildHaystack(fields),
Fields: fields,
})
}
return tuples, gone, nil
}
// buildHaystack folds a record's searchable text fields into one lowercased
// blob. Shared by the full and incremental index reads so the two can never
// disagree about what is searchable — a drift there would make a record's
// matchability depend on which path happened to index it.
func buildHaystack(fields map[string]string) string {
var sb strings.Builder
for _, f := range searchIndexFields {
if v, ok := fields[f]; ok && v != "" {
sb.WriteString(strings.ToLower(v))
sb.WriteByte('\n')
}
}
return sb.String()
}
+211
View File
@@ -0,0 +1,211 @@
package storage
import (
"context"
"sort"
"testing"
)
// Tests for incremental search-index maintenance (search_dirty.go).
//
// The dirty set is derived state on the hot write path, which makes its failure
// mode the dangerous kind: a MISSING mark doesn't error, it just leaves the
// search index quietly serving a stale copy of that record until someone
// triggers a full rebuild. So the property worth pinning is coverage — every
// path that changes what a search would match must mark — and that is what the
// bulk of these tests assert, one write path at a time.
//
// The second property is that a delete is a mark too, not an unmark: the
// refresh has to LEARN the record is gone in order to evict it. Marking on
// delete is counter-intuitive enough to be worth a test of its own.
func dirtyMembers(t *testing.T, c *Client) []string {
t.Helper()
got, err := c.client.SMembers(context.Background(), c.buildDirtyKey()).Result()
if err != nil {
t.Fatalf("smembers dirty: %v", err)
}
sort.Strings(got)
return got
}
func clearDirty(t *testing.T, c *Client) {
t.Helper()
if err := c.client.Del(context.Background(), c.buildDirtyKey()).Err(); err != nil {
t.Fatalf("del dirty: %v", err)
}
}
// Every write path must mark. A path that doesn't leaves the index serving a
// stale record with no error anywhere.
func TestDirty_EveryWritePathMarks(t *testing.T) {
forEachPrefix(t, func(t *testing.T, c *Client) {
cases := []struct {
name string
id string
write func(id string) error
}{
{"SetMetadataFlat", "r-set", func(id string) error {
return c.SetMetadataFlat(id, map[string]string{"title": "A"})
}},
{"SetProperty", "r-prop", func(id string) error {
return c.SetProperty(id, "title", "B")
}},
{"MergeMetadataFlat", "r-merge", func(id string) error {
_, err := c.MergeMetadataFlat(id, map[string]string{"title": "C"})
return err
}},
{"AddToSet", "r-add", func(id string) error {
_, err := c.AddToSet(id, "tags", "x")
return err
}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
clearDirty(t, c)
if err := tc.write(tc.id); err != nil {
t.Fatalf("%s: %v", tc.name, err)
}
got := dirtyMembers(t, c)
if len(got) != 1 || got[0] != tc.id {
t.Fatalf("%s must mark %q dirty, got %v", tc.name, tc.id, got)
}
})
}
})
}
// RemoveFromSet's non-empty branch changes a field's VALUE without changing the
// field-name index, so it is the one path that a mark riding on field-index
// maintenance would have missed. Pinned explicitly for that reason.
func TestDirty_RemoveFromSetMarksEvenWhenTheFieldSurvives(t *testing.T) {
forEachPrefix(t, func(t *testing.T, c *Client) {
const id = "r-rem"
for _, v := range []string{"x", "y"} {
if _, err := c.AddToSet(id, "tags", v); err != nil {
t.Fatalf("AddToSet: %v", err)
}
}
clearDirty(t, c)
if _, err := c.RemoveFromSet(id, "tags", "x"); err != nil {
t.Fatalf("RemoveFromSet: %v", err)
}
// `tags` still exists (y remains) — no field-index change, but the
// searchable value changed.
if got := dirtyMembers(t, c); len(got) != 1 || got[0] != id {
t.Fatalf("a value-only change must still mark dirty, got %v", got)
}
})
}
// A delete MARKS rather than unmarks: the refresh must read the record, find it
// gone, and evict it. Unmarking would leave a deleted record answering searches
// until the next full rebuild.
func TestDirty_DeletesMarkSoTheRefreshCanEvict(t *testing.T) {
forEachPrefix(t, func(t *testing.T, c *Client) {
const id = "r-del"
if err := c.SetMetadataFlat(id, map[string]string{"title": "Doomed"}); err != nil {
t.Fatalf("SetMetadataFlat: %v", err)
}
clearDirty(t, c)
if _, err := c.DeleteMetadata(id); err != nil {
t.Fatalf("DeleteMetadata: %v", err)
}
if got := dirtyMembers(t, c); len(got) != 1 || got[0] != id {
t.Fatalf("a delete must mark dirty so the index evicts it, got %v", got)
}
// …and the incremental read reports it as gone rather than as an error
// or an empty-but-present tuple.
tuples, gone, err := c.GetSearchIndexTuplesFor([]string{id})
if err != nil {
t.Fatalf("GetSearchIndexTuplesFor: %v", err)
}
if len(tuples) != 0 {
t.Fatalf("a deleted record must produce no tuple, got %d", len(tuples))
}
if len(gone) != 1 || gone[0] != id {
t.Fatalf("a deleted record must be reported gone, got %v", gone)
}
})
}
// The drain is destructive so concurrent refreshes stay disjoint and a record
// isn't re-read every tick forever. The caller owns what it drained.
func TestDirty_DrainIsDestructive(t *testing.T) {
forEachPrefix(t, func(t *testing.T, c *Client) {
clearDirty(t, c)
c.MarkDirty("a", "b", "c")
ids, more, err := c.DrainDirty()
if err != nil {
t.Fatalf("DrainDirty: %v", err)
}
sort.Strings(ids)
if len(ids) != 3 {
t.Fatalf("expected 3 drained ids, got %v", ids)
}
if more {
t.Fatal("a fully-drained set must not report more")
}
if got := dirtyMembers(t, c); len(got) != 0 {
t.Fatalf("drain must empty the set, got %v", got)
}
// A second drain is empty, not an error — the steady state when nothing
// has been written since the last tick.
ids, _, err = c.DrainDirty()
if err != nil || len(ids) != 0 {
t.Fatalf("draining an empty set must be a clean no-op, got %v / %v", ids, err)
}
})
}
// The incremental read must produce tuples indistinguishable from the full
// rebuild's — same haystack, same fields, same exclusions. A drift here would
// make a record's matchability depend on which path happened to index it, which
// is invisible until someone searches for the one term that differs.
func TestDirty_IncrementalTuplesMatchTheFullRebuild(t *testing.T) {
forEachPrefix(t, func(t *testing.T, c *Client) {
const id = "r-parity"
if err := c.SetMetadataFlat(id, map[string]string{
"title": "Parity Release",
"fileName": "parity.s01e01.mkv",
"fileType": "video",
"categories/XXX": "true",
"categories/newznab/5070": "true",
}); err != nil {
t.Fatalf("SetMetadataFlat: %v", err)
}
full := tuplesByHashID(t, c)[id]
incr, gone, err := c.GetSearchIndexTuplesFor([]string{id})
if err != nil {
t.Fatalf("GetSearchIndexTuplesFor: %v", err)
}
if len(gone) != 0 || len(incr) != 1 {
t.Fatalf("expected exactly one live tuple, got %d live / %d gone", len(incr), len(gone))
}
if incr[0].Haystack != full.Haystack {
t.Errorf("haystack drift:\n incremental %q\n full %q", incr[0].Haystack, full.Haystack)
}
if len(incr[0].Fields) != len(full.Fields) {
t.Errorf("field-count drift: incremental %d, full %d", len(incr[0].Fields), len(full.Fields))
}
for k, v := range full.Fields {
if incr[0].Fields[k] != v {
t.Errorf("field %q drift: incremental %q, full %q", k, incr[0].Fields[k], v)
}
}
// The index projection must apply on both paths.
if _, ok := incr[0].Fields["categories/newznab/5070"]; ok {
t.Error("the incremental path must honour the index exclusion list too")
}
if _, ok := incr[0].Fields["categories/XXX"]; !ok {
t.Error("the incremental path must keep the adult-filter category")
}
})
}
@@ -0,0 +1,116 @@
package storage
import (
"testing"
)
// Tests for the search-index field projection (GetSearchIndexTuples +
// indexExcludePrefixes).
//
// The projection narrows the in-memory index only — Redis keeps every field and
// every per-record read still returns it. What has to be pinned is the boundary
// between the two, because getting it wrong in either direction is silent:
// exclude too little and the index keeps paying for fields nothing reads;
// exclude too much and a record served *from the index* comes back missing a
// field a downstream consumer depends on.
//
// The specific hazard the default guards is adult-content filtering:
// `categories/XXX` is the server-side enforcement point (meta-search's
// `query_eval::fields_are_adult`, meta-gateway's `cards::fields_are_adult`),
// and `exclude_adult` defaults to ON. Excluding the whole `categories/` family
// would make every index-served record read as non-adult and the filter would
// fail OPEN — so the default must keep `categories/XXX` while dropping the
// reader-less `categories/newznab/` mirror.
func tuplesByHashID(t *testing.T, c *Client) map[string]SearchTuple {
t.Helper()
got, err := c.GetSearchIndexTuples()
if err != nil {
t.Fatalf("GetSearchIndexTuples: %v", err)
}
out := make(map[string]SearchTuple, len(got))
for _, tu := range got {
out[tu.HashID] = tu
}
return out
}
func TestSearchIndex_ExcludesNewznabCategoriesButKeepsTheRest(t *testing.T) {
forEachPrefix(t, func(t *testing.T, c *Client) {
const id = "root-1"
if err := c.SetMetadataFlat(id, map[string]string{
"title": "Some Release",
"fileType": "video",
"categories/TV/Anime": "true",
"categories/XXX": "true",
"categories/newznab/5070": "true",
"categories/newznab/2040": "true",
}); err != nil {
t.Fatalf("SetMetadataFlat: %v", err)
}
fields := tuplesByHashID(t, c)[id].Fields
if fields == nil {
t.Fatal("record missing from the search index")
}
for _, excluded := range []string{"categories/newznab/5070", "categories/newznab/2040"} {
if _, ok := fields[excluded]; ok {
t.Errorf("%s must not be indexed — it is a reader-less mirror vocabulary", excluded)
}
}
// The load-bearing ones must survive. XXX especially: without it the
// adult filter fails open on every index-served record.
for _, kept := range []string{"categories/XXX", "categories/TV/Anime", "title", "fileType"} {
if _, ok := fields[kept]; !ok {
t.Errorf("%s must stay in the index", kept)
}
}
// The store itself is untouched — the projection is index-only.
stored, err := c.GetMetadataFlat(id)
if err != nil {
t.Fatalf("GetMetadataFlat: %v", err)
}
if _, ok := stored["categories/newznab/5070"]; !ok {
t.Error("an index-excluded field must still be stored and readable per-record")
}
})
}
func TestIndexExcludePrefixes_EnvOverride(t *testing.T) {
if got := indexExcludePrefixes(); len(got) != 1 || got[0] != "categories/newznab/" {
t.Fatalf("unset env must yield the default, got %v", got)
}
t.Setenv("META_CORE_INDEX_EXCLUDE_PREFIXES", "foo/, bar/ ,")
got := indexExcludePrefixes()
if len(got) != 2 || got[0] != "foo/" || got[1] != "bar/" {
t.Fatalf("override must trim and drop blanks, got %v", got)
}
// An explicitly empty value is the escape hatch: index everything.
t.Setenv("META_CORE_INDEX_EXCLUDE_PREFIXES", "")
if got := indexExcludePrefixes(); len(got) != 0 {
t.Fatalf("an empty override must disable exclusion, got %v", got)
}
}
func TestHasAnyPrefix(t *testing.T) {
prefixes := []string{"categories/newznab/", "tmp/"}
cases := map[string]bool{
"categories/newznab/5070": true,
"tmp/scratch": true,
"categories/XXX": false,
"categories/newznab": false, // the family root is not the mirror
"title": false,
}
for field, want := range cases {
if got := hasAnyPrefix(field, prefixes); got != want {
t.Errorf("hasAnyPrefix(%q) = %v, want %v", field, got, want)
}
}
if hasAnyPrefix("anything", nil) {
t.Error("an empty prefix list must exclude nothing")
}
}
+23 -5
View File
@@ -72,7 +72,15 @@ func udlIdxCidUsers(cid string) string { return fmt.Sprintf("udl:idx:cid:%s
// //
// KEYS: 1=cell 2=idx:user:key 3=idx:user:cid 4=idx:cid:key 5=idx:cid:users // KEYS: 1=cell 2=idx:user:key 3=idx:user:cid 4=idx:cid:key 5=idx:cid:users
// ARGV: 1=version 2=ts 3=record 4=value 5=hasValue("1"/"0") // ARGV: 1=version 2=ts 3=record 4=value 5=hasValue("1"/"0")
// 6=cid 7=key 8=uid // 6=cid 7=key 8=uid 9=tombstone("1"/"0")
//
// A tombstone still writes the cell — that is the CRDT state, and dropping it
// would let an older write resurrect the value — but it SREMs the cid from the
// user+key index. That index is what `active_for_user_key` returns, so leaving
// deleted cids in it means every My List / Continue Watching read carries every
// title ever un-liked or finished, forever. The other three indexes are left
// alone: they answer "who has an opinion about this cid", where a tombstone is
// still an answer.
var udlUpsertScript = redis.NewScript(` var udlUpsertScript = redis.NewScript(`
local stored = redis.call('HGET', KEYS[1], 'version') local stored = redis.call('HGET', KEYS[1], 'version')
if stored and tonumber(stored) >= tonumber(ARGV[1]) then if stored and tonumber(stored) >= tonumber(ARGV[1]) then
@@ -84,7 +92,11 @@ if ARGV[5] == '1' then
else else
redis.call('HDEL', KEYS[1], 'value') redis.call('HDEL', KEYS[1], 'value')
end end
redis.call('SADD', KEYS[2], ARGV[6]) if ARGV[9] == '1' then
redis.call('SREM', KEYS[2], ARGV[6])
else
redis.call('SADD', KEYS[2], ARGV[6])
end
redis.call('SADD', KEYS[3], ARGV[7]) redis.call('SADD', KEYS[3], ARGV[7])
redis.call('SADD', KEYS[4], ARGV[8]) redis.call('SADD', KEYS[4], ARGV[8])
redis.call('SADD', KEYS[5], ARGV[8]) redis.call('SADD', KEYS[5], ARGV[8])
@@ -94,8 +106,10 @@ return 1
// UDLUpsertIfNewer stores a record cell iff version strictly exceeds the stored // UDLUpsertIfNewer stores a record cell iff version strictly exceeds the stored
// one. publicValue/hasValue carry the plaintext JSON scalar for public keys // one. publicValue/hasValue carry the plaintext JSON scalar for public keys
// (used by aggregation); pass hasValue=false for private-tier keys and // (used by aggregation); pass hasValue=false for private-tier keys and
// tombstones. Returns accepted=false when the write is stale. // tombstones. Set tombstone=true when the record deletes the cell, which also
func (c *Client) UDLUpsertIfNewer(uid, cid, key string, version, ts int64, record, publicValue string, hasValue bool) (bool, error) { // prunes the cid from the user+key index. Returns accepted=false when the write
// is stale.
func (c *Client) UDLUpsertIfNewer(uid, cid, key string, version, ts int64, record, publicValue string, hasValue, tombstone bool) (bool, error) {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
@@ -117,7 +131,11 @@ func (c *Client) UDLUpsertIfNewer(uid, cid, key string, version, ts int64, recor
if hasValue { if hasValue {
hv = "1" hv = "1"
} }
res, err := udlUpsertScript.Run(ctx, c.client, keys, version, ts, record, publicValue, hv, cid, key, uid).Result() tomb := "0"
if tombstone {
tomb = "1"
}
res, err := udlUpsertScript.Run(ctx, c.client, keys, version, ts, record, publicValue, hv, cid, key, uid, tomb).Result()
if err != nil { if err != nil {
return false, fmt.Errorf("udl upsert failed: %w", err) return false, fmt.Errorf("udl upsert failed: %w", err)
} }
+245
View File
@@ -0,0 +1,245 @@
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 — because there is no per-user cid index to consult. The Redis model
// (see udl.go) indexes user+key → cids and user+cid → keys, which answer
// "what is in My List", not "everything this account ever wrote". Adding a
// third index would put a write on every UDL upsert to serve two admin calls,
// so these SCAN instead and accept being O(keyspace).
//
// That cost is why UDLAllUserStats exists as a bulk call rather than a per-uid
// one: the accounts page needs a number for every account, and MATCH filters
// server-side without indexing, so N per-uid calls would walk the keyspace N
// times. One walk buckets them all.
import (
"context"
"fmt"
"strings"
"time"
)
// 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"`
}
// udlScanBatch is the SCAN COUNT hint, matching the rest of this package.
const udlScanBatch = 1000
// safeUIDForPattern rejects anything that would change the meaning of a SCAN
// glob or reach across the key structure. 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
}
// scanKeys walks the keyspace for pattern and returns every match. Caller holds
// the lock.
//
// SCAN can return the same key more than once across cursor iterations (it
// guarantees no misses, not no duplicates), so callers that count must
// de-duplicate — both callers below collect into maps or sets for that reason.
func (c *Client) scanKeys(ctx context.Context, pattern string) ([]string, error) {
var (
out []string
cursor uint64
)
for {
keys, next, err := c.client.Scan(ctx, cursor, pattern, udlScanBatch).Result()
if err != nil {
return nil, fmt.Errorf("scan %q: %w", pattern, err)
}
out = append(out, keys...)
cursor = next
if cursor == 0 {
return out, nil
}
}
}
// UDLUserCells returns every (cid, key) pair this account holds a cell for.
// De-duplicated, since SCAN may repeat a key.
func (c *Client) UDLUserCells(uid string) (map[string]string, error) {
// Keyed by the full "cid/key" tail so the pair stays a pair; the value is
// the cid, which the purge needs grouped.
if err := safeUIDForPattern(uid); err != nil {
return nil, err
}
c.mu.RLock()
defer c.mu.RUnlock()
if c.client == nil {
return nil, fmt.Errorf("not connected")
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
prefix := c.buildKey("udl:rec:")
keys, err := c.scanKeys(ctx, prefix+uid+"/*")
if err != nil {
return nil, err
}
out := make(map[string]string, len(keys))
for _, full := range keys {
gotUID, cid, key, ok := splitUDLRecSuffix(strings.TrimPrefix(full, prefix))
// The glob can only match this uid, but a uid that is a prefix of
// another would still be caught by the '/' in the pattern; check anyway
// so a malformed key can never be attributed to the wrong account.
if !ok || gotUID != uid {
continue
}
out[cid+"/"+key] = cid
}
return out, nil
}
// UDLAllUserStats counts every account's cells in one keyspace 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) {
c.mu.RLock()
defer c.mu.RUnlock()
if c.client == nil {
return nil, fmt.Errorf("not connected")
}
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
prefix := c.buildKey("udl:rec:")
keys, err := c.scanKeys(ctx, prefix+"*")
if err != nil {
return nil, err
}
// Sets rather than counters: SCAN may hand back the same key twice, which
// would otherwise inflate every number on the page.
type acc struct {
cells map[string]struct{}
cids map[string]struct{}
keys map[string]struct{}
}
byUID := make(map[string]*acc)
for _, full := range keys {
uid, cid, key, ok := splitUDLRecSuffix(strings.TrimPrefix(full, prefix))
if !ok {
continue
}
a := byUID[uid]
if a == nil {
a = &acc{
cells: make(map[string]struct{}),
cids: make(map[string]struct{}),
keys: make(map[string]struct{}),
}
byUID[uid] = a
}
a.cells[cid+"/"+key] = struct{}{}
a.cids[cid] = struct{}{}
a.keys[key] = struct{}{}
}
out := make(map[string]UDLUserStats, len(byUID))
for uid, a := range byUID {
out[uid] = UDLUserStats{Records: len(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 two 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. Aggregation
// then HGETs a cell that no longer exists — UDLPublicValues skips it, so the
// rating average stays right, but uids_for_cid keeps naming a deleted account
// forever and every aggregate read pays for the lookup.
//
// Idempotent: purging an account with nothing stored is a no-op returning
// zeroes, so a retry after a partial failure is safe.
func (c *Client) UDLPurgeUser(uid string) (UDLUserStats, error) {
cells, err := c.UDLUserCells(uid)
if err != nil {
return UDLUserStats{}, err
}
c.mu.Lock()
defer c.mu.Unlock()
if c.client == nil {
return UDLUserStats{}, fmt.Errorf("not connected")
}
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
stats := UDLUserStats{Records: len(cells)}
distinctCids := make(map[string]struct{})
distinctKeys := make(map[string]struct{})
pipe := c.client.Pipeline()
for cidKey, cid := range cells {
key := strings.TrimPrefix(cidKey, cid+"/")
distinctCids[cid] = struct{}{}
distinctKeys[key] = struct{}{}
pipe.Del(ctx, c.buildKey(udlRecKey(uid, cid, key)))
pipe.SRem(ctx, c.buildKey(udlIdxCidKey(cid, key)), uid)
}
for cid := range distinctCids {
pipe.SRem(ctx, c.buildKey(udlIdxCidUsers(cid)), uid)
}
stats.Cids = len(distinctCids)
stats.Keys = len(distinctKeys)
// The two user-scoped indexes are enumerable directly, and are dropped
// whole rather than member-by-member. Scanning them separately also catches
// index entries whose cell is already gone — exactly the state a previously
// half-finished purge leaves behind.
idxPrefix := c.buildKey("udl:idx:user:") + uid + ":"
idxKeys, err := c.scanKeys(ctx, idxPrefix+"*")
if err != nil {
return UDLUserStats{}, err
}
for _, k := range idxKeys {
pipe.Del(ctx, k)
}
if _, err := pipe.Exec(ctx); err != nil {
return UDLUserStats{}, fmt.Errorf("purge pipeline: %w", err)
}
return stats, nil
}
+253
View File
@@ -0,0 +1,253 @@
package storage
import (
"testing"
)
// seedCell writes one live UDL cell the way a real client would.
func seedCell(t *testing.T, c *Client, uid, cid, key string) {
t.Helper()
if ok, err := c.UDLUpsertIfNewer(uid, cid, key, 1, 100, "rec", "", false, false); err != nil || !ok {
t.Fatalf("seed %s/%s/%s: ok=%v err=%v", uid, cid, key, ok, err)
}
}
// The purge has to reach the cid-scoped indexes, not just the user's own rows.
//
// This is the failure that motivated the whole endpoint: deleting an account
// while leaving `udl:idx:cid:<cid>:users` intact means every aggregate read on
// every title that account ever touched keeps naming a uid nobody can resolve,
// forever — and the rows are unreachable through any API, so nothing will ever
// clean them up.
func TestUDLPurgeUser_RemovesCellsAndBothIndexFamilies(t *testing.T) {
c, _ := newTestClient(t, "")
seedCell(t, c, "zAlice", "cidOne", "like")
seedCell(t, c, "zAlice", "cidOne", "rating")
seedCell(t, c, "zAlice", "cidTwo", "seek")
stats, err := c.UDLPurgeUser("zAlice")
if err != nil {
t.Fatalf("purge: %v", err)
}
if stats.Records != 3 || stats.Cids != 2 || stats.Keys != 3 {
t.Fatalf("stats = %+v, want 3 records / 2 cids / 3 keys", stats)
}
// Cells gone.
if _, ok, err := c.UDLGetCell("zAlice", "cidOne", "like"); err != nil || ok {
t.Fatalf("cell survived the purge: ok=%v err=%v", ok, err)
}
// User-scoped indexes gone.
if entries, err := c.UDLListUserKey("zAlice", "like"); err != nil || len(entries) != 0 {
t.Fatalf("user+key index survived: %d entries, err=%v", len(entries), err)
}
if entries, err := c.UDLListUserCid("zAlice", "cidOne"); err != nil || len(entries) != 0 {
t.Fatalf("user+cid index survived: %d entries, err=%v", len(entries), err)
}
// Cid-scoped index gone — the one that is easy to miss.
for _, cid := range []string{"cidOne", "cidTwo"} {
uids, err := c.UDLCidUsers(cid)
if err != nil {
t.Fatalf("cid users %s: %v", cid, err)
}
for _, u := range uids {
if u == "zAlice" {
t.Fatalf("%s still lists the deleted account in uids_for_cid", cid)
}
}
}
}
// Purging one account must not touch another's rows, including on a cid they
// both have an opinion about — where the two share an index set and a careless
// DEL of the whole key would take the survivor's membership with it.
func TestUDLPurgeUser_LeavesOtherAccountsIntact(t *testing.T) {
c, _ := newTestClient(t, "")
seedCell(t, c, "zAlice", "shared", "rating")
seedCell(t, c, "zBob", "shared", "rating")
if _, err := c.UDLPurgeUser("zAlice"); err != nil {
t.Fatalf("purge: %v", err)
}
if _, ok, err := c.UDLGetCell("zBob", "shared", "rating"); err != nil || !ok {
t.Fatalf("bob's cell must survive alice's purge: ok=%v err=%v", ok, err)
}
uids, err := c.UDLCidUsers("shared")
if err != nil {
t.Fatalf("cid users: %v", err)
}
if len(uids) != 1 || uids[0] != "zBob" {
t.Fatalf("uids_for_cid = %v, want exactly [zBob]", uids)
}
entries, err := c.UDLListUserKey("zBob", "rating")
if err != nil || len(entries) != 1 {
t.Fatalf("bob's user+key index must survive: %d entries, err=%v", len(entries), err)
}
}
// A uid that is a prefix of another must not drag its neighbour's rows out.
// The key layout separates them with '/' and ':', so this passes only as long
// as the scan patterns keep those separators.
func TestUDLPurgeUser_DoesNotMatchUIDsByPrefix(t *testing.T) {
c, _ := newTestClient(t, "")
seedCell(t, c, "zAlice", "cidOne", "like")
seedCell(t, c, "zAliceExtra", "cidOne", "like")
stats, err := c.UDLPurgeUser("zAlice")
if err != nil {
t.Fatalf("purge: %v", err)
}
if stats.Records != 1 {
t.Fatalf("purged %d records, want exactly alice's 1", stats.Records)
}
if _, ok, err := c.UDLGetCell("zAliceExtra", "cidOne", "like"); err != nil || !ok {
t.Fatalf("zAliceExtra's cell must survive: ok=%v err=%v", ok, err)
}
}
// Tombstoned cells still belong to the account. They are absent from the
// user+key index by design (see udl.go), so a purge that enumerated *that*
// index instead of the cells would leave every retracted rating behind — the
// rows most likely to have accumulated over an account's life.
func TestUDLPurgeUser_RemovesTombstonedCellsToo(t *testing.T) {
c, _ := newTestClient(t, "")
seedCell(t, c, "zAlice", "cidOne", "like")
if ok, err := c.UDLUpsertIfNewer("zAlice", "cidOne", "like", 2, 200, "rec-tomb", "", false, true); err != nil || !ok {
t.Fatalf("tombstone: ok=%v err=%v", ok, err)
}
// Precondition: the tombstone left the cell but dropped it from the index.
if entries, _ := c.UDLListUserKey("zAlice", "like"); len(entries) != 0 {
t.Fatalf("precondition: tombstone should be out of the user+key index")
}
stats, err := c.UDLPurgeUser("zAlice")
if err != nil {
t.Fatalf("purge: %v", err)
}
if stats.Records != 1 {
t.Fatalf("purged %d records, want the 1 tombstoned cell", stats.Records)
}
if _, ok, _ := c.UDLGetCell("zAlice", "cidOne", "like"); ok {
t.Fatal("tombstoned cell survived the purge")
}
}
// Retry-safe: the delete handler purges before removing the key, so a caller
// that retries after a partial failure runs this a second time.
func TestUDLPurgeUser_IsIdempotent(t *testing.T) {
c, _ := newTestClient(t, "")
seedCell(t, c, "zAlice", "cidOne", "like")
if _, err := c.UDLPurgeUser("zAlice"); err != nil {
t.Fatalf("first purge: %v", err)
}
stats, err := c.UDLPurgeUser("zAlice")
if err != nil {
t.Fatalf("second purge must be a no-op, got: %v", err)
}
if stats.Records != 0 || stats.Cids != 0 {
t.Fatalf("second purge = %+v, want zeroes", stats)
}
}
func TestUDLPurgeUser_RefusesGlobInjection(t *testing.T) {
c, _ := newTestClient(t, "")
seedCell(t, c, "zAlice", "cidOne", "like")
// "*" would otherwise match every account in the keyspace — a wildcard
// purge dressed up as a uid.
for _, uid := range []string{"*", "z*", "", "z:Alice", "z/Alice"} {
if _, err := c.UDLPurgeUser(uid); err == nil {
t.Errorf("uid %q must be refused before it reaches a SCAN pattern", uid)
}
}
if _, ok, _ := c.UDLGetCell("zAlice", "cidOne", "like"); !ok {
t.Fatal("a refused purge must not have deleted anything")
}
}
func TestUDLAllUserStats_CountsEachAccountSeparately(t *testing.T) {
c, _ := newTestClient(t, "")
seedCell(t, c, "zAlice", "cidOne", "like")
seedCell(t, c, "zAlice", "cidOne", "rating")
seedCell(t, c, "zAlice", "cidTwo", "like")
seedCell(t, c, "zBob", "cidOne", "seek")
stats, err := c.UDLAllUserStats()
if err != nil {
t.Fatalf("stats: %v", err)
}
if got := stats["zAlice"]; got.Records != 3 || got.Cids != 2 || got.Keys != 2 {
t.Fatalf("alice = %+v, want 3 records / 2 cids / 2 keys", got)
}
if got := stats["zBob"]; got.Records != 1 || got.Cids != 1 || got.Keys != 1 {
t.Fatalf("bob = %+v, want 1/1/1", got)
}
// An account with nothing stored is simply absent — the caller renders 0.
if _, present := stats["zNobody"]; present {
t.Fatal("an account with no records must not appear in the map")
}
}
// The stats and the purge must agree: the number shown in the delete
// confirmation is the number of rows the delete then removes.
func TestUDLAllUserStats_AgreesWithWhatPurgeRemoves(t *testing.T) {
c, _ := newTestClient(t, "")
seedCell(t, c, "zAlice", "cidOne", "like")
seedCell(t, c, "zAlice", "cidTwo", "rating")
seedCell(t, c, "zAlice", "cidTwo", "seek")
before, err := c.UDLAllUserStats()
if err != nil {
t.Fatalf("stats: %v", err)
}
purged, err := c.UDLPurgeUser("zAlice")
if err != nil {
t.Fatalf("purge: %v", err)
}
if before["zAlice"] != purged {
t.Fatalf("stats said %+v but purge removed %+v", before["zAlice"], purged)
}
}
// The key prefix (multi-tenant Redis) must not leak into the parsed uid, or
// every account on a prefixed node would be reported under a mangled name and
// the purge would find nothing.
func TestUDLAdmin_RespectsKeyPrefix(t *testing.T) {
c, _ := newTestClient(t, "tenantA:")
seedCell(t, c, "zAlice", "cidOne", "like")
stats, err := c.UDLAllUserStats()
if err != nil {
t.Fatalf("stats: %v", err)
}
if got := stats["zAlice"]; got.Records != 1 {
t.Fatalf("stats under a prefix = %+v, want 1 record for zAlice", stats)
}
if s, err := c.UDLPurgeUser("zAlice"); err != nil || s.Records != 1 {
t.Fatalf("purge under a prefix: %+v err=%v", s, err)
}
}
func TestSplitUDLRecSuffix_KeepsSlashesInTheKey(t *testing.T) {
// A namespaced key arriving later must not be truncated: the purge SREMs
// `udl:idx:cid:<cid>:key:<key>`, so a half-read key removes the wrong
// member and leaves the real one behind.
uid, cid, key, ok := splitUDLRecSuffix("zAlice/cidOne/ns/sub:key")
if !ok || uid != "zAlice" || cid != "cidOne" || key != "ns/sub:key" {
t.Fatalf("got (%q,%q,%q,%v)", uid, cid, key, ok)
}
for _, bad := range []string{"", "zAlice", "zAlice/cidOne", "zAlice//key", "/cid/key"} {
if _, _, _, ok := splitUDLRecSuffix(bad); ok {
t.Errorf("%q should not parse", bad)
}
}
}
+79
View File
@@ -0,0 +1,79 @@
package storage
import (
"testing"
)
// A tombstone must leave the cell in place but drop the cid from the user+key
// index.
//
// The cell has to stay: it *is* the CRDT state, and deleting it would let an
// older write resurrect the value the tombstone retracted. The index entry has
// to go: `active_for_user_key` returns every member of that set, so a set that
// only ever grows means each My List / Continue Watching read carries every
// title the user has ever un-liked or finished — with its full record blob —
// forever. The reader filters them out again, so the cost is invisible until
// the account is old.
func TestUDLUpsert_TombstonePrunesUserKeyIndexButKeepsCell(t *testing.T) {
c, _ := newTestClient(t, "")
const (
uid = "zAlice"
cid = "bagcsaaalocator"
key = "seek"
)
// A live position: indexed, readable.
if ok, err := c.UDLUpsertIfNewer(uid, cid, key, 1, 100, "rec-v1", "", false, false); err != nil || !ok {
t.Fatalf("live upsert: ok=%v err=%v", ok, err)
}
entries, err := c.UDLListUserKey(uid, key)
if err != nil {
t.Fatalf("active_for_user_key: %v", err)
}
if len(entries) != 1 || entries[0].Cid != cid {
t.Fatalf("live position should be indexed, got %+v", entries)
}
// Finishing it tombstones the seek.
if ok, err := c.UDLUpsertIfNewer(uid, cid, key, 2, 200, "rec-v2-tombstone", "", false, true); err != nil || !ok {
t.Fatalf("tombstone upsert: ok=%v err=%v", ok, err)
}
entries, err = c.UDLListUserKey(uid, key)
if err != nil {
t.Fatalf("active_for_user_key after tombstone: %v", err)
}
if len(entries) != 0 {
t.Fatalf("tombstoned cid must leave the user+key index, got %+v", entries)
}
// ...but the cell itself survives, at the tombstone's version.
cell, exists, err := c.UDLGetCell(uid, cid, key)
if err != nil {
t.Fatalf("get cell: %v", err)
}
if !exists {
t.Fatal("tombstone must not delete the cell — an older write could then resurrect the value")
}
if cell.Version != 2 || cell.Record != "rec-v2-tombstone" {
t.Fatalf("cell should hold the tombstone record at v2, got %+v", cell)
}
// And the high-water mark still rejects the superseded write.
if ok, err := c.UDLUpsertIfNewer(uid, cid, key, 1, 300, "rec-v1-replay", "", false, false); err != nil || ok {
t.Fatalf("stale replay must be rejected: ok=%v err=%v", ok, err)
}
// Re-watching re-establishes the cell and puts it back in the index.
if ok, err := c.UDLUpsertIfNewer(uid, cid, key, 3, 400, "rec-v3", "", false, false); err != nil || !ok {
t.Fatalf("re-establish upsert: ok=%v err=%v", ok, err)
}
entries, err = c.UDLListUserKey(uid, key)
if err != nil {
t.Fatalf("active_for_user_key after re-establish: %v", err)
}
if len(entries) != 1 || entries[0].Cid != cid {
t.Fatalf("re-established cid must return to the index, got %+v", entries)
}
}
+12 -4
View File
@@ -50,8 +50,18 @@ func (d *Dispatcher) dispatchToStream(event FileEvent) {
client := d.storageClient client := d.storageClient
d.mu.RUnlock() d.mu.RUnlock()
if err := PublishEvent(client, event); err != nil {
log.Printf("[Dispatcher] Failed to publish to stream: %v", err)
}
}
// PublishEvent appends one file event to the file:events stream. Exported so a
// caller with no Dispatcher (POST /api/files/register on a box whose watcher is
// off) emits exactly the entry the watcher would — meta-share's ipfs_seed
// consumes this stream to chunk, seed and provide new files.
func PublishEvent(client *storage.Client, event FileEvent) error {
if client == nil || !client.IsConnected() { if client == nil || !client.IsConnected() {
return return fmt.Errorf("storage client not connected")
} }
// Build stream fields // Build stream fields
@@ -79,9 +89,7 @@ func (d *Dispatcher) dispatchToStream(event FileEvent) {
// `EmitReset → ClearStream` path is still the only way the stream gets // `EmitReset → ClearStream` path is still the only way the stream gets
// truncated to zero (signalled to consumers via the `reset` event). // truncated to zero (signalled to consumers via the `reset` event).
_, err := client.XAdd(EventsStream, EventsStreamMaxLen, fields) _, err := client.XAdd(EventsStream, EventsStreamMaxLen, fields)
if err != nil { return err
log.Printf("[Dispatcher] Failed to publish to stream: %v", err)
}
} }
// EmitReset clears the stream and emits a reset event // EmitReset clears the stream and emits a reset event
+2 -37
View File
@@ -4,7 +4,6 @@ import (
"log" "log"
"os" "os"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
"sync" "sync"
@@ -54,42 +53,8 @@ func (w *Watcher) writeFileTuple(absPath string, size, mtimeNano int64, midhash
return return
} }
uuid, err := w.storage.GetByCID(midhash) if _, _, err := w.storage.RegisterFileTuple(absPath, size, mtimeNano, midhash); err != nil {
if err != nil { log.Printf("[Watcher] register %s: %v", absPath, err)
log.Printf("[Watcher] GetByCID(%s): %v", midhash, err)
return
}
if uuid == "" {
// New content. Mint sets filePath/sizeByte/mtimeNano; SetProperty
// then writes the midhash as a cids/<cid> key-set member — and the
// cid_resolution.go hook registers the reverse-index alias for us.
uuid, err = w.storage.Mint(absPath, size, mtimeNano)
if err != nil {
log.Printf("[Watcher] Mint(%s): %v", absPath, err)
return
}
if err := w.storage.SetProperty(uuid, storage.CIDsKeyPrefix+midhash, "true"); err != nil {
log.Printf("[Watcher] SetProperty cids/%s for %s: %v", midhash, uuid, err)
}
return
}
// Known content. If this is a new physical path for the same content,
// record it as a duplicate. Otherwise refresh size/mtime — same content,
// same path, but the file may have been rewritten with a fresh mtime.
existing, _ := w.storage.GetProperty(uuid, "filePath")
if existing != absPath {
if _, err := w.storage.AddDuplicatePath(uuid, absPath); err != nil {
log.Printf("[Watcher] AddDuplicatePath %s @ %s: %v", uuid, absPath, err)
}
return
}
if _, err := w.storage.MergeMetadataFlat(uuid, map[string]string{
"sizeByte": strconv.FormatInt(size, 10),
"mtimeNano": strconv.FormatInt(mtimeNano, 10),
}); err != nil {
log.Printf("[Watcher] refresh tuple for %s: %v", uuid, err)
} }
} }