remove in metadata cid_* to put in the cids/ set
This commit is contained in:
Vendored
+1
-1
@@ -21,7 +21,7 @@
|
|||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<script type="module" crossorigin src="/assets/index-DJlbGMsa.js"></script>
|
<script type="module" crossorigin src="/assets/index-Cbd1uH5A.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>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import Dashboard from './pages/Dashboard';
|
|||||||
import Mounts from './pages/Mounts';
|
import Mounts from './pages/Mounts';
|
||||||
import Watchers from './pages/Watchers';
|
import Watchers from './pages/Watchers';
|
||||||
import Files from './pages/Files';
|
import Files from './pages/Files';
|
||||||
|
import Identity from './pages/Identity';
|
||||||
import ServiceNav from './components/ServiceNav';
|
import ServiceNav from './components/ServiceNav';
|
||||||
|
|
||||||
const navStyle: React.CSSProperties = {
|
const navStyle: React.CSSProperties = {
|
||||||
@@ -74,6 +75,12 @@ export default function App() {
|
|||||||
>
|
>
|
||||||
Files
|
Files
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
<NavLink
|
||||||
|
to="/identity"
|
||||||
|
style={({ isActive }) => (isActive ? activeLinkStyle : linkStyle)}
|
||||||
|
>
|
||||||
|
Identity
|
||||||
|
</NavLink>
|
||||||
<NavLink
|
<NavLink
|
||||||
to="/editor"
|
to="/editor"
|
||||||
style={({ isActive }) => (isActive ? activeLinkStyle : linkStyle)}
|
style={({ isActive }) => (isActive ? activeLinkStyle : linkStyle)}
|
||||||
@@ -89,6 +96,7 @@ export default function App() {
|
|||||||
<Route path="/mounts" element={<Mounts />} />
|
<Route path="/mounts" element={<Mounts />} />
|
||||||
<Route path="/watchers" element={<Watchers />} />
|
<Route path="/watchers" element={<Watchers />} />
|
||||||
<Route path="/files" element={<Files />} />
|
<Route path="/files" element={<Files />} />
|
||||||
|
<Route path="/identity" element={<Identity />} />
|
||||||
<Route path="/editor" element={<EditorPage />} />
|
<Route path="/editor" element={<EditorPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,375 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
// Status returned by GET /api/identity. Private key is never present here —
|
||||||
|
// it is shown exactly once at generation time (see GeneratedKeyPanel).
|
||||||
|
interface IdentityStatus {
|
||||||
|
hasIdentity: boolean;
|
||||||
|
uid?: string;
|
||||||
|
curve?: string;
|
||||||
|
createdAt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GeneratedKey {
|
||||||
|
uid: string;
|
||||||
|
curve: string;
|
||||||
|
createdAt: number;
|
||||||
|
privateKeyHex: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cardStyle: React.CSSProperties = {
|
||||||
|
background: '#16213e',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '1.5rem',
|
||||||
|
marginBottom: '1rem',
|
||||||
|
};
|
||||||
|
|
||||||
|
const buttonStyle: React.CSSProperties = {
|
||||||
|
padding: '0.5rem 1rem',
|
||||||
|
background: '#0f3460',
|
||||||
|
color: '#fff',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
marginRight: '0.5rem',
|
||||||
|
};
|
||||||
|
|
||||||
|
const dangerButtonStyle: React.CSSProperties = {
|
||||||
|
...buttonStyle,
|
||||||
|
background: '#7f1d1d',
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputStyle: React.CSSProperties = {
|
||||||
|
width: '100%',
|
||||||
|
padding: '0.5rem',
|
||||||
|
marginBottom: '0.5rem',
|
||||||
|
background: '#1a1a2e',
|
||||||
|
border: '1px solid #0f3460',
|
||||||
|
borderRadius: '4px',
|
||||||
|
color: '#fff',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
};
|
||||||
|
|
||||||
|
const monoStyle: React.CSSProperties = {
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: '0.85rem',
|
||||||
|
wordBreak: 'break-all',
|
||||||
|
background: '#0a0a18',
|
||||||
|
border: '1px solid #0f3460',
|
||||||
|
padding: '0.75rem',
|
||||||
|
borderRadius: '4px',
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatCreatedAt(unixSec?: number): string {
|
||||||
|
if (!unixSec) return '';
|
||||||
|
try {
|
||||||
|
return new Date(unixSec * 1000).toISOString().replace('T', ' ').replace(/\..+$/, ' UTC');
|
||||||
|
} catch {
|
||||||
|
return String(unixSec);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the JSON body of a non-2xx response if present, fall back to status text.
|
||||||
|
async function errorMessage(res: Response, fallback: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
const j = await res.json();
|
||||||
|
if (j && (j.message || j.error)) return j.message ?? j.error;
|
||||||
|
} catch {
|
||||||
|
// not JSON
|
||||||
|
}
|
||||||
|
return `${fallback} (HTTP ${res.status})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function GeneratedKeyPanel({ data, onDismiss }: { data: GeneratedKey; onDismiss: () => void }) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const copy = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(data.privateKeyHex);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
} catch {
|
||||||
|
// Clipboard API blocked (non-HTTPS context, etc.) — fall back to selection.
|
||||||
|
const sel = window.getSelection();
|
||||||
|
const range = document.createRange();
|
||||||
|
const el = document.getElementById('mm-privkey-box');
|
||||||
|
if (sel && el) {
|
||||||
|
range.selectNodeContents(el);
|
||||||
|
sel.removeAllRanges();
|
||||||
|
sel.addRange(range);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div style={{ ...cardStyle, border: '2px solid #dc2626' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: '#7f1d1d',
|
||||||
|
color: '#fee2e2',
|
||||||
|
padding: '0.75rem 1rem',
|
||||||
|
borderRadius: '4px',
|
||||||
|
marginBottom: '1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong>This private key is shown ONCE.</strong> Save it now in a password manager.
|
||||||
|
It will never be displayed again — there is no recovery path. Losing it means losing access
|
||||||
|
to every record signed under this identity.
|
||||||
|
</div>
|
||||||
|
<p style={{ color: '#9ca3af', margin: '0 0 0.5rem' }}>uid (public):</p>
|
||||||
|
<div style={monoStyle}>{data.uid}</div>
|
||||||
|
<p style={{ color: '#9ca3af', margin: '1rem 0 0.5rem' }}>privateKeyHex (secret):</p>
|
||||||
|
<div id="mm-privkey-box" style={{ ...monoStyle, background: '#1f0a0a', borderColor: '#7f1d1d' }}>
|
||||||
|
{data.privateKeyHex}
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: '1rem' }}>
|
||||||
|
<button style={buttonStyle} onClick={copy}>
|
||||||
|
{copied ? 'Copied' : 'Copy private key'}
|
||||||
|
</button>
|
||||||
|
<button style={dangerButtonStyle} onClick={onDismiss}>
|
||||||
|
I have saved it
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NoIdentityPanel({
|
||||||
|
onGenerated,
|
||||||
|
onImported,
|
||||||
|
}: {
|
||||||
|
onGenerated: (g: GeneratedKey) => void;
|
||||||
|
onImported: () => void;
|
||||||
|
}) {
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [importing, setImporting] = useState(false);
|
||||||
|
const [importHex, setImportHex] = useState('');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const generate = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/identity/generate', { method: 'POST' });
|
||||||
|
if (!res.ok) {
|
||||||
|
setError(await errorMessage(res, 'Generate failed'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = (await res.json()) as GeneratedKey;
|
||||||
|
onGenerated(data);
|
||||||
|
} catch (e) {
|
||||||
|
setError(`Generate failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const importKey = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/identity/import', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ privateKeyHex: importHex.trim() }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
setError(await errorMessage(res, 'Import failed'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setImportHex('');
|
||||||
|
setImporting(false);
|
||||||
|
onImported();
|
||||||
|
} catch (e) {
|
||||||
|
setError(`Import failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={cardStyle}>
|
||||||
|
<h3 style={{ marginTop: 0 }}>No identity yet</h3>
|
||||||
|
<p style={{ color: '#9ca3af' }}>
|
||||||
|
The User Data Layer needs a signing key for your ratings, watch progress, and "liked" flags
|
||||||
|
to be attributed to you across devices. Nothing happens automatically — generate one below,
|
||||||
|
or import a key you already have.
|
||||||
|
</p>
|
||||||
|
{error && (
|
||||||
|
<p style={{ color: '#f87171', background: '#1f0a0a', padding: '0.5rem 0.75rem', borderRadius: '4px' }}>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<button style={buttonStyle} disabled={busy} onClick={generate}>
|
||||||
|
{busy ? 'Working…' : 'Generate new keypair'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
style={buttonStyle}
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => {
|
||||||
|
setImporting((x) => !x);
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{importing ? 'Cancel import' : 'Import existing private key'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{importing && (
|
||||||
|
<form onSubmit={importKey} style={{ marginTop: '1rem' }}>
|
||||||
|
<p style={{ color: '#9ca3af', margin: '0 0 0.5rem' }}>
|
||||||
|
64 hex chars (32-byte secp256k1 scalar). A "0x" prefix is fine.
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
style={inputStyle}
|
||||||
|
placeholder="e.g. a3f1…"
|
||||||
|
value={importHex}
|
||||||
|
onChange={(e) => setImportHex(e.target.value)}
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<button type="submit" style={buttonStyle} disabled={busy || !importHex.trim()}>
|
||||||
|
{busy ? 'Importing…' : 'Import'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HasIdentityPanel({ status, onReplace }: { status: IdentityStatus; onReplace: () => void }) {
|
||||||
|
const [confirming, setConfirming] = useState(false);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const remove = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
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 (
|
||||||
|
<div style={cardStyle}>
|
||||||
|
<h3 style={{ marginTop: 0 }}>Active identity</h3>
|
||||||
|
<p style={{ color: '#9ca3af', margin: '0 0 0.5rem' }}>uid:</p>
|
||||||
|
<div style={monoStyle}>{status.uid}</div>
|
||||||
|
<p style={{ color: '#888', marginTop: '1rem' }}>
|
||||||
|
Curve: {status.curve} · Created: {formatCreatedAt(status.createdAt)}
|
||||||
|
</p>
|
||||||
|
<p style={{ color: '#9ca3af', fontSize: '0.85rem' }}>
|
||||||
|
The private key lives at <code>/meta-core/identity/identity.json</code> on this volume and
|
||||||
|
is never returned through the API after generation. Other services on this stack (e.g.
|
||||||
|
meta-watch) sign records by calling <code>POST /api/identity/sign</code>.
|
||||||
|
</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>
|
||||||
|
<button style={dangerButtonStyle} onClick={remove} disabled={busy}>
|
||||||
|
{busy ? 'Deleting…' : 'Yes, delete identity'}
|
||||||
|
</button>
|
||||||
|
<button style={buttonStyle} onClick={() => setConfirming(false)} disabled={busy}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Identity() {
|
||||||
|
const [status, setStatus] = useState<IdentityStatus | null>(null);
|
||||||
|
const [generated, setGenerated] = useState<GeneratedKey | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [fetchError, setFetchError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const refresh = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setFetchError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/identity');
|
||||||
|
if (!res.ok) {
|
||||||
|
setFetchError(await errorMessage(res, 'Failed to load identity status'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStatus((await res.json()) as IdentityStatus);
|
||||||
|
} catch (e) {
|
||||||
|
setFetchError(`Failed to load identity status: ${e instanceof Error ? e.message : String(e)}`);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refresh();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '2rem', maxWidth: '900px', margin: '0 auto' }}>
|
||||||
|
<h1>Identity</h1>
|
||||||
|
<p style={{ color: '#9ca3af' }}>
|
||||||
|
Signing key for the User Data Layer (per-user ratings, progress, "liked" flags). One
|
||||||
|
identity per node. The same key on another device makes that device count as "you".
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{loading && <div style={cardStyle}>Loading…</div>}
|
||||||
|
{fetchError && (
|
||||||
|
<div style={{ ...cardStyle, border: '1px solid #7f1d1d' }}>
|
||||||
|
<p style={{ color: '#f87171', margin: 0 }}>{fetchError}</p>
|
||||||
|
<button style={buttonStyle} onClick={refresh}>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !fetchError && generated && (
|
||||||
|
<GeneratedKeyPanel
|
||||||
|
data={generated}
|
||||||
|
onDismiss={() => {
|
||||||
|
setGenerated(null);
|
||||||
|
refresh();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !fetchError && !generated && status && !status.hasIdentity && (
|
||||||
|
<NoIdentityPanel onGenerated={(g) => setGenerated(g)} onImported={refresh} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !fetchError && !generated && status && status.hasIdentity && (
|
||||||
|
<HasIdentityPanel status={status} onReplace={refresh} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,10 +3,13 @@ module github.com/metazla/meta-core
|
|||||||
go 1.21
|
go 1.21
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/gorilla/mux v1.8.1
|
github.com/gorilla/mux v1.8.1
|
||||||
|
github.com/mr-tron/base58 v1.3.0
|
||||||
github.com/redis/go-redis/v9 v9.4.0
|
github.com/redis/go-redis/v9 v9.4.0
|
||||||
golang.org/x/net v0.20.0
|
golang.org/x/crypto v0.31.0
|
||||||
|
golang.org/x/net v0.21.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
|||||||
@@ -4,13 +4,21 @@ github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
|||||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||||
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
|
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
|
||||||
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
|
github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8=
|
||||||
|
github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo=
|
||||||
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo=
|
||||||
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||||
|
github.com/mr-tron/base58 v1.3.0 h1:K6Y13R2h+dku0wOqKtecgRnBUBPrZzLZy5aIj8lCcJI=
|
||||||
|
github.com/mr-tron/base58 v1.3.0/go.mod h1:2BuubE67DCSWwVfx37JWNG8emOC0sHEU4/HpcYgCLX8=
|
||||||
github.com/redis/go-redis/v9 v9.4.0 h1:Yzoz33UZw9I/mFhx4MNrB6Fk+XHO1VukNcCa1+lwyKk=
|
github.com/redis/go-redis/v9 v9.4.0 h1:Yzoz33UZw9I/mFhx4MNrB6Fk+XHO1VukNcCa1+lwyKk=
|
||||||
github.com/redis/go-redis/v9 v9.4.0/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M=
|
github.com/redis/go-redis/v9 v9.4.0/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M=
|
||||||
golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo=
|
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||||
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
|
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||||
|
golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
|
||||||
|
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||||
|
|||||||
+12
-10
@@ -15,10 +15,10 @@ import (
|
|||||||
// - 404 if the CID isn't registered
|
// - 404 if the CID isn't registered
|
||||||
// - 503 if storage is down
|
// - 503 if storage is down
|
||||||
//
|
//
|
||||||
// CIDs in the URL must be in prefixed-token form ("midhash256:bafk…",
|
// CIDs in the URL are bare multibase CIDv1 strings ("bafk…") — the midhash,
|
||||||
// "sha256:bafk…", "ipfs:bafy…"). Bare CIDs without an algorithm prefix
|
// a sibling sha2-256, an IPFS CID, a btih, anything registered in the
|
||||||
// won't resolve — callers should pass the token they got from a previous
|
// reverse index. A CID is self-describing (its algorithm is the multicodec),
|
||||||
// metadata document, /api/file/{cid}/info, or a meta-share federated query.
|
// so there is no <algo>: prefix.
|
||||||
//
|
//
|
||||||
// This endpoint is the public read API for the CID-resolution layer and
|
// This endpoint is the public read API for the CID-resolution layer and
|
||||||
// is auth-bypassed alongside /api/file/{cid} so that meta-share peers and
|
// is auth-bypassed alongside /api/file/{cid} so that meta-share peers and
|
||||||
@@ -57,12 +57,14 @@ func (s *Server) handleGetMetaByCID(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Response envelope mirrors GetMetadataDocument JSON tags (metadata /
|
// Response envelope mirrors GetMetadataDocument JSON tags (metadata /
|
||||||
// cids / duplicates), with cid added so the caller can correlate when
|
// cids / canonical_cid / duplicates), with cid added so the caller can
|
||||||
// they fan out N lookups.
|
// correlate when they fan out N lookups. canonical_cid is derived by
|
||||||
|
// rank from the cids key-set on read — it is not a stored field.
|
||||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
"cid": cid,
|
"cid": cid,
|
||||||
"metadata": doc.Flat,
|
"metadata": doc.Flat,
|
||||||
"cids": doc.CIDs,
|
"cids": doc.CIDs,
|
||||||
"duplicates": doc.Duplicates,
|
"canonical_cid": doc.Canonical,
|
||||||
|
"duplicates": doc.Duplicates,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/metazla/meta-core/internal/identity"
|
||||||
|
)
|
||||||
|
|
||||||
|
// identityStatusResponse is the shape of GET /api/identity. The private key
|
||||||
|
// is intentionally absent — it is only ever returned once, by
|
||||||
|
// POST /api/identity/generate, immediately after creation.
|
||||||
|
type identityStatusResponse struct {
|
||||||
|
HasIdentity bool `json:"hasIdentity"`
|
||||||
|
UID string `json:"uid,omitempty"`
|
||||||
|
Curve string `json:"curve,omitempty"`
|
||||||
|
CreatedAt int64 `json:"createdAt,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type identityGenerateResponse struct {
|
||||||
|
UID string `json:"uid"`
|
||||||
|
Curve string `json:"curve"`
|
||||||
|
CreatedAt int64 `json:"createdAt"`
|
||||||
|
PrivateKeyHex string `json:"privateKeyHex"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type identityImportRequest struct {
|
||||||
|
PrivateKeyHex string `json:"privateKeyHex"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type identityImportResponse struct {
|
||||||
|
UID string `json:"uid"`
|
||||||
|
Curve string `json:"curve"`
|
||||||
|
CreatedAt int64 `json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type identityDeleteRequest struct {
|
||||||
|
Confirm bool `json:"confirm"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type identitySignRequest struct {
|
||||||
|
BytesB64 string `json:"bytesB64"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type identitySignResponse struct {
|
||||||
|
SigB64 string `json:"sigB64"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type identityAEADKeyResponse struct {
|
||||||
|
AEADKeyB64 string `json:"aeadKeyB64"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleIdentityGet(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := identity.Load(s.config.IdentityFilePath())
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if id == nil {
|
||||||
|
writeJSON(w, http.StatusOK, identityStatusResponse{HasIdentity: false})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, identityStatusResponse{
|
||||||
|
HasIdentity: true,
|
||||||
|
UID: id.UID,
|
||||||
|
Curve: id.Curve,
|
||||||
|
CreatedAt: id.CreatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleIdentityGenerate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if identity.Exists(s.config.IdentityFilePath()) {
|
||||||
|
writeErrorSlug(w, http.StatusConflict, "identity_exists",
|
||||||
|
"identity already exists; delete it first to generate a new one", false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, err := identity.Generate()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := identity.Save(s.config.IdentityFilePath(), id); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusCreated, identityGenerateResponse{
|
||||||
|
UID: id.UID,
|
||||||
|
Curve: id.Curve,
|
||||||
|
CreatedAt: id.CreatedAt,
|
||||||
|
PrivateKeyHex: id.PrivateKeyHex,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleIdentityImport(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if identity.Exists(s.config.IdentityFilePath()) {
|
||||||
|
writeErrorSlug(w, http.StatusConflict, "identity_exists",
|
||||||
|
"identity already exists; delete it first to import a new one", false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body identityImportRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(body.PrivateKeyHex) == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "privateKeyHex is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, err := identity.Import(strings.TrimSpace(body.PrivateKeyHex))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := identity.Save(s.config.IdentityFilePath(), id); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusCreated, identityImportResponse{
|
||||||
|
UID: id.UID,
|
||||||
|
Curve: id.Curve,
|
||||||
|
CreatedAt: id.CreatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleIdentityDelete(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body identityDeleteRequest
|
||||||
|
// Tolerate empty body — confirm flag in a query param is also acceptable
|
||||||
|
// for shells where sending a JSON body with DELETE is awkward.
|
||||||
|
_ = 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
|
||||||
|
}
|
||||||
|
if err := identity.Delete(s.config.IdentityFilePath()); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleIdentitySign(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := identity.Load(s.config.IdentityFilePath())
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if id == nil {
|
||||||
|
writeErrorSlug(w, http.StatusNotFound, "no_identity",
|
||||||
|
"no identity is configured", false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body identitySignRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
raw, err := base64.StdEncoding.DecodeString(body.BytesB64)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "bytesB64 is not valid base64")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sig, err := identity.Sign(id, raw)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, identitySignResponse{
|
||||||
|
SigB64: base64.StdEncoding.EncodeToString(sig),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleIdentityAEADKey(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := identity.Load(s.config.IdentityFilePath())
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if id == nil {
|
||||||
|
writeErrorSlug(w, http.StatusNotFound, "no_identity",
|
||||||
|
"no identity is configured", false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
key, err := identity.DeriveAEADKey(id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, identityAEADKeyResponse{
|
||||||
|
AEADKeyB64: base64.StdEncoding.EncodeToString(key),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -155,6 +155,15 @@ func (s *Server) setupRoutes() {
|
|||||||
s.router.HandleFunc("/api/metadata/batch", s.handleBatchUpdate).Methods("POST")
|
s.router.HandleFunc("/api/metadata/batch", s.handleBatchUpdate).Methods("POST")
|
||||||
s.router.HandleFunc("/api/metadata/clear", s.handleClearMetadata).Methods("POST")
|
s.router.HandleFunc("/api/metadata/clear", s.handleClearMetadata).Methods("POST")
|
||||||
|
|
||||||
|
// User Data Layer signing identity (see internal/identity). Auth-gated
|
||||||
|
// 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/generate", s.handleIdentityGenerate).Methods("POST")
|
||||||
|
s.router.HandleFunc("/api/identity/import", s.handleIdentityImport).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/identity/aead-key", s.handleIdentityAEADKey).Methods("GET")
|
||||||
|
|
||||||
// Snapshot (export / import / wipe)
|
// Snapshot (export / import / wipe)
|
||||||
s.router.HandleFunc("/api/snapshot/export", s.handleSnapshotExport).Methods("GET")
|
s.router.HandleFunc("/api/snapshot/export", s.handleSnapshotExport).Methods("GET")
|
||||||
s.router.HandleFunc("/api/snapshot/import", s.handleSnapshotImport).Methods("POST")
|
s.router.HandleFunc("/api/snapshot/import", s.handleSnapshotImport).Methods("POST")
|
||||||
|
|||||||
+108
-63
@@ -1,81 +1,126 @@
|
|||||||
// Package cid centralizes CID-format helpers: ranking by utility for
|
// Package cid centralizes CID-format helpers: decoding a multibase-base32
|
||||||
// external presentation, normalizing prefixed CID tokens, and the rules for
|
// CIDv1 into its multicodec parts, ranking by utility for external
|
||||||
// picking which CID is the canonical externally-visible identifier of a
|
// presentation, and picking which CID is the canonical externally-visible
|
||||||
// file.
|
// identifier of a file.
|
||||||
//
|
//
|
||||||
// A "CID token" in this codebase is the prefixed form "<algorithm>:<value>",
|
// Sibling CIDs are stored as a bare-CID key-set (`cids/<cid> = "true"`); a
|
||||||
// e.g. "midhash256:bafkr4ih...", "sha256:bafkrei...", "ipfs:bafy...". The
|
// CID is self-describing — its algorithm is recoverable from the CIDv1
|
||||||
// prefix makes the type recoverable without parsing the multicodec, which is
|
// codec / multihash code, so there is no `<algorithm>:` prefix and no
|
||||||
// what the reverse index (cid:<token> → uuid), the per-root cids set, and
|
// per-algorithm field name. Ranking and selection parse the multicodec
|
||||||
// the canonical_cid field all rely on.
|
// rather than a string prefix.
|
||||||
package cid
|
package cid
|
||||||
|
|
||||||
import "strings"
|
import (
|
||||||
|
"encoding/base32"
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
// Rank returns a score for a CID token. Higher = more preferred as the
|
// Multicodec / multihash codes used for ranking. Source of truth for the
|
||||||
// public-facing "canonical" identifier for a file. Unknown algorithms rank
|
// digest codes is meta-hash/src/lib/hash-compute/MultiHashData.ts and
|
||||||
// below all known ones (0) so a plugin that invents a new digest type
|
// METADATA_KEYS.md §2.
|
||||||
// can't accidentally outrank an established one until it's registered here.
|
const (
|
||||||
|
CodecRaw = 0x55 // raw — IPFS content-addressed block
|
||||||
|
CodecDagPB = 0x70 // dag-pb — IPFS content-addressed block
|
||||||
|
CodeSha1 = 0x11 // multihash sha1
|
||||||
|
CodeSha256 = 0x12 // multihash sha2-256
|
||||||
|
CodeSha3_384 = 0x15 // multihash sha3-384
|
||||||
|
CodeSha3_256 = 0x16 // multihash sha3-256
|
||||||
|
CodeMd5 = 0xd5 // multihash md5
|
||||||
|
CodeCrc32 = 0x0132 // multihash crc32
|
||||||
|
CodeMidhash256 = 0x1000 // custom — SHA-256(size‖middle 1MB)
|
||||||
|
CodeBtPiecesRoot = 0xb702 // bittorrent v2 pieces root
|
||||||
|
CodeBtihV2 = 0x10B7 // custom — BitTorrent v2 info hash (BEP 52)
|
||||||
|
CodeBtihV1File = 0x1001 // custom — per-file BitTorrent v1 CID
|
||||||
|
CodeBtihV2File = 0x1002 // custom — per-file BitTorrent v2 CID
|
||||||
|
)
|
||||||
|
|
||||||
|
// Decode parses a multibase-base32 CIDv1 string ("b…") into its content
|
||||||
|
// codec and multihash function code. It is the inverse of the encoder in
|
||||||
|
// internal/watcher/midhash.go. Returns an error for anything that isn't a
|
||||||
|
// CIDv1 in base32lower (e.g. a CIDv0 "Qm…" or a non-CID string).
|
||||||
|
func Decode(cidStr string) (codec uint64, mhCode uint64, err error) {
|
||||||
|
if len(cidStr) < 2 || cidStr[0] != 'b' {
|
||||||
|
return 0, 0, 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 0, 0, fmt.Errorf("cid %q: base32 decode: %w", cidStr, err)
|
||||||
|
}
|
||||||
|
version, n := binary.Uvarint(raw)
|
||||||
|
if n <= 0 {
|
||||||
|
return 0, 0, fmt.Errorf("cid %q: bad version varint", cidStr)
|
||||||
|
}
|
||||||
|
raw = raw[n:]
|
||||||
|
if version != 1 {
|
||||||
|
return 0, 0, fmt.Errorf("cid %q: unsupported CID version %d", cidStr, version)
|
||||||
|
}
|
||||||
|
codec, n = binary.Uvarint(raw)
|
||||||
|
if n <= 0 {
|
||||||
|
return 0, 0, fmt.Errorf("cid %q: bad codec varint", cidStr)
|
||||||
|
}
|
||||||
|
raw = raw[n:]
|
||||||
|
mhCode, n = binary.Uvarint(raw)
|
||||||
|
if n <= 0 {
|
||||||
|
return 0, 0, fmt.Errorf("cid %q: bad multihash-code varint", cidStr)
|
||||||
|
}
|
||||||
|
return codec, mhCode, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rank returns a score for a bare CID. Higher = more preferred as the
|
||||||
|
// public-facing "canonical" identifier for a file. Unknown / undecodable
|
||||||
|
// CIDs rank 0 so a new digest type can't accidentally outrank an
|
||||||
|
// established one until it's classified here.
|
||||||
|
//
|
||||||
|
// We rank by the **multihash code** (the algorithm), with the custom
|
||||||
|
// BitTorrent CID codecs checked alongside it. This is deliberate: MetaMesh's
|
||||||
|
// two CID encoders disagree on the *content codec* — meta-hash/the watcher
|
||||||
|
// set codec == algorithm code, while the fullhash plugin and gateway wrap
|
||||||
|
// every digest with the raw codec (0x55) and put the algorithm in the
|
||||||
|
// multihash code. The multihash code is the algorithm in all of them, so it
|
||||||
|
// is the only reliable discriminator. (The BT info hash is given a distinct
|
||||||
|
// multihash code — btih-v2 0x10B7 — so it can't be confused with a plain
|
||||||
|
// sha2-256, see METADATA_KEYS.md §14.13.)
|
||||||
//
|
//
|
||||||
// Ordering rationale (utility-weighted, NOT cryptographic-strength):
|
// Ordering rationale (utility-weighted, NOT cryptographic-strength):
|
||||||
// - IPFS CIDs win because the network around them — gateways, swarm,
|
// - dag-pb (chunked IPFS file) wins: best retrieval over the IPFS network.
|
||||||
// content-addressing tooling — has the broadest reach. A cryptographically
|
// - sha2-256 / sha3 digests follow: standard content hashes (a raw IPFS
|
||||||
// equivalent sha256 in a non-CID form has less downstream value.
|
// block of the file IS its sha2-256, so they share this tier).
|
||||||
// - sha256 / sha3 follow: standard, widely recognized, no platform lock-in.
|
// - BitTorrent infohashes (btih v2, per-file v1/v2) outrank midhash256 for
|
||||||
// - BitTorrent infohashes (btih) outrank midhash256 not for strength
|
// swarm interop, not strength.
|
||||||
// (v1 is sha1, weaker than midhash256's sha256 inner) but for swarm
|
// - midhash256 is the floor among real options: internally meaningful,
|
||||||
// interop — a btih makes a file discoverable on existing torrent
|
// externally invisible.
|
||||||
// infrastructure that knows nothing about MetaMesh.
|
// - md5 / crc32 / plain sha1 / pieces-root / unknown rank 0.
|
||||||
// - midhash256 is the floor. Internally meaningful, externally invisible.
|
func Rank(cidStr string) int {
|
||||||
func Rank(token string) int {
|
codec, mh, err := Decode(cidStr)
|
||||||
switch AlgorithmOf(token) {
|
if err != nil {
|
||||||
case "ipfs":
|
return 0
|
||||||
|
}
|
||||||
|
if codec == CodecDagPB {
|
||||||
return 40
|
return 40
|
||||||
case "sha256", "sha3", "sha3-256", "sha3-384", "sha3-512":
|
}
|
||||||
return 30
|
// BitTorrent: distinct CID codecs (per-file v1/v2) or the btih-v2
|
||||||
case "btih", "btih-v2":
|
// multihash code, in either encoder convention.
|
||||||
|
if codec == CodeBtihV1File || codec == CodeBtihV2File || codec == CodeBtihV2 ||
|
||||||
|
mh == CodeBtihV1File || mh == CodeBtihV2File || mh == CodeBtihV2 {
|
||||||
return 20
|
return 20
|
||||||
case "midhash256":
|
}
|
||||||
|
switch mh {
|
||||||
|
case CodeSha256, CodeSha3_256, CodeSha3_384:
|
||||||
|
return 30
|
||||||
|
case CodeMidhash256:
|
||||||
return 10
|
return 10
|
||||||
default:
|
default:
|
||||||
|
// md5, crc32, plain sha1, pieces-root, unknown.
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// AlgorithmOf returns the algorithm prefix of a CID token, or "" if the
|
// Better picks the higher-ranked of two CIDs. On ties, returns the
|
||||||
// token is malformed (no colon, or empty prefix).
|
|
||||||
func AlgorithmOf(token string) string {
|
|
||||||
colon := strings.IndexByte(token, ':')
|
|
||||||
if colon <= 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return token[:colon]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Token assembles a CID token from its algorithm and raw value. Mirror of
|
|
||||||
// AlgorithmOf — callers writing to the reverse index, cids set, or
|
|
||||||
// canonical_cid field route through this so the format stays consistent.
|
|
||||||
func Token(algorithm, raw string) string {
|
|
||||||
if algorithm == "" || raw == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return algorithm + ":" + raw
|
|
||||||
}
|
|
||||||
|
|
||||||
// ValueOf returns the raw value portion of a CID token (everything after
|
|
||||||
// the first colon). Returns "" for malformed tokens.
|
|
||||||
func ValueOf(token string) string {
|
|
||||||
colon := strings.IndexByte(token, ':')
|
|
||||||
if colon <= 0 || colon == len(token)-1 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return token[colon+1:]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Better picks the higher-ranked of two tokens. On ties, returns the
|
|
||||||
// lexicographically smaller one — gives the reconciler a deterministic
|
// lexicographically smaller one — gives the reconciler a deterministic
|
||||||
// answer so an unchanged canonical_cid doesn't churn when peers add the
|
// answer so the chosen canonical doesn't churn when peers add the same
|
||||||
// same digest under a different ordering.
|
// digest under a different ordering.
|
||||||
func Better(a, b string) string {
|
func Better(a, b string) string {
|
||||||
ra, rb := Rank(a), Rank(b)
|
ra, rb := Rank(a), Rank(b)
|
||||||
if ra > rb {
|
if ra > rb {
|
||||||
|
|||||||
+107
-62
@@ -1,85 +1,130 @@
|
|||||||
package cid
|
package cid
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"encoding/base32"
|
||||||
|
"encoding/binary"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
func TestRank_Ordering(t *testing.T) {
|
// buildCID assembles a multibase-base32 CIDv1 from a content codec, a
|
||||||
|
// multihash code, and a digest length (digest bytes are zero — only the
|
||||||
|
// header is under test). Mirrors the encoder in watcher/midhash.go and the
|
||||||
|
// TS CID.createV1(code, create(code, digest)) path.
|
||||||
|
func buildCID(codec, mhCode uint64, digestLen int) string {
|
||||||
|
var b []byte
|
||||||
|
b = binary.AppendUvarint(b, 1) // CIDv1
|
||||||
|
b = binary.AppendUvarint(b, codec)
|
||||||
|
b = binary.AppendUvarint(b, mhCode)
|
||||||
|
b = binary.AppendUvarint(b, uint64(digestLen))
|
||||||
|
b = append(b, make([]byte, digestLen)...)
|
||||||
|
enc := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b)
|
||||||
|
return "b" + strings.ToLower(enc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Real CIDs produced by @metazla/meta-hash (from its main.test.ts). For
|
||||||
|
// digest CIDs meta-hash sets codec == multihash code == algorithm code.
|
||||||
|
const (
|
||||||
|
cidCrc32 = "bagzafmqcathsbnrk"
|
||||||
|
cidMd5 = "bahkqdvibcatpcfl7ine2n625eb5ml5mzkfaa"
|
||||||
|
cidSha1 = "baeircff3eqvxxaqqot2lt242stkdtk3gq4c2vra"
|
||||||
|
cidSha256 = "baejbeibku6ua2l6r5olnggx4pto2sii2s5jeeawgh4cbkhlbmp52gudbua"
|
||||||
|
cidSha3256 = "baelbmidner7x3dd7t2kldh6wiq4w6lsye7qrwo6uoqot2t52zkhqk56fnq"
|
||||||
|
cidSha3384 = "baekrkmeglipkjf33exqexdvijl4twipruay4wsbvt44akce43lq5rgccxgr7v6dwksmj3sxedakevc53g6ga"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDecode_Fixtures(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
token string
|
name string
|
||||||
want int
|
cid string
|
||||||
|
wantMh uint64
|
||||||
}{
|
}{
|
||||||
{"ipfs:bafy", 40},
|
{"crc32", cidCrc32, CodeCrc32},
|
||||||
{"sha256:bafk", 30},
|
{"md5", cidMd5, CodeMd5},
|
||||||
{"sha3-256:abc", 30},
|
{"sha1", cidSha1, CodeSha1},
|
||||||
{"btih:def", 20},
|
{"sha2-256", cidSha256, CodeSha256},
|
||||||
{"btih-v2:def", 20},
|
{"sha3-256", cidSha3256, CodeSha3_256},
|
||||||
{"midhash256:bafk", 10},
|
{"sha3-384", cidSha3384, CodeSha3_384},
|
||||||
{"unknown:xyz", 0},
|
|
||||||
{"", 0},
|
|
||||||
{"bare-no-colon", 0},
|
|
||||||
}
|
}
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
if got := Rank(tc.token); got != tc.want {
|
codec, mh, err := Decode(tc.cid)
|
||||||
t.Errorf("Rank(%q) = %d, want %d", tc.token, got, tc.want)
|
if err != nil {
|
||||||
|
t.Errorf("Decode(%s %q) error: %v", tc.name, tc.cid, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// meta-hash digest CIDs use codec == multihash code.
|
||||||
|
if codec != tc.wantMh || mh != tc.wantMh {
|
||||||
|
t.Errorf("Decode(%s) = codec 0x%x mh 0x%x, want both 0x%x", tc.name, codec, mh, tc.wantMh)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecode_Rejects(t *testing.T) {
|
||||||
|
for _, in := range []string{"", "Qmabc", "x", "bzzzz!!"} {
|
||||||
|
if _, _, err := Decode(in); err == nil {
|
||||||
|
t.Errorf("Decode(%q) expected error, got nil", in)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRank_Tiers(t *testing.T) {
|
||||||
|
ipfsDagPB := buildCID(CodecDagPB, CodeSha256, 32) // chunked IPFS file
|
||||||
|
rawSha256 := buildCID(CodecRaw, CodeSha256, 32) // fullhash-style sha2-256
|
||||||
|
btihV2meta := buildCID(CodeBtihV2, CodeBtihV2, 32) // meta-hash btih-v2 (codec==mh)
|
||||||
|
btihV2raw := buildCID(CodecRaw, CodeBtihV2, 32) // fullhash btih-v2 (raw codec)
|
||||||
|
btV1File := buildCID(CodeBtihV1File, CodeBtihV1File, 20) // gateway per-file btih v1
|
||||||
|
midhashRaw := buildCID(CodecRaw, CodeMidhash256, 32) // fullhash midhash (raw codec)
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
cid string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"ipfs-dagpb", ipfsDagPB, 40},
|
||||||
|
{"sha2-256(fixture, codec==mh)", cidSha256, 30},
|
||||||
|
{"sha2-256(raw codec)", rawSha256, 30},
|
||||||
|
{"sha3-256", cidSha3256, 30},
|
||||||
|
{"sha3-384", cidSha3384, 30},
|
||||||
|
{"btih-v2(codec==mh)", btihV2meta, 20},
|
||||||
|
{"btih-v2(raw codec)", btihV2raw, 20},
|
||||||
|
{"btih-v1-file", btV1File, 20},
|
||||||
|
{"midhash256(raw codec)", midhashRaw, 10},
|
||||||
|
{"sha1", cidSha1, 0},
|
||||||
|
{"md5", cidMd5, 0},
|
||||||
|
{"crc32", cidCrc32, 0},
|
||||||
|
{"garbage", "not-a-cid", 0},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
if got := Rank(tc.cid); got != tc.want {
|
||||||
|
t.Errorf("Rank(%s) = %d, want %d", tc.name, got, tc.want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRank_StrictOrdering(t *testing.T) {
|
func TestRank_StrictOrdering(t *testing.T) {
|
||||||
// The exact numbers don't matter, but the inequality chain does — this
|
ipfs := Rank(buildCID(CodecDagPB, CodeSha256, 32))
|
||||||
// is what the canonical_cid reconciler relies on.
|
sha := Rank(cidSha256)
|
||||||
ipfs := Rank("ipfs:x")
|
btih := Rank(buildCID(CodeBtihV2, CodeBtihV2, 32))
|
||||||
sha := Rank("sha256:x")
|
mid := Rank(buildCID(CodeMidhash256, CodeMidhash256, 32))
|
||||||
btih := Rank("btih:x")
|
unknown := Rank(cidMd5)
|
||||||
mid := Rank("midhash256:x")
|
|
||||||
unknown := Rank("unknown:x")
|
|
||||||
|
|
||||||
if !(ipfs > sha && sha > btih && btih > mid && mid > unknown) {
|
if !(ipfs > sha && sha > btih && btih > mid && mid > unknown) {
|
||||||
t.Fatalf("rank ordering broken: ipfs=%d sha=%d btih=%d mid=%d unknown=%d",
|
t.Fatalf("rank ordering broken: ipfs=%d sha=%d btih=%d mid=%d unknown=%d",
|
||||||
ipfs, sha, btih, mid, unknown)
|
ipfs, sha, btih, mid, unknown)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAlgorithmOf(t *testing.T) {
|
|
||||||
cases := map[string]string{
|
|
||||||
"ipfs:bafy": "ipfs",
|
|
||||||
"sha256:bafk": "sha256",
|
|
||||||
"midhash256:abc": "midhash256",
|
|
||||||
"": "",
|
|
||||||
"no-colon": "",
|
|
||||||
":leading-colon": "", // empty algo not allowed
|
|
||||||
}
|
|
||||||
for in, want := range cases {
|
|
||||||
if got := AlgorithmOf(in); got != want {
|
|
||||||
t.Errorf("AlgorithmOf(%q) = %q, want %q", in, got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestToken(t *testing.T) {
|
|
||||||
if got := Token("sha256", "abc"); got != "sha256:abc" {
|
|
||||||
t.Errorf("Token(sha256, abc) = %q", got)
|
|
||||||
}
|
|
||||||
if got := Token("", "abc"); got != "" {
|
|
||||||
t.Errorf("Token with empty algo should return empty, got %q", got)
|
|
||||||
}
|
|
||||||
if got := Token("sha256", ""); got != "" {
|
|
||||||
t.Errorf("Token with empty value should return empty, got %q", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBetter(t *testing.T) {
|
func TestBetter(t *testing.T) {
|
||||||
// IPFS beats sha256
|
dagpb := buildCID(CodecDagPB, CodeSha256, 32)
|
||||||
if got := Better("ipfs:a", "sha256:b"); got != "ipfs:a" {
|
// dag-pb (IPFS) beats a sha2-256 digest, regardless of arg order.
|
||||||
t.Errorf("Better(ipfs, sha256) = %q, want ipfs:a", got)
|
if got := Better(dagpb, cidSha256); got != dagpb {
|
||||||
|
t.Errorf("Better(dagpb, sha256) = %q, want dagpb", got)
|
||||||
}
|
}
|
||||||
if got := Better("sha256:b", "ipfs:a"); got != "ipfs:a" {
|
if got := Better(cidSha256, dagpb); got != dagpb {
|
||||||
t.Errorf("Better(sha256, ipfs) = %q, want ipfs:a (order shouldn't matter)", got)
|
t.Errorf("Better(sha256, dagpb) = %q, want dagpb (order shouldn't matter)", got)
|
||||||
}
|
}
|
||||||
// Tie → lexicographically smaller wins, deterministic
|
// Tie → lexicographically smaller wins, deterministic.
|
||||||
if got := Better("ipfs:b", "ipfs:a"); got != "ipfs:a" {
|
a, b := "bafyaaa", "bafyaab"
|
||||||
t.Errorf("Better(ipfs:b, ipfs:a) = %q, want ipfs:a", got)
|
if Better(a, b) != a || Better(b, a) != a {
|
||||||
}
|
t.Errorf("tie-break not deterministic for %q vs %q", a, b)
|
||||||
if got := Better("ipfs:a", "ipfs:b"); got != "ipfs:a" {
|
|
||||||
t.Errorf("Better(ipfs:a, ipfs:b) = %q, want ipfs:a", got)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,6 +104,18 @@ func (c *Config) WatchersFilePath() string {
|
|||||||
return c.MetaCorePath + "/watchers.json"
|
return c.MetaCorePath + "/watchers.json"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IdentityDir returns the directory holding the User Data Layer signing
|
||||||
|
// identity (created by the dashboard's Identity menu — never auto-generated
|
||||||
|
// at startup).
|
||||||
|
func (c *Config) IdentityDir() string {
|
||||||
|
return c.MetaCorePath + "/identity"
|
||||||
|
}
|
||||||
|
|
||||||
|
// IdentityFilePath returns the path to the persisted identity JSON.
|
||||||
|
func (c *Config) IdentityFilePath() string {
|
||||||
|
return c.IdentityDir() + "/identity.json"
|
||||||
|
}
|
||||||
|
|
||||||
func getEnv(key, defaultValue string) string {
|
func getEnv(key, defaultValue string) string {
|
||||||
if value := os.Getenv(key); value != "" {
|
if value := os.Getenv(key); value != "" {
|
||||||
return value
|
return value
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
// Package identity manages the user-controlled signing identity used by the
|
||||||
|
// User Data Layer (see docs/project-architecture/user-data-layer.md).
|
||||||
|
//
|
||||||
|
// The keypair is secp256k1 (D1: wallet forward-compat). The uid is the
|
||||||
|
// multibase base58btc encoding of the 33-byte compressed pubkey, prefixed
|
||||||
|
// with "z" per multibase convention.
|
||||||
|
//
|
||||||
|
// Storage is plaintext JSON at {META_CORE_PATH}/identity/identity.json,
|
||||||
|
// matching the trust model of the rest of /meta-core. Signing happens
|
||||||
|
// in-process; meta-watch calls POST /api/identity/sign rather than holding
|
||||||
|
// the private key itself.
|
||||||
|
package identity
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/decred/dcrd/dcrec/secp256k1/v4"
|
||||||
|
"github.com/decred/dcrd/dcrec/secp256k1/v4/ecdsa"
|
||||||
|
"github.com/mr-tron/base58"
|
||||||
|
"golang.org/x/crypto/hkdf"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
CurveSecp256k1 = "secp256k1"
|
||||||
|
|
||||||
|
// HKDF parameters for deriving the AEAD key from the private key.
|
||||||
|
// Stable across versions — changing these is a breaking change for
|
||||||
|
// encrypted records on disk.
|
||||||
|
aeadKDFSalt = "metamesh-uds-v1"
|
||||||
|
aeadKDFInfo = "aead-key"
|
||||||
|
aeadKeyLen = 32
|
||||||
|
)
|
||||||
|
|
||||||
|
// Identity is the persisted form of a user's keypair.
|
||||||
|
//
|
||||||
|
// PrivateKeyHex is the 32-byte secp256k1 scalar, lower-case hex, no prefix.
|
||||||
|
// PublicKeyHex is the 33-byte compressed pubkey, lower-case hex, no prefix.
|
||||||
|
type Identity struct {
|
||||||
|
UID string `json:"uid"`
|
||||||
|
Curve string `json:"curve"`
|
||||||
|
PublicKeyHex string `json:"publicKeyHex"`
|
||||||
|
PrivateKeyHex string `json:"privateKeyHex"`
|
||||||
|
CreatedAt int64 `json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate creates a fresh secp256k1 identity.
|
||||||
|
func Generate() (*Identity, error) {
|
||||||
|
priv, err := secp256k1.GeneratePrivateKey()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("generate keypair: %w", err)
|
||||||
|
}
|
||||||
|
return identityFromPrivKey(priv), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import parses an externally-provided 32-byte secp256k1 scalar and derives
|
||||||
|
// the matching pubkey. Accepts hex with or without a "0x" prefix.
|
||||||
|
func Import(privateKeyHex string) (*Identity, error) {
|
||||||
|
if len(privateKeyHex) >= 2 && (privateKeyHex[:2] == "0x" || privateKeyHex[:2] == "0X") {
|
||||||
|
privateKeyHex = privateKeyHex[2:]
|
||||||
|
}
|
||||||
|
raw, err := hex.DecodeString(privateKeyHex)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid hex: %w", err)
|
||||||
|
}
|
||||||
|
if len(raw) != 32 {
|
||||||
|
return nil, fmt.Errorf("private key must be 32 bytes, got %d", len(raw))
|
||||||
|
}
|
||||||
|
priv := secp256k1.PrivKeyFromBytes(raw)
|
||||||
|
// Reject the zero scalar (secp256k1 lib does not reject it on its own).
|
||||||
|
pubBytes := priv.PubKey().SerializeCompressed()
|
||||||
|
if isAllZero(raw) || isAllZero(pubBytes) {
|
||||||
|
return nil, errors.New("private key is the curve identity (zero); refusing")
|
||||||
|
}
|
||||||
|
return identityFromPrivKey(priv), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func identityFromPrivKey(priv *secp256k1.PrivateKey) *Identity {
|
||||||
|
pubBytes := priv.PubKey().SerializeCompressed()
|
||||||
|
return &Identity{
|
||||||
|
UID: uidFromCompressedPubkey(pubBytes),
|
||||||
|
Curve: CurveSecp256k1,
|
||||||
|
PublicKeyHex: hex.EncodeToString(pubBytes),
|
||||||
|
PrivateKeyHex: hex.EncodeToString(priv.Serialize()),
|
||||||
|
CreatedAt: time.Now().UTC().Unix(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// uidFromCompressedPubkey encodes the pubkey as a multibase base58btc
|
||||||
|
// string ("z" prefix). The encoding is deterministic and matches MetaMesh
|
||||||
|
// CID conventions.
|
||||||
|
func uidFromCompressedPubkey(pub []byte) string {
|
||||||
|
return "z" + base58.Encode(pub)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load reads the identity file. Returns (nil, nil) if it does not exist —
|
||||||
|
// callers distinguish "no identity" from real errors.
|
||||||
|
func Load(path string) (*Identity, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("read identity file: %w", err)
|
||||||
|
}
|
||||||
|
var id Identity
|
||||||
|
if err := json.Unmarshal(data, &id); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse identity file: %w", err)
|
||||||
|
}
|
||||||
|
if id.Curve != CurveSecp256k1 {
|
||||||
|
return nil, fmt.Errorf("unsupported curve on disk: %q", id.Curve)
|
||||||
|
}
|
||||||
|
return &id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save writes the identity atomically (tmp + rename), matching the pattern
|
||||||
|
// used by discovery/service.go.
|
||||||
|
func Save(path string, id *Identity) error {
|
||||||
|
if id == nil {
|
||||||
|
return errors.New("nil identity")
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
|
||||||
|
return fmt.Errorf("mkdir identity dir: %w", err)
|
||||||
|
}
|
||||||
|
data, err := json.MarshalIndent(id, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal identity: %w", err)
|
||||||
|
}
|
||||||
|
tmp := path + ".tmp"
|
||||||
|
if err := os.WriteFile(tmp, data, 0600); err != nil {
|
||||||
|
return fmt.Errorf("write tmp identity: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmp, path); err != nil {
|
||||||
|
return fmt.Errorf("rename tmp identity: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes the identity file. Idempotent.
|
||||||
|
func Delete(path string) error {
|
||||||
|
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exists reports whether an identity file is present.
|
||||||
|
func Exists(path string) bool {
|
||||||
|
_, err := os.Stat(path)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sign produces an ECDSA-secp256k1 signature over SHA-256(bytes). The output
|
||||||
|
// is DER-encoded — the wire format we commit to for User Data Layer records.
|
||||||
|
func Sign(id *Identity, bytes []byte) ([]byte, error) {
|
||||||
|
if id == nil {
|
||||||
|
return nil, errors.New("no identity loaded")
|
||||||
|
}
|
||||||
|
raw, err := hex.DecodeString(id.PrivateKeyHex)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("decode private key: %w", err)
|
||||||
|
}
|
||||||
|
if len(raw) != 32 {
|
||||||
|
return nil, fmt.Errorf("private key length %d", len(raw))
|
||||||
|
}
|
||||||
|
priv := secp256k1.PrivKeyFromBytes(raw)
|
||||||
|
digest := sha256.Sum256(bytes)
|
||||||
|
sig := ecdsa.Sign(priv, digest[:])
|
||||||
|
return sig.Serialize(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeriveAEADKey derives the symmetric key used to encrypt private-tier
|
||||||
|
// User Data Layer records. The KDF parameters (salt, info, length) are
|
||||||
|
// fixed for the v1 wire format.
|
||||||
|
func DeriveAEADKey(id *Identity) ([]byte, error) {
|
||||||
|
if id == nil {
|
||||||
|
return nil, errors.New("no identity loaded")
|
||||||
|
}
|
||||||
|
raw, err := hex.DecodeString(id.PrivateKeyHex)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("decode private key: %w", err)
|
||||||
|
}
|
||||||
|
r := hkdf.New(sha256.New, raw, []byte(aeadKDFSalt), []byte(aeadKDFInfo))
|
||||||
|
out := make([]byte, aeadKeyLen)
|
||||||
|
if _, err := io.ReadFull(r, out); err != nil {
|
||||||
|
return nil, fmt.Errorf("hkdf: %w", err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isAllZero(b []byte) bool {
|
||||||
|
for _, x := range b {
|
||||||
|
if x != 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure rand.Reader is referenced so dependency-stripped builds don't
|
||||||
|
// accidentally remove the crypto/rand import that secp256k1.GeneratePrivateKey
|
||||||
|
// relies on transitively.
|
||||||
|
var _ = rand.Reader
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
package identity
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/decred/dcrd/dcrec/secp256k1/v4"
|
||||||
|
"github.com/decred/dcrd/dcrec/secp256k1/v4/ecdsa"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGenerateUIDIsDeterministicFromPubkey(t *testing.T) {
|
||||||
|
id, err := Generate()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(id.UID, "z") {
|
||||||
|
t.Fatalf("uid missing multibase 'z' prefix: %q", id.UID)
|
||||||
|
}
|
||||||
|
if id.Curve != CurveSecp256k1 {
|
||||||
|
t.Fatalf("curve = %q", id.Curve)
|
||||||
|
}
|
||||||
|
if len(id.PrivateKeyHex) != 64 {
|
||||||
|
t.Fatalf("privkey hex len = %d", len(id.PrivateKeyHex))
|
||||||
|
}
|
||||||
|
if len(id.PublicKeyHex) != 66 {
|
||||||
|
t.Fatalf("compressed pubkey hex len = %d", len(id.PublicKeyHex))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reimporting yields the same uid.
|
||||||
|
imported, err := Import(id.PrivateKeyHex)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("import: %v", err)
|
||||||
|
}
|
||||||
|
if imported.UID != id.UID {
|
||||||
|
t.Fatalf("uid changed after import roundtrip: %q vs %q", id.UID, imported.UID)
|
||||||
|
}
|
||||||
|
if imported.PublicKeyHex != id.PublicKeyHex {
|
||||||
|
t.Fatalf("pubkey changed after import roundtrip")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImportRejectsBadInput(t *testing.T) {
|
||||||
|
if _, err := Import(""); err == nil {
|
||||||
|
t.Fatal("empty hex should error")
|
||||||
|
}
|
||||||
|
if _, err := Import("zz"); err == nil {
|
||||||
|
t.Fatal("non-hex should error")
|
||||||
|
}
|
||||||
|
if _, err := Import("00"); err == nil {
|
||||||
|
t.Fatal("wrong-length hex should error")
|
||||||
|
}
|
||||||
|
if _, err := Import(strings.Repeat("00", 32)); err == nil {
|
||||||
|
t.Fatal("zero scalar should error")
|
||||||
|
}
|
||||||
|
// Hex with 0x prefix should work.
|
||||||
|
id1, err := Generate()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
id2, err := Import("0x" + id1.PrivateKeyHex)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("0x-prefixed hex: %v", err)
|
||||||
|
}
|
||||||
|
if id1.UID != id2.UID {
|
||||||
|
t.Fatal("uid mismatch through 0x prefix")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSaveLoadRoundtrip(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "identity.json")
|
||||||
|
|
||||||
|
if Exists(path) {
|
||||||
|
t.Fatal("file should not exist yet")
|
||||||
|
}
|
||||||
|
if got, _ := Load(path); got != nil {
|
||||||
|
t.Fatal("Load on missing file should return nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := Generate()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := Save(path, id); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !Exists(path) {
|
||||||
|
t.Fatal("file should exist after Save")
|
||||||
|
}
|
||||||
|
|
||||||
|
loaded, err := Load(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if loaded == nil || loaded.UID != id.UID || loaded.PrivateKeyHex != id.PrivateKeyHex {
|
||||||
|
t.Fatalf("roundtrip mismatch: %+v vs %+v", id, loaded)
|
||||||
|
}
|
||||||
|
|
||||||
|
// File should be 0600 (private key on disk).
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if info.Mode().Perm() != 0600 {
|
||||||
|
t.Errorf("identity file mode = %o, want 0600", info.Mode().Perm())
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := Delete(path); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if Exists(path) {
|
||||||
|
t.Fatal("file should not exist after Delete")
|
||||||
|
}
|
||||||
|
// Delete is idempotent.
|
||||||
|
if err := Delete(path); err != nil {
|
||||||
|
t.Fatalf("Delete on missing file should be nil: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSignAndVerify(t *testing.T) {
|
||||||
|
id, err := Generate()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
msg := []byte("hello user data layer")
|
||||||
|
sig, err := Sign(id, msg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(sig) == 0 {
|
||||||
|
t.Fatal("empty sig")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify externally with the public key, matching what meta-watch will do.
|
||||||
|
pubBytes, err := hex.DecodeString(id.PublicKeyHex)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
pub, err := secp256k1.ParsePubKey(pubBytes)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
parsedSig, err := ecdsa.ParseDERSignature(sig)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse DER sig: %v", err)
|
||||||
|
}
|
||||||
|
digest := sha256.Sum256(msg)
|
||||||
|
if !parsedSig.Verify(digest[:], pub) {
|
||||||
|
t.Fatal("signature did not verify")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flipping a byte must break verification.
|
||||||
|
tampered := append([]byte{}, msg...)
|
||||||
|
tampered[0] ^= 0x01
|
||||||
|
digest2 := sha256.Sum256(tampered)
|
||||||
|
if parsedSig.Verify(digest2[:], pub) {
|
||||||
|
t.Fatal("signature verified on tampered message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeriveAEADKeyIsDeterministic(t *testing.T) {
|
||||||
|
id, err := Generate()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
k1, err := DeriveAEADKey(id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
k2, err := DeriveAEADKey(id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !bytesEqual(k1, k2) {
|
||||||
|
t.Fatal("AEAD key derivation is non-deterministic")
|
||||||
|
}
|
||||||
|
if len(k1) != aeadKeyLen {
|
||||||
|
t.Fatalf("AEAD key len = %d, want %d", len(k1), aeadKeyLen)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Different identity → different key.
|
||||||
|
other, _ := Generate()
|
||||||
|
k3, _ := DeriveAEADKey(other)
|
||||||
|
if bytesEqual(k1, k3) {
|
||||||
|
t.Fatal("two distinct identities derived the same AEAD key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func bytesEqual(a, b []byte) bool {
|
||||||
|
if len(a) != len(b) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := range a {
|
||||||
|
if a[i] != b[i] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
+158
-208
@@ -12,37 +12,38 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// This file implements the CID-resolution layer that lives on top of the
|
// This file implements the CID-resolution layer that lives on top of the
|
||||||
// flat metadata storage in client.go. The shape is described in detail in
|
// flat metadata storage in client.go. The short version:
|
||||||
// docs/uuid-rooted-metadata.md; the short version:
|
|
||||||
//
|
//
|
||||||
// - Roots are opaque UUIDv7s — file:<uuid>/<property> for every property
|
// - Roots are opaque UUIDv7s — file:<uuid>/<property> for every property.
|
||||||
|
// The UUID is an internal implementation detail; the mental model is
|
||||||
|
// "N CIDs → one record of keys→metadata".
|
||||||
// - Every known CID for a file is registered as a reverse-index entry:
|
// - Every known CID for a file is registered as a reverse-index entry:
|
||||||
// cid:<algorithm>:<value> → <uuid> (Redis STRING)
|
// cid:<bareCid> → <uuid> (Redis STRING)
|
||||||
// and mirrored in a per-root set for fast enumeration:
|
// and as a member of the per-root bare-CID key-set:
|
||||||
// file:<uuid>/cids (Redis SET)
|
// file:<uuid>/cids/<bareCid> = "true" (flat key, sentinel value)
|
||||||
// - A canonical_cid field tracks the best (rank-wise) CID to advertise
|
// - A CID is self-describing: its algorithm is recoverable from the CIDv1
|
||||||
// externally; updated by reconcileCanonicalCIDLocked whenever the set
|
// multicodec (see internal/cid), so there is no per-algorithm field name
|
||||||
// changes
|
// and no <algo>: token prefix. The canonical externally-visible CID is
|
||||||
|
// *derived by rank* from the key-set on read (GetMetadataDocument), never
|
||||||
|
// stored.
|
||||||
//
|
//
|
||||||
// Auto-alias hooks in SetProperty/SetMetadataFlat/MergeMetadataFlat catch
|
// Auto-alias hooks in SetProperty/SetMetadataFlat/MergeMetadataFlat catch
|
||||||
// any write of a cid_* or midhash256 field and register the alias, so
|
// any write of a cids/<cid> field and register the reverse-index entry, so
|
||||||
// plugins that just write their result fields don't need to know about
|
// writers that just emit the key-set members don't need to know about the
|
||||||
// the reverse index at all. The hook helpers are *Locked — they assume
|
// reverse index at all. The hook helpers are *Locked — they assume c.mu is
|
||||||
// c.mu is already held by the caller, avoiding nested RLock (which can
|
// already held by the caller, avoiding nested RLock (which can deadlock
|
||||||
// deadlock against a waiting writer).
|
// against a waiting writer).
|
||||||
|
|
||||||
// CIDIndexPrefix is the Redis key prefix for the reverse index. Combined
|
// CIDIndexPrefix is the Redis key prefix for the reverse index. Combined
|
||||||
// with a CID token, it forms the full key: "cid:midhash256:bafk…".
|
// with a bare CID, it forms the full key: "cid:bafk…".
|
||||||
const CIDIndexPrefix = "cid:"
|
const CIDIndexPrefix = "cid:"
|
||||||
|
|
||||||
// CIDsField is the per-root SET key that mirrors all CID aliases for a file.
|
// CIDsKeyPrefix is the field-name prefix for the bare-CID key-set. Sibling
|
||||||
// Stored at file:<uuid>/cids — read by reconcileCanonicalCIDLocked and by
|
// CIDs live as flat keys file:<uuid>/cids/<cid> = "true"; the set IS the
|
||||||
// DeleteRoot to find every reverse-index entry that needs to be cleaned up.
|
// keyset, the value is the sentinel. Read by GetMetadataDocument (hoisted
|
||||||
const CIDsField = "cids"
|
// into the typed CIDs array) and by DeleteRoot/DeleteMetadata (to find the
|
||||||
|
// reverse-index entries that need cleanup).
|
||||||
// CanonicalCIDField is the per-root STRING key that stores the externally
|
const CIDsKeyPrefix = "cids/"
|
||||||
// preferred CID for this file. Picked by cid.Better over the cids set.
|
|
||||||
const CanonicalCIDField = "canonical_cid"
|
|
||||||
|
|
||||||
// DuplicatesField is the per-root SET key holding additional file paths
|
// DuplicatesField is the per-root SET key holding additional file paths
|
||||||
// whose content hashes to the same CIDs as this root. The watcher writes
|
// whose content hashes to the same CIDs as this root. The watcher writes
|
||||||
@@ -51,9 +52,9 @@ const CanonicalCIDField = "canonical_cid"
|
|||||||
const DuplicatesField = "duplicates"
|
const DuplicatesField = "duplicates"
|
||||||
|
|
||||||
// buildCIDIndexKey returns the absolute Redis key for a reverse-index entry.
|
// buildCIDIndexKey returns the absolute Redis key for a reverse-index entry.
|
||||||
// token is a CID in prefixed form ("midhash256:bafk…", "sha256:…", etc.).
|
// cidStr is a bare multibase CIDv1 ("bafk…").
|
||||||
func (c *Client) buildCIDIndexKey(token string) string {
|
func (c *Client) buildCIDIndexKey(cidStr string) string {
|
||||||
return c.buildKey(CIDIndexPrefix + token)
|
return c.buildKey(CIDIndexPrefix + cidStr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mint creates a new root with filePath/sizeByte/mtimeNano and registers it
|
// Mint creates a new root with filePath/sizeByte/mtimeNano and registers it
|
||||||
@@ -88,11 +89,10 @@ func (c *Client) Mint(filePath string, size, mtimeNano int64) (string, error) {
|
|||||||
return uuid, nil
|
return uuid, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddAlias registers a CID token as resolving to uuid. Idempotent: writing
|
// AddAlias registers cidStr as a sibling CID of uuid: it writes the reverse
|
||||||
// the same alias twice is a no-op. After the alias is registered, the
|
// index cid:<cidStr> → uuid and the key-set member cids/<cidStr> = "true".
|
||||||
// canonical_cid field is reconciled — picks the highest-ranked token in
|
// Idempotent: registering the same CID twice is a no-op.
|
||||||
// the cids set and writes it to canonical_cid if it changed.
|
func (c *Client) AddAlias(uuid, cidStr string) error {
|
||||||
func (c *Client) AddAlias(uuid, token string) error {
|
|
||||||
c.mu.RLock()
|
c.mu.RLock()
|
||||||
defer c.mu.RUnlock()
|
defer c.mu.RUnlock()
|
||||||
|
|
||||||
@@ -103,119 +103,102 @@ func (c *Client) AddAlias(uuid, token string) error {
|
|||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
return c.addAliasLocked(ctx, uuid, token)
|
return c.addAliasLocked(ctx, uuid, cidStr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// addAliasLocked is the lock-free body of AddAlias. The caller must already
|
// addAliasLocked is the lock-free body of AddAlias. The caller must already
|
||||||
// hold c.mu (read or write). Splits out so the alias-maintenance hooks
|
// hold c.mu (read or write). Split out so the alias-maintenance hooks
|
||||||
// invoked from SetProperty / SetMetadataFlat / MergeMetadataFlat can reuse
|
// invoked from SetProperty / SetMetadataFlat / MergeMetadataFlat can reuse
|
||||||
// the work without re-entering the lock — nested RLock can deadlock
|
// the work without re-entering the lock — nested RLock can deadlock against
|
||||||
// against a waiting writer.
|
// a waiting writer.
|
||||||
func (c *Client) addAliasLocked(ctx context.Context, uuid, token string) error {
|
func (c *Client) addAliasLocked(ctx context.Context, uuid, cidStr string) error {
|
||||||
if uuid == "" || token == "" {
|
if uuid == "" || cidStr == "" {
|
||||||
return fmt.Errorf("uuid and token are required")
|
return fmt.Errorf("uuid and cid are required")
|
||||||
}
|
|
||||||
if cid.AlgorithmOf(token) == "" {
|
|
||||||
return fmt.Errorf("token %q is not in <algorithm>:<value> form", token)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
keysetKey := c.buildKeyPrefix(uuid) + CIDsKeyPrefix + cidStr
|
||||||
|
|
||||||
// Self-pointing alias guard. If the caller is trying to register
|
// Self-pointing alias guard. If the caller is trying to register
|
||||||
// `cid:<algo>:<value>` → `<value>` (i.e. the root IS the cid value),
|
// cid:<cid> → <cid> (i.e. the root IS the cid value), that's the
|
||||||
// that's the dual-root failure mode in disguise — it means somebody
|
// dual-root failure mode in disguise — it means somebody wrote
|
||||||
// wrote /meta/<value> without first resolving via ResolveRoot. Refuse
|
// /meta/<cid> without first resolving via ResolveRoot. Refuse the alias
|
||||||
// the alias write; leave the existing alias (if any) alone. Still
|
// write; leave any existing alias alone. Still record the key-set
|
||||||
// add to this root's cids set so the field-level write succeeds.
|
// membership so the field-level write succeeds.
|
||||||
if uuid == cid.ValueOf(token) {
|
if uuid == cidStr {
|
||||||
if _, err := c.client.SAdd(ctx, c.buildKeyPrefix(uuid)+CIDsField, token).Result(); err != nil {
|
return c.client.Set(ctx, keysetKey, "true", 0).Err()
|
||||||
return fmt.Errorf("sadd cids set: %w", err)
|
|
||||||
}
|
|
||||||
return c.reconcileCanonicalCIDLocked(ctx, uuid)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Guard against the dual-root pattern: if `cid:<token>` already points
|
// Guard against the dual-root pattern: if cid:<cid> already points at a
|
||||||
// at a DIFFERENT uuid, don't silently overwrite. The historical bug:
|
// DIFFERENT uuid, don't silently overwrite. Leave the existing alias and
|
||||||
// meta-sort writing cid_midhash256=<v> to root "<v>" caused the hook
|
// just record the key-set membership so it's still discoverable via the
|
||||||
// to register cid:midhash256:<v> → <v>, severing the watcher's prior
|
// root → cids enumeration.
|
||||||
// cid:midhash256:<v> → <real-uuid> alias. With write-path resolution
|
indexKey := c.buildCIDIndexKey(cidStr)
|
||||||
// now in place callers shouldn't hit this anymore, but the guard is
|
|
||||||
// cheap and prevents regression if a new caller bypasses ResolveRoot.
|
|
||||||
indexKey := c.buildCIDIndexKey(token)
|
|
||||||
existing, err := c.client.Get(ctx, indexKey).Result()
|
existing, err := c.client.Get(ctx, indexKey).Result()
|
||||||
if err != nil && err != redis.Nil {
|
if err != nil && err != redis.Nil {
|
||||||
return fmt.Errorf("read existing alias: %w", err)
|
return fmt.Errorf("read existing alias: %w", err)
|
||||||
}
|
}
|
||||||
if existing != "" && existing != uuid {
|
if existing != "" && existing != uuid {
|
||||||
// Existing alias points elsewhere. Leave it alone and just add the
|
return c.client.Set(ctx, keysetKey, "true", 0).Err()
|
||||||
// token to this root's cids set so it's still discoverable via the
|
|
||||||
// root → cids enumeration.
|
|
||||||
if _, err := c.client.SAdd(ctx, c.buildKeyPrefix(uuid)+CIDsField, token).Result(); err != nil {
|
|
||||||
return fmt.Errorf("sadd cids set: %w", err)
|
|
||||||
}
|
|
||||||
return c.reconcileCanonicalCIDLocked(ctx, uuid)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
prefix := c.buildKeyPrefix(uuid)
|
|
||||||
pipe := c.client.Pipeline()
|
pipe := c.client.Pipeline()
|
||||||
pipe.Set(ctx, indexKey, uuid, 0)
|
pipe.Set(ctx, indexKey, uuid, 0)
|
||||||
pipe.SAdd(ctx, prefix+CIDsField, token)
|
pipe.Set(ctx, keysetKey, "true", 0)
|
||||||
if _, err := pipe.Exec(ctx); err != nil {
|
if _, err := pipe.Exec(ctx); err != nil {
|
||||||
return fmt.Errorf("add alias pipeline: %w", err)
|
return fmt.Errorf("add alias pipeline: %w", err)
|
||||||
}
|
}
|
||||||
return c.reconcileCanonicalCIDLocked(ctx, uuid)
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResolveRoot maps a hash supplied by a caller (a meta-sort write, an
|
// ResolveRoot maps a hash supplied by a caller (a meta-sort write, an editor
|
||||||
// editor lookup, an SSE consumer) to the actual storage root key.
|
// lookup, an SSE consumer) to the actual storage root key.
|
||||||
//
|
//
|
||||||
// Callers historically send a bare midhash256 value (e.g.
|
// Callers send a bare CID (e.g. the midhash "bafk…" the watcher publishes on
|
||||||
// "bagacba…") because that's what the watcher publishes on file:events.
|
// file:events, or any sibling CID). Roots are kept opaque and every CID is
|
||||||
// The UUID-rooted-metadata design (see docs/uuid-rooted-metadata.md)
|
// aliased into the reverse index:
|
||||||
// keeps roots opaque and aliases CIDs into the reverse index:
|
|
||||||
//
|
//
|
||||||
// cid:midhash256:bagacba… → 01KSBHM*
|
// cid:bafk… → 01KSBHM*
|
||||||
//
|
//
|
||||||
// Without resolution, every /meta/<midhash> write creates a parallel
|
// Without resolution, every /meta/<cid> write creates a parallel cid-rooted
|
||||||
// midhash-rooted entry that the watcher's UUID never sees. The editor
|
// entry that the watcher's UUID never sees.
|
||||||
// then shows two entries per file: the (rich) midhash root and the
|
|
||||||
// (empty) UUID root containing only `duplicates`.
|
|
||||||
//
|
//
|
||||||
// Behaviour:
|
// Behaviour:
|
||||||
// - empty hash → empty (caller's problem)
|
// - empty hash → empty (caller's problem)
|
||||||
// - hash matches cid:midhash256:<hash> in the reverse index → return UUID
|
// - hash matches a reverse-index entry → return UUID
|
||||||
// - otherwise return hash unchanged (legacy / explicit-UUID writes)
|
// - otherwise return hash unchanged (legacy / explicit-UUID writes)
|
||||||
//
|
//
|
||||||
// Errors are converted to "no resolution" — if Redis is down or the
|
// Errors are converted to "no resolution" — if Redis is down or the lookup
|
||||||
// lookup fails we let the caller proceed with the bare hash and surface
|
// fails we let the caller proceed with the bare hash and surface the Redis
|
||||||
// the Redis failure on the actual operation.
|
// failure on the actual operation.
|
||||||
func (c *Client) ResolveRoot(hash string) string {
|
func (c *Client) ResolveRoot(hash string) string {
|
||||||
if hash == "" {
|
if hash == "" {
|
||||||
return hash
|
return hash
|
||||||
}
|
}
|
||||||
uuid, err := c.GetByCID("midhash256:" + hash)
|
uuid, err := c.GetByCID(hash)
|
||||||
if err != nil || uuid == "" {
|
if err != nil || uuid == "" {
|
||||||
return hash
|
return hash
|
||||||
}
|
}
|
||||||
return uuid
|
return uuid
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetByCID resolves a CID token to the UUID of its root. Returns ("", nil)
|
// GetByCID resolves a bare CID to the UUID of its root. Returns ("", nil)
|
||||||
// when the token is unknown; the empty string is the "not found" signal
|
// when the CID is unknown; the empty string is the "not found" signal rather
|
||||||
// rather than a typed error so callers can branch without errors.As.
|
// than a typed error so callers can branch without errors.As.
|
||||||
func (c *Client) GetByCID(token string) (string, error) {
|
func (c *Client) GetByCID(cidStr string) (string, error) {
|
||||||
c.mu.RLock()
|
c.mu.RLock()
|
||||||
defer c.mu.RUnlock()
|
defer c.mu.RUnlock()
|
||||||
|
|
||||||
if c.client == nil {
|
if c.client == nil {
|
||||||
return "", fmt.Errorf("not connected")
|
return "", fmt.Errorf("not connected")
|
||||||
}
|
}
|
||||||
if token == "" {
|
if cidStr == "" {
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
uuid, err := c.client.Get(ctx, c.buildCIDIndexKey(token)).Result()
|
uuid, err := c.client.Get(ctx, c.buildCIDIndexKey(cidStr)).Result()
|
||||||
if err == redis.Nil {
|
if err == redis.Nil {
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
@@ -250,9 +233,32 @@ func (c *Client) AddDuplicatePath(uuid, path string) (bool, error) {
|
|||||||
return added > 0, nil
|
return added > 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteRoot removes everything keyed off uuid: every file:<uuid>/* property,
|
// cidsForRootLocked scans the bare-CID key-set for a root and returns its
|
||||||
// every reverse-index entry listed in the cids set, and the index membership.
|
// members (bare CIDs, with the cids/ prefix stripped). Caller must hold c.mu.
|
||||||
// Idempotent — a uuid with no remaining keys is a successful no-op.
|
func (c *Client) cidsForRootLocked(ctx context.Context, uuid string) ([]string, error) {
|
||||||
|
prefix := c.buildKeyPrefix(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
|
||||||
|
// (including the cids/<cid> key-set), every reverse-index entry for those
|
||||||
|
// CIDs, and the index membership. Idempotent — a uuid with no remaining keys
|
||||||
|
// is a successful no-op.
|
||||||
func (c *Client) DeleteRoot(uuid string) error {
|
func (c *Client) DeleteRoot(uuid string) error {
|
||||||
c.mu.RLock()
|
c.mu.RLock()
|
||||||
defer c.mu.RUnlock()
|
defer c.mu.RUnlock()
|
||||||
@@ -269,27 +275,25 @@ func (c *Client) DeleteRoot(uuid string) error {
|
|||||||
|
|
||||||
prefix := c.buildKeyPrefix(uuid)
|
prefix := c.buildKeyPrefix(uuid)
|
||||||
|
|
||||||
// 1. Read the cids set first — once we delete file:<uuid>/* we lose the
|
// 1. Read the cids key-set first — once we delete file:<uuid>/* we lose
|
||||||
// list of reverse-index entries to clean up. Tolerate a missing set
|
// the list of reverse-index entries to clean up.
|
||||||
// (empty list = nothing to unmap).
|
cids, err := c.cidsForRootLocked(ctx, uuid)
|
||||||
tokens, err := c.client.SMembers(ctx, prefix+CIDsField).Result()
|
if err != nil {
|
||||||
if err != nil && err != redis.Nil {
|
return err
|
||||||
return fmt.Errorf("smembers cids: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Delete every reverse-index entry. Build the keys upfront, then DEL
|
// 2. Delete every reverse-index entry in one shot.
|
||||||
// in one shot so we don't pay per-RTT.
|
if len(cids) > 0 {
|
||||||
if len(tokens) > 0 {
|
keys := make([]string, 0, len(cids))
|
||||||
keys := make([]string, 0, len(tokens))
|
for _, cidStr := range cids {
|
||||||
for _, t := range tokens {
|
keys = append(keys, c.buildCIDIndexKey(cidStr))
|
||||||
keys = append(keys, c.buildCIDIndexKey(t))
|
|
||||||
}
|
}
|
||||||
if err := c.client.Del(ctx, keys...).Err(); err != nil {
|
if err := c.client.Del(ctx, keys...).Err(); err != nil {
|
||||||
return fmt.Errorf("del cid index: %w", err)
|
return fmt.Errorf("del cid index: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Scan and delete every file:<uuid>/* key.
|
// 3. Scan and delete every file:<uuid>/* key (cids/<cid> keys included).
|
||||||
var cursor uint64
|
var cursor uint64
|
||||||
for {
|
for {
|
||||||
keys, next, err := c.client.Scan(ctx, cursor, prefix+"*", 1000).Result()
|
keys, next, err := c.client.Scan(ctx, cursor, prefix+"*", 1000).Result()
|
||||||
@@ -314,115 +318,59 @@ func (c *Client) DeleteRoot(uuid string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// reconcileCanonicalCIDLocked reads the cids set, picks the highest-ranked
|
// maybeAddAliasFromFieldLocked inspects a (field, value) pair written into a
|
||||||
// token, and updates the canonical_cid field if it changed. Called from
|
// root. If it's a key-set member — field name starts with "cids/" — it
|
||||||
// addAliasLocked after a new alias is registered. Caller must hold c.mu.
|
// registers the implied CID in the reverse index. Writers emit cids/<cid>
|
||||||
func (c *Client) reconcileCanonicalCIDLocked(ctx context.Context, uuid string) error {
|
// members without knowing about the reverse index; this hook keeps it in
|
||||||
prefix := c.buildKeyPrefix(uuid)
|
// sync transparently.
|
||||||
|
|
||||||
tokens, err := c.client.SMembers(ctx, prefix+CIDsField).Result()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("smembers cids: %w", err)
|
|
||||||
}
|
|
||||||
if len(tokens) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
best := tokens[0]
|
|
||||||
for _, t := range tokens[1:] {
|
|
||||||
best = cid.Better(best, t)
|
|
||||||
}
|
|
||||||
|
|
||||||
canonicalKey := prefix + CanonicalCIDField
|
|
||||||
current, err := c.client.Get(ctx, canonicalKey).Result()
|
|
||||||
if err != nil && err != redis.Nil {
|
|
||||||
return fmt.Errorf("get canonical_cid: %w", err)
|
|
||||||
}
|
|
||||||
if current == best {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if err := c.client.Set(ctx, canonicalKey, best, 0).Err(); err != nil {
|
|
||||||
return fmt.Errorf("set canonical_cid: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// maybeAddAliasFromFieldLocked inspects a (field, value) pair written into
|
|
||||||
// a root. If it looks like a CID field — name is "midhash256" or starts
|
|
||||||
// with "cid_" — it registers the implied CID token as an alias. Plugins
|
|
||||||
// write CID fields without knowing about the reverse index; this hook
|
|
||||||
// keeps the index in sync transparently.
|
|
||||||
//
|
//
|
||||||
// Caller must hold c.mu. Errors are intentionally swallowed at the call
|
// Caller must hold c.mu. Errors are intentionally swallowed at the call site
|
||||||
// site (logged, not returned): an out-of-sync reverse index is recoverable
|
// (logged, not returned): an out-of-sync reverse index is recoverable via a
|
||||||
// via a sweep, while a failed property write is not.
|
// sweep, while a failed property write is not.
|
||||||
func (c *Client) maybeAddAliasFromFieldLocked(ctx context.Context, uuid, field, value string) error {
|
func (c *Client) maybeAddAliasFromFieldLocked(ctx context.Context, uuid, field, value string) error {
|
||||||
token := cidTokenFromField(field, value)
|
cidStr := cidFromKeysetField(field)
|
||||||
if token == "" {
|
if cidStr == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return c.addAliasLocked(ctx, uuid, token)
|
return c.addAliasLocked(ctx, uuid, cidStr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// maybeAddAliasesFromMetadataLocked bulk-applies maybeAddAliasFromFieldLocked
|
// maybeAddAliasesFromMetadataLocked bulk-applies maybeAddAliasFromFieldLocked
|
||||||
// over every entry in a metadata map. Used by SetMetadataFlat /
|
// over every entry in a metadata map. Used by SetMetadataFlat /
|
||||||
// MergeMetadataFlat after the underlying pipeline write succeeds. Caller
|
// MergeMetadataFlat after the underlying pipeline write succeeds. Caller must
|
||||||
// must hold c.mu.
|
// hold c.mu.
|
||||||
func (c *Client) maybeAddAliasesFromMetadataLocked(ctx context.Context, uuid string, metadata map[string]string) {
|
func (c *Client) maybeAddAliasesFromMetadataLocked(ctx context.Context, uuid string, metadata map[string]string) {
|
||||||
for k, v := range metadata {
|
for k := range metadata {
|
||||||
_ = c.maybeAddAliasFromFieldLocked(ctx, uuid, k, v)
|
_ = c.maybeAddAliasFromFieldLocked(ctx, uuid, k, metadata[k])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// cidTokenFromField turns a (field-name, raw-value) pair into a normalized
|
// cidFromKeysetField returns the bare CID encoded in a cids/<cid> field name,
|
||||||
// CID token "<algorithm>:<value>". Returns "" for non-CID fields.
|
// or "" if the field isn't a key-set member.
|
||||||
//
|
func cidFromKeysetField(field string) string {
|
||||||
// Acceptable inputs:
|
if !strings.HasPrefix(field, CIDsKeyPrefix) {
|
||||||
// - field="midhash256", value="bafk…" → "midhash256:bafk…"
|
|
||||||
// - field="cid_sha256", value="bafk…" → "sha256:bafk…"
|
|
||||||
// - field="cid_ipfs", value="ipfs:bafy…" → "ipfs:bafy…" (trusted)
|
|
||||||
//
|
|
||||||
// Plugins that already use the prefixed form get their value passed through;
|
|
||||||
// plugins that store the bare CID get prefixed from the field name. The
|
|
||||||
// detection heuristic is just "value contains a colon" — base32lower CIDs
|
|
||||||
// never contain colons, so this is unambiguous.
|
|
||||||
func cidTokenFromField(field, value string) string {
|
|
||||||
if value == "" {
|
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
var algo string
|
return strings.TrimPrefix(field, CIDsKeyPrefix)
|
||||||
switch {
|
|
||||||
case field == "midhash256":
|
|
||||||
algo = "midhash256"
|
|
||||||
case strings.HasPrefix(field, "cid_"):
|
|
||||||
algo = strings.TrimPrefix(field, "cid_")
|
|
||||||
default:
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
if algo == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
if strings.IndexByte(value, ':') > 0 {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
return cid.Token(algo, value)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MetadataDocument is the complete state of one file as the public API
|
// MetadataDocument is the complete state of one file as the public API
|
||||||
// returns it: every flat string field, plus the cids and duplicates sets
|
// returns it: every flat string field (the cids/<cid> members included),
|
||||||
// hoisted into typed arrays. Distinct from GetMetadataFlat (which only
|
// plus the CIDs hoisted into a typed array, the canonical CID derived by
|
||||||
// returns string keys) — clients querying /api/meta/<cid> want the SETs
|
// rank, and the duplicates set. Distinct from GetMetadataFlat (which only
|
||||||
// expanded too, since "what other CIDs identify this file" and "what other
|
// returns the raw string keys).
|
||||||
// paths point at this content" are core to the response.
|
|
||||||
type MetadataDocument struct {
|
type MetadataDocument struct {
|
||||||
Flat map[string]string `json:"metadata"`
|
Flat map[string]string `json:"metadata"`
|
||||||
CIDs []string `json:"cids"`
|
CIDs []string `json:"cids"`
|
||||||
|
Canonical string `json:"canonical_cid,omitempty"`
|
||||||
Duplicates []string `json:"duplicates"`
|
Duplicates []string `json:"duplicates"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetMetadataDocument returns the full document for a root: every string
|
// GetMetadataDocument returns the full document for a root: every string
|
||||||
// property under file:<uuid>/* plus the cids and duplicates SETs. Returns
|
// property under file:<uuid>/* (cids/<cid> members included in Flat), the
|
||||||
// nil if the root has no keys at all (deleted or never existed).
|
// CIDs hoisted into a typed array, the canonical CID computed by rank, and
|
||||||
|
// the duplicates SET. Returns nil if the root has no keys at all (deleted or
|
||||||
|
// never existed).
|
||||||
func (c *Client) GetMetadataDocument(uuid string) (*MetadataDocument, error) {
|
func (c *Client) GetMetadataDocument(uuid string) (*MetadataDocument, error) {
|
||||||
c.mu.RLock()
|
c.mu.RLock()
|
||||||
defer c.mu.RUnlock()
|
defer c.mu.RUnlock()
|
||||||
@@ -443,10 +391,24 @@ func (c *Client) GetMetadataDocument(uuid string) (*MetadataDocument, error) {
|
|||||||
}
|
}
|
||||||
prefix := c.buildKeyPrefix(uuid)
|
prefix := c.buildKeyPrefix(uuid)
|
||||||
|
|
||||||
cids, err := c.client.SMembers(ctx, prefix+CIDsField).Result()
|
// Hoist the cids/<cid> key-set members into a typed array and derive the
|
||||||
if err != nil && err != redis.Nil {
|
// canonical CID by rank. The members remain in Flat too — that's how
|
||||||
return nil, fmt.Errorf("smembers cids: %w", err)
|
// flat-map consumers (meta-share ingest) see them.
|
||||||
|
cids := make([]string, 0)
|
||||||
|
canonical := ""
|
||||||
|
for k := range flat {
|
||||||
|
if !strings.HasPrefix(k, CIDsKeyPrefix) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cidStr := strings.TrimPrefix(k, CIDsKeyPrefix)
|
||||||
|
cids = append(cids, cidStr)
|
||||||
|
if canonical == "" {
|
||||||
|
canonical = cidStr
|
||||||
|
} else {
|
||||||
|
canonical = cid.Better(canonical, cidStr)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dups, err := c.client.SMembers(ctx, prefix+DuplicatesField).Result()
|
dups, err := c.client.SMembers(ctx, prefix+DuplicatesField).Result()
|
||||||
if err != nil && err != redis.Nil {
|
if err != nil && err != redis.Nil {
|
||||||
return nil, fmt.Errorf("smembers duplicates: %w", err)
|
return nil, fmt.Errorf("smembers duplicates: %w", err)
|
||||||
@@ -458,19 +420,7 @@ func (c *Client) GetMetadataDocument(uuid string) (*MetadataDocument, error) {
|
|||||||
return &MetadataDocument{
|
return &MetadataDocument{
|
||||||
Flat: flat,
|
Flat: flat,
|
||||||
CIDs: cids,
|
CIDs: cids,
|
||||||
|
Canonical: canonical,
|
||||||
Duplicates: dups,
|
Duplicates: dups,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// lookupSidecarPathByCID — REMOVED.
|
|
||||||
//
|
|
||||||
// This function used to walk every metadata entry looking for poster/backdrop
|
|
||||||
// matches and return their `posterPath`/`backdropPath` companion fields. It
|
|
||||||
// was a workaround for plugin output (TMDB images, etc.) not being indexed
|
|
||||||
// by the watcher.
|
|
||||||
//
|
|
||||||
// Now that the plugin output directory is a first-class watcher root
|
|
||||||
// (see config.DefaultWatcherPaths), every plugin artefact has its own
|
|
||||||
// midhash256 alias and resolves through the standard reverse index. Storing
|
|
||||||
// paths in metadata broke the CID-as-identity contract; the watcher
|
|
||||||
// indexing fixes the root cause.
|
|
||||||
|
|||||||
+38
-47
@@ -291,16 +291,16 @@ func (c *Client) DeleteMetadata(hashID string) (int64, error) {
|
|||||||
|
|
||||||
prefix := c.buildKeyPrefix(hashID)
|
prefix := c.buildKeyPrefix(hashID)
|
||||||
|
|
||||||
// Read the cids set first — once file:<uuid>/* is gone we've lost the
|
// Read the cids key-set first — once file:<uuid>/* is gone we've lost the
|
||||||
// list of reverse-index keys that need cleanup.
|
// list of reverse-index keys that need cleanup.
|
||||||
tokens, err := c.client.SMembers(ctx, prefix+CIDsField).Result()
|
cids, err := c.cidsForRootLocked(ctx, hashID)
|
||||||
if err != nil && err != redis.Nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("smembers cids: %w", err)
|
return 0, err
|
||||||
}
|
}
|
||||||
if len(tokens) > 0 {
|
if len(cids) > 0 {
|
||||||
idxKeys := make([]string, 0, len(tokens))
|
idxKeys := make([]string, 0, len(cids))
|
||||||
for _, t := range tokens {
|
for _, cidStr := range cids {
|
||||||
idxKeys = append(idxKeys, c.buildCIDIndexKey(t))
|
idxKeys = append(idxKeys, c.buildCIDIndexKey(cidStr))
|
||||||
}
|
}
|
||||||
if err := c.client.Del(ctx, idxKeys...).Err(); err != nil {
|
if err := c.client.Del(ctx, idxKeys...).Err(); err != nil {
|
||||||
return 0, fmt.Errorf("del cid index: %w", err)
|
return 0, fmt.Errorf("del cid index: %w", err)
|
||||||
@@ -347,32 +347,24 @@ func (c *Client) CountFiles() (int, error) {
|
|||||||
return len(hashIDs), nil
|
return len(hashIDs), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// LookupPathByCID resolves a CID to its file path on disk via the reverse
|
// LookupPathByCID resolves a bare CID to its file path on disk via the
|
||||||
// index. O(1) lookup followed by a single GET of file:<uuid>/filePath.
|
// reverse index. O(1) lookup followed by a single GET of file:<uuid>/filePath.
|
||||||
//
|
//
|
||||||
// Accepts both prefixed tokens ("midhash256:bafk…", "sha256:…",
|
// Any registered CID resolves — the midhash, a sibling sha2-256, an IPFS
|
||||||
// "ipfs:bafy…") and bare midhash256 CIDs — the bare form is what the
|
// CID, a btih, etc. (they all alias to the same root). The bare CID is what
|
||||||
// editor passes to /api/file/{cid}/info because that's how plugins
|
// the editor passes to /api/file/{cid}/info and what plugins write (e.g.
|
||||||
// write the value (e.g. `backdrop=<bare-cid>`). For bare tokens we
|
// `backdrop=<bare-cid>`).
|
||||||
// try the midhash256 prefix as a fallback.
|
|
||||||
//
|
//
|
||||||
// Plugin output (TMDB posters/backdrops, extracted subtitles, etc.) is
|
// Plugin output (TMDB posters/backdrops, extracted subtitles, etc.) is
|
||||||
// resolved through this same path: the plugin output directory is a
|
// resolved through this same path: the plugin output directory is a
|
||||||
// watcher root (see config.DefaultWatcherPaths), so every plugin
|
// watcher root (see config.DefaultWatcherPaths), so every plugin artefact
|
||||||
// artefact gets a midhash256 alias and resolves through the reverse
|
// gets a midhash alias and resolves through the reverse index like any
|
||||||
// index like any other file. There is no path-companion sidecar field.
|
// other file. There is no path-companion sidecar field.
|
||||||
func (c *Client) LookupPathByCID(token string) (string, error) {
|
func (c *Client) LookupPathByCID(cidStr string) (string, error) {
|
||||||
uuid, err := c.GetByCID(token)
|
uuid, err := c.GetByCID(cidStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
if uuid == "" && !strings.Contains(token, ":") {
|
|
||||||
// Bare CID — most likely a midhash256 written by a plugin.
|
|
||||||
uuid, err = c.GetByCID("midhash256:" + token)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if uuid == "" {
|
if uuid == "" {
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
@@ -599,23 +591,24 @@ func (c *Client) RemoveFromSet(hashID, property, value string) (bool, error) {
|
|||||||
// file. Used by the watcher hydrator at startup to avoid re-mid-hashing
|
// file. Used by the watcher hydrator at startup to avoid re-mid-hashing
|
||||||
// files whose tuple matches what's already in Redis.
|
// files whose tuple matches what's already in Redis.
|
||||||
//
|
//
|
||||||
// HashID is the root identifier (UUIDv7 in the current model); MidHash256
|
// HashID is the root identifier (UUIDv7 in the current model). Hydration
|
||||||
// is the actual midhash CID for the file, present iff the watcher already
|
// uses HashID + (FilePath, Size, MtimeNano) to seed the in-memory state so
|
||||||
// computed and stored it. Hydration uses HashID for the state-registry
|
// the next scan can skip re-mid-hashing files whose size+mtime are unchanged.
|
||||||
// key and MidHash256 for the in-memory "I already know this hash" flag.
|
|
||||||
type FileTuple struct {
|
type FileTuple struct {
|
||||||
HashID string
|
HashID string
|
||||||
FilePath string
|
FilePath string
|
||||||
Size int64
|
Size int64
|
||||||
MtimeNano int64
|
MtimeNano int64
|
||||||
MidHash256 string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetTuplesForAllFiles returns one FileTuple per indexed root, fetched in
|
// GetTuplesForAllFiles returns one FileTuple per indexed root, fetched in
|
||||||
// batched MGETs (no SCAN per root). Skips files missing any of filePath,
|
// batched MGETs (no SCAN per root). Skips files missing any of filePath,
|
||||||
// sizeByte, or mtimeNano. midhash256 is optional — files that haven't
|
// sizeByte, or mtimeNano. Sub-second on local Redis for ~10K files.
|
||||||
// been hashed yet still appear in the result with an empty MidHash256.
|
//
|
||||||
// Sub-second on local Redis for ~10K files.
|
// The midhash is intentionally NOT fetched here: it now lives only as a
|
||||||
|
// cids/<cid> key-set member (no named field), and the skip-rehash decision
|
||||||
|
// is purely size+mtime — so reading it would cost a SCAN per file for no
|
||||||
|
// benefit.
|
||||||
func (c *Client) GetTuplesForAllFiles() ([]FileTuple, error) {
|
func (c *Client) GetTuplesForAllFiles() ([]FileTuple, error) {
|
||||||
c.mu.RLock()
|
c.mu.RLock()
|
||||||
defer c.mu.RUnlock()
|
defer c.mu.RUnlock()
|
||||||
@@ -638,8 +631,8 @@ func (c *Client) GetTuplesForAllFiles() ([]FileTuple, error) {
|
|||||||
// Build the key list: 4 keys per root, in a fixed order so we can decode
|
// Build the key list: 4 keys per root, in a fixed order so we can decode
|
||||||
// the MGET response by position. fields[i] for hashIDs[j] sits at
|
// the MGET response by position. fields[i] for hashIDs[j] sits at
|
||||||
// keys[j*4+i].
|
// keys[j*4+i].
|
||||||
const fieldsPerFile = 4
|
const fieldsPerFile = 3
|
||||||
fields := [fieldsPerFile]string{"filePath", "sizeByte", "mtimeNano", "midhash256"}
|
fields := [fieldsPerFile]string{"filePath", "sizeByte", "mtimeNano"}
|
||||||
keys := make([]string, 0, len(hashIDs)*fieldsPerFile)
|
keys := make([]string, 0, len(hashIDs)*fieldsPerFile)
|
||||||
for _, h := range hashIDs {
|
for _, h := range hashIDs {
|
||||||
prefix := c.buildKeyPrefix(h)
|
prefix := c.buildKeyPrefix(h)
|
||||||
@@ -669,7 +662,6 @@ func (c *Client) GetTuplesForAllFiles() ([]FileTuple, error) {
|
|||||||
filePath, _ := values[base].(string)
|
filePath, _ := values[base].(string)
|
||||||
sizeStr, _ := values[base+1].(string)
|
sizeStr, _ := values[base+1].(string)
|
||||||
mtimeStr, _ := values[base+2].(string)
|
mtimeStr, _ := values[base+2].(string)
|
||||||
midhash, _ := values[base+3].(string)
|
|
||||||
if filePath == "" || sizeStr == "" || mtimeStr == "" {
|
if filePath == "" || sizeStr == "" || mtimeStr == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -682,11 +674,10 @@ func (c *Client) GetTuplesForAllFiles() ([]FileTuple, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
out = append(out, FileTuple{
|
out = append(out, FileTuple{
|
||||||
HashID: h,
|
HashID: h,
|
||||||
FilePath: filePath,
|
FilePath: filePath,
|
||||||
Size: size,
|
Size: size,
|
||||||
MtimeNano: mtime,
|
MtimeNano: mtime,
|
||||||
MidHash256: midhash,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
|
|||||||
@@ -3,160 +3,39 @@ package storage
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// MigrateDualRoots scans file:__index__ for hashIds that look like raw
|
// MigrateDualRoots was a one-shot repair for the "dual-root" pattern in the
|
||||||
// midhash256 CID values (i.e. NOT UUIDs/ULIDs) and have a matching alias
|
// legacy storage model: /meta/{hash} write handlers that didn't resolve
|
||||||
// `cid:midhash256:<hash>` pointing to a different UUID. For each such
|
// CID → UUID first created a parallel midhash-rooted entry alongside the
|
||||||
// "stranded" root we:
|
// watcher's UUID root.
|
||||||
//
|
//
|
||||||
// 1. Copy every file:<midhash>/<property> into file:<uuid>/<property>
|
// That pattern is now structurally impossible:
|
||||||
// using MergeMetadataFlat (preserves anything the UUID root already
|
// - writes resolve via ResolveRoot before landing (see handlers), and
|
||||||
// holds, e.g. duplicates and watcher-owned fields).
|
// - CIDs are stored as a bare-CID key-set (cids/<cid>) keyed off opaque
|
||||||
// 2. Delete the entire file:<midhash>/* subtree.
|
// UUID roots, with the reverse index cid:<cid> → uuid as the single
|
||||||
// 3. Drop the midhash hashId from file:__index__ (kept on the UUID).
|
// resolution path.
|
||||||
//
|
//
|
||||||
// This is the one-shot fix for the dual-root pattern described in
|
// The legacy on-disk shape this migration targeted (midhash-rooted entries,
|
||||||
// docs/api-mediated-access.md. The pattern was caused by the (now-fixed)
|
// <algo>:<value> tokens, named midhash256 fields) no longer exists, and the
|
||||||
// /meta/{hash} write handlers not resolving CID → UUID before writing.
|
// cut-over wipes Redis, so there is nothing to migrate. The function is kept
|
||||||
//
|
// as a no-op so the admin endpoint and its wrappers continue to compile and
|
||||||
// Returns the number of stranded roots fixed.
|
// respond.
|
||||||
func (c *Client) MigrateDualRoots(ctx context.Context) (int, error) {
|
func (c *Client) MigrateDualRoots(ctx context.Context) (int, error) {
|
||||||
c.mu.RLock()
|
|
||||||
if c.client == nil {
|
if c.client == nil {
|
||||||
c.mu.RUnlock()
|
|
||||||
return 0, fmt.Errorf("not connected")
|
return 0, fmt.Errorf("not connected")
|
||||||
}
|
}
|
||||||
hashIDs, err := c.getAllHashIDsInternal(ctx)
|
return 0, nil
|
||||||
c.mu.RUnlock()
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("get hash ids: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build the orphaned-UUID index: for every UUID-rooted entry, record
|
|
||||||
// midhash256 → UUID. This lets us find the "real" target when an alias
|
|
||||||
// has been overwritten to self-point (the failure mode the in-flight
|
|
||||||
// fix prevents going forward, but a one-shot still needs to repair
|
|
||||||
// existing dual-rooted data).
|
|
||||||
midhashToUUID := make(map[string]string)
|
|
||||||
for _, hashID := range hashIDs {
|
|
||||||
if !looksLikeULID(hashID) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
mh, err := c.GetProperty(hashID, "midhash256")
|
|
||||||
if err != nil || mh == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
midhashToUUID[mh] = hashID
|
|
||||||
}
|
|
||||||
|
|
||||||
fixed := 0
|
|
||||||
for _, hashID := range hashIDs {
|
|
||||||
// Skip likely-UUID hashIds. ULIDs are 26 chars, uppercase Crockford
|
|
||||||
// Base32. We treat anything that's not all-uppercase-alphanumeric
|
|
||||||
// of length ~26 as a CID-shaped hashId worth checking.
|
|
||||||
if looksLikeULID(hashID) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Is there an alias pointing to a different uuid?
|
|
||||||
token := "midhash256:" + hashID
|
|
||||||
uuid, err := c.GetByCID(token)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("[MigrateDualRoots] GetByCID(%s): %v (skipping)", token, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// Three cases:
|
|
||||||
// - uuid empty → no alias, this midhash root is standalone (skip)
|
|
||||||
// - uuid == hashID → self-pointing alias; check the orphan index
|
|
||||||
// for a UUID that claims this midhash via its midhash256 field
|
|
||||||
// - uuid != hashID and != "" → proper alias, migrate to that UUID
|
|
||||||
if uuid == "" || uuid == hashID {
|
|
||||||
if orphan, ok := midhashToUUID[hashID]; ok && orphan != "" {
|
|
||||||
uuid = orphan
|
|
||||||
} else {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pull every key under file:<hashID>/* and merge into file:<uuid>/.
|
|
||||||
flat, err := c.GetMetadataFlat(hashID)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("[MigrateDualRoots] GetMetadataFlat(%s): %v (skipping)", hashID, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if len(flat) > 0 {
|
|
||||||
if _, err := c.MergeMetadataFlat(uuid, flat); err != nil {
|
|
||||||
log.Printf("[MigrateDualRoots] MergeMetadataFlat(%s ← %s): %v", uuid, hashID, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete the stranded root entirely.
|
|
||||||
if _, err := c.DeleteMetadata(hashID); err != nil {
|
|
||||||
log.Printf("[MigrateDualRoots] DeleteMetadata(%s): %v", hashID, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-point the alias to the surviving UUID. DeleteMetadata wipes
|
|
||||||
// the cid:* entries for the deleted root, so the alias for this
|
|
||||||
// midhash may have been removed. Restore it pointing at the
|
|
||||||
// canonical UUID so future ResolveRoot calls land correctly.
|
|
||||||
if err := c.AddAlias(uuid, "midhash256:"+hashID); err != nil {
|
|
||||||
log.Printf("[MigrateDualRoots] AddAlias(%s → midhash256:%s): %v", uuid, hashID, err)
|
|
||||||
// Not fatal — data is migrated; the alias mismatch can be
|
|
||||||
// re-fixed by another sweep.
|
|
||||||
}
|
|
||||||
|
|
||||||
fixed++
|
|
||||||
}
|
|
||||||
|
|
||||||
if fixed > 0 {
|
|
||||||
log.Printf("[MigrateDualRoots] Reunited %d stranded midhash roots into their UUID counterparts", fixed)
|
|
||||||
}
|
|
||||||
return fixed, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// looksLikeULID is a cheap heuristic for "this hashId is opaque internal
|
|
||||||
// UUID, not a CID we should resolve." ULIDs are 26-char Crockford Base32;
|
|
||||||
// CIDs in this codebase are longer and lowercase. Tolerates dashes for
|
|
||||||
// future-proofing.
|
|
||||||
func looksLikeULID(s string) bool {
|
|
||||||
if len(s) != 26 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for _, r := range s {
|
|
||||||
if r >= 'A' && r <= 'Z' {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if r >= '0' && r <= '9' {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MigrateDualRootsWithTimeout is a convenience wrapper used by the API
|
// MigrateDualRootsWithTimeout is a convenience wrapper used by the API
|
||||||
// handler and the startup hook. Default to a generous 5-minute deadline —
|
// handler and the startup hook.
|
||||||
// the per-entry work is small but the index can be large.
|
|
||||||
func (c *Client) MigrateDualRootsWithTimeout() (int, error) {
|
func (c *Client) MigrateDualRootsWithTimeout() (int, error) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
return c.MigrateDualRoots(context.Background())
|
||||||
defer cancel()
|
|
||||||
return c.MigrateDualRoots(ctx)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MigrateDualRootsReport returns a human-readable summary of what
|
// MigrateDualRootsReport returns a human-readable summary. With the dual-root
|
||||||
// MigrateDualRoots would do or did. Useful for the admin endpoint.
|
// pattern retired (see MigrateDualRoots), this always reports nothing to do.
|
||||||
func (c *Client) MigrateDualRootsReport(ctx context.Context) (string, int, error) {
|
func (c *Client) MigrateDualRootsReport(ctx context.Context) (string, int, error) {
|
||||||
fixed, err := c.MigrateDualRoots(ctx)
|
return "dual-root migration is obsolete under the bare-CID key-set model; nothing to do", 0, nil
|
||||||
if err != nil {
|
|
||||||
return "", fixed, err
|
|
||||||
}
|
|
||||||
parts := []string{}
|
|
||||||
parts = append(parts, fmt.Sprintf("merged %d stranded midhash roots into UUID roots", fixed))
|
|
||||||
return strings.Join(parts, "; "), fixed, nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,26 +52,23 @@ func TestNewUUID_TimeSortable(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCIDTokenFromField(t *testing.T) {
|
func TestCIDFromKeysetField(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
field, value string
|
field string
|
||||||
want string
|
want string
|
||||||
}{
|
}{
|
||||||
{"midhash256", "bafk", "midhash256:bafk"},
|
{"cids/bafkmidhash", "bafkmidhash"},
|
||||||
{"cid_sha256", "bafk", "sha256:bafk"},
|
{"cids/baejbeisha256", "baejbeisha256"},
|
||||||
{"cid_ipfs", "bafy", "ipfs:bafy"},
|
{"cids/bafyipfs", "bafyipfs"},
|
||||||
{"cid_sha256", "sha256:bafk", "sha256:bafk"}, // already prefixed
|
{"title", ""}, // non-CID field
|
||||||
{"cid_ipfs", "ipfs:bafy", "ipfs:bafy"}, // already prefixed
|
{"tmdb/title", ""}, // non-CID nested
|
||||||
{"title", "Inception", ""}, // non-CID field
|
{"cid_sha2-256", ""}, // legacy flat field — no longer recognized
|
||||||
{"tmdb/title", "Inception", ""}, // non-CID nested
|
{"midhash256", ""}, // legacy named field — no longer recognized
|
||||||
{"cid_", "bafk", ""}, // empty algo
|
{"cids", ""}, // prefix without a member
|
||||||
{"midhash256", "", ""}, // empty value
|
|
||||||
{"cid_sha256", "", ""}, // empty value
|
|
||||||
}
|
}
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
if got := cidTokenFromField(tc.field, tc.value); got != tc.want {
|
if got := cidFromKeysetField(tc.field); got != tc.want {
|
||||||
t.Errorf("cidTokenFromField(%q, %q) = %q, want %q",
|
t.Errorf("cidFromKeysetField(%q) = %q, want %q", tc.field, got, tc.want)
|
||||||
tc.field, tc.value, got, tc.want)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-12
@@ -38,10 +38,11 @@ func (w *Watcher) SetStorageClient(s *storage.Client) {
|
|||||||
// Caller already has all four values from its existing os.Stat / FileInfo —
|
// Caller already has all four values from its existing os.Stat / FileInfo —
|
||||||
// no extra IO is performed here beyond the Redis writes themselves.
|
// no extra IO is performed here beyond the Redis writes themselves.
|
||||||
//
|
//
|
||||||
// Flow (see docs/uuid-rooted-metadata.md):
|
// Flow:
|
||||||
// - Look up the midhash in the reverse index (cid:midhash256:… → uuid)
|
// - Look up the midhash CID in the reverse index (cid:<midhash> → uuid)
|
||||||
// - If unknown, Mint a fresh UUID root and write the midhash256 field
|
// - If unknown, Mint a fresh UUID root and write the midhash as a cids/
|
||||||
// (the SetProperty hook auto-registers the alias)
|
// key-set member (the SetProperty hook auto-registers the reverse-index
|
||||||
|
// alias). The midhash stays the file's address; it has no named field.
|
||||||
// - If known, this is a duplicate physical file for the same content —
|
// - If known, this is a duplicate physical file for the same content —
|
||||||
// record the new path in the duplicates set and refresh size/mtime
|
// record the new path in the duplicates set and refresh size/mtime
|
||||||
//
|
//
|
||||||
@@ -52,25 +53,24 @@ func (w *Watcher) writeFileTuple(absPath string, size, mtimeNano int64, midhash
|
|||||||
if w.storage == nil || midhash == "" {
|
if w.storage == nil || midhash == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
token := "midhash256:" + midhash
|
|
||||||
|
|
||||||
uuid, err := w.storage.GetByCID(token)
|
uuid, err := w.storage.GetByCID(midhash)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[Watcher] GetByCID(%s): %v", token, err)
|
log.Printf("[Watcher] GetByCID(%s): %v", midhash, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if uuid == "" {
|
if uuid == "" {
|
||||||
// New content. Mint sets filePath/sizeByte/mtimeNano; SetProperty
|
// New content. Mint sets filePath/sizeByte/mtimeNano; SetProperty
|
||||||
// then writes midhash256 — and the cid_resolution.go hook on
|
// then writes the midhash as a cids/<cid> key-set member — and the
|
||||||
// SetProperty registers the reverse-index alias for us.
|
// cid_resolution.go hook registers the reverse-index alias for us.
|
||||||
uuid, err = w.storage.Mint(absPath, size, mtimeNano)
|
uuid, err = w.storage.Mint(absPath, size, mtimeNano)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[Watcher] Mint(%s): %v", absPath, err)
|
log.Printf("[Watcher] Mint(%s): %v", absPath, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := w.storage.SetProperty(uuid, "midhash256", midhash); err != nil {
|
if err := w.storage.SetProperty(uuid, storage.CIDsKeyPrefix+midhash, "true"); err != nil {
|
||||||
log.Printf("[Watcher] SetProperty midhash256 for %s: %v", uuid, err)
|
log.Printf("[Watcher] SetProperty cids/%s for %s: %v", midhash, uuid, err)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -359,7 +359,9 @@ func (w *Watcher) HydrateStateFromStorage(roots []WatcherRoot) (loaded, skipped
|
|||||||
ws.Set(rel, &FileState{
|
ws.Set(rel, &FileState{
|
||||||
Size: t.Size,
|
Size: t.Size,
|
||||||
MtimeNano: t.MtimeNano,
|
MtimeNano: t.MtimeNano,
|
||||||
MidHash: t.MidHash256,
|
// MidHash left empty: the skip-rehash decision is
|
||||||
|
// size+mtime only, and the midhash is no longer a named
|
||||||
|
// field (it lives as a cids/<cid> member).
|
||||||
})
|
})
|
||||||
loaded++
|
loaded++
|
||||||
matched = true
|
matched = true
|
||||||
|
|||||||
Reference in New Issue
Block a user