@3sln/trove 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1227 -0
- package/package.json +75 -0
- package/packages/core/src/collections/index.js +249 -0
- package/packages/core/src/errors.js +186 -0
- package/packages/core/src/identity/discovery.js +210 -0
- package/packages/core/src/identity/index.js +188 -0
- package/packages/core/src/identity/jwt.js +199 -0
- package/packages/core/src/index.js +104 -0
- package/packages/core/src/indexers/contribution.js +115 -0
- package/packages/core/src/indexers/registry.js +162 -0
- package/packages/core/src/indexing.js +340 -0
- package/packages/core/src/issues.js +150 -0
- package/packages/core/src/kv.js +0 -0
- package/packages/core/src/links.js +141 -0
- package/packages/core/src/metadata/cursor.js +73 -0
- package/packages/core/src/metadata/interface.js +244 -0
- package/packages/core/src/metadata/memory.js +270 -0
- package/packages/core/src/metadata/sqlite.js +412 -0
- package/packages/core/src/notifications/index.js +139 -0
- package/packages/core/src/notifications/webpush.js +217 -0
- package/packages/core/src/plugins/contributions.js +177 -0
- package/packages/core/src/plugins/identity.js +98 -0
- package/packages/core/src/plugins/index.js +225 -0
- package/packages/core/src/plugins/indexers.js +142 -0
- package/packages/core/src/plugins/installStore.js +134 -0
- package/packages/core/src/plugins/package.js +102 -0
- package/packages/core/src/plugins/packageStore.js +61 -0
- package/packages/core/src/plugins/runtime.js +101 -0
- package/packages/core/src/plugins/sql.js +52 -0
- package/packages/core/src/retry.js +74 -0
- package/packages/core/src/scan.js +302 -0
- package/packages/core/src/search/embeddings.js +128 -0
- package/packages/core/src/search/index.js +200 -0
- package/packages/core/src/search/keywordStore.js +107 -0
- package/packages/core/src/search/sqliteStores.js +455 -0
- package/packages/core/src/search/tagMatch.js +59 -0
- package/packages/core/src/search/transformer.js +195 -0
- package/packages/core/src/search/vectorStore.js +274 -0
- package/packages/core/src/search/vectorize.js +249 -0
- package/packages/core/src/sidecar/document.js +213 -0
- package/packages/core/src/sidecar/index.js +174 -0
- package/packages/core/src/sidecar/manager.js +239 -0
- package/packages/core/src/sidecar/store.js +46 -0
- package/packages/core/src/signedUrls.js +170 -0
- package/packages/core/src/sqlite-d1.js +162 -0
- package/packages/core/src/sqlite-driver.js +42 -0
- package/packages/core/src/sqlite.js +162 -0
- package/packages/core/src/storage/filesystem.js +283 -0
- package/packages/core/src/storage/interface.js +222 -0
- package/packages/core/src/storage/memory.js +113 -0
- package/packages/core/src/storage/prefixed.js +75 -0
- package/packages/core/src/storage/s3.js +316 -0
- package/packages/core/src/storage/s3sigv4.js +185 -0
- package/packages/core/src/tasks.js +228 -0
- package/packages/core/src/uploads.js +386 -0
- package/packages/core/src/util.js +125 -0
- package/packages/core/src/vfs.js +666 -0
- package/packages/plugin-sdk/src/browser.js +316 -0
- package/packages/plugin-sdk/src/index.js +32 -0
- package/packages/plugin-sdk/src/protocol.js +59 -0
- package/packages/plugin-sdk/src/rpc.js +95 -0
- package/packages/server/src/adapters/bun.js +78 -0
- package/packages/server/src/adapters/node.js +115 -0
- package/packages/server/src/adapters/staticAssets.js +123 -0
- package/packages/server/src/adapters/webDist.js +70 -0
- package/packages/server/src/adapters/worker-tasks.js +206 -0
- package/packages/server/src/adapters/worker.js +159 -0
- package/packages/server/src/cachePolicy.js +34 -0
- package/packages/server/src/engine/README.md +88 -0
- package/packages/server/src/engine/actions/scanCollection.js +114 -0
- package/packages/server/src/engine/index.js +95 -0
- package/packages/server/src/engine/lazy.js +25 -0
- package/packages/server/src/engine/providers/access.js +363 -0
- package/packages/server/src/engine/providers/core.js +405 -0
- package/packages/server/src/engine/providers/scan.js +67 -0
- package/packages/server/src/index.js +698 -0
- package/packages/server/src/manifest.js +98 -0
- package/packages/server/src/mcp/auth.js +40 -0
- package/packages/server/src/mcp/index.js +213 -0
- package/packages/server/src/mcp/protocol.js +181 -0
- package/packages/server/src/mcp/tools.js +351 -0
- package/packages/server/src/router.js +229 -0
- package/packages/server/src/routes.js +1066 -0
- package/packages/server/src/scope.js +43 -0
- package/packages/web/dist/assets/chunk-4xqbzebh.js +5 -0
- package/packages/web/dist/assets/chunk-4xqbzebh.js.map +9 -0
- package/packages/web/dist/assets/chunk-h05bxfbs.js +5 -0
- package/packages/web/dist/assets/chunk-h05bxfbs.js.map +10 -0
- package/packages/web/dist/assets/main-4cxs7prw.js +356 -0
- package/packages/web/dist/assets/main-4cxs7prw.js.map +103 -0
- package/packages/web/dist/assets/styles-kcx1x337.css +1 -0
- package/packages/web/dist/icon.svg +11 -0
- package/packages/web/dist/index.html +16 -0
- package/packages/web/dist/sql-wasm.wasm +0 -0
- package/packages/web/dist/sw.js +186 -0
- package/packages/web/src/bl/actions.js +410 -0
- package/packages/web/src/bl/activity.js +306 -0
- package/packages/web/src/bl/commands.js +274 -0
- package/packages/web/src/bl/fileType.js +49 -0
- package/packages/web/src/bl/index.js +70 -0
- package/packages/web/src/bl/links.js +54 -0
- package/packages/web/src/bl/offline.js +268 -0
- package/packages/web/src/bl/openers.js +71 -0
- package/packages/web/src/bl/pluginInstall.js +59 -0
- package/packages/web/src/bl/services.js +143 -0
- package/packages/web/src/bl/social.js +234 -0
- package/packages/web/src/bl/tagQuery.js +44 -0
- package/packages/web/src/main.js +10 -0
- package/packages/web/src/platform/api.js +529 -0
- package/packages/web/src/platform/commands.js +89 -0
- package/packages/web/src/platform/context.js +77 -0
- package/packages/web/src/platform/contributions.js +156 -0
- package/packages/web/src/platform/index.js +150 -0
- package/packages/web/src/platform/keybindings.js +199 -0
- package/packages/web/src/platform/mediaUrls.js +137 -0
- package/packages/web/src/platform/navigation.js +131 -0
- package/packages/web/src/platform/notifications.js +50 -0
- package/packages/web/src/platform/overlay.js +81 -0
- package/packages/web/src/platform/pluginClientDb.js +132 -0
- package/packages/web/src/platform/pluginDock.js +141 -0
- package/packages/web/src/platform/pluginFrames.js +194 -0
- package/packages/web/src/platform/pluginHost.js +648 -0
- package/packages/web/src/platform/pluginMedia.js +62 -0
- package/packages/web/src/platform/pluginModules.js +90 -0
- package/packages/web/src/platform/pluginNet.js +71 -0
- package/packages/web/src/platform/pluginPackage.js +247 -0
- package/packages/web/src/platform/pluginRpc.js +377 -0
- package/packages/web/src/platform/pluginSigning.js +168 -0
- package/packages/web/src/platform/pluginStore.js +67 -0
- package/packages/web/src/platform/settings.js +101 -0
- package/packages/web/src/platform/spatialNav.js +286 -0
- package/packages/web/src/platform/viewport.js +123 -0
- package/packages/web/src/platform/voice.js +133 -0
- package/packages/web/src/platform/voiceSearch.js +155 -0
- package/packages/web/src/platform/whenclause.js +162 -0
- package/packages/web/src/platform/workbench.js +156 -0
- package/packages/web/src/runtime.js +73 -0
- package/packages/web/src/styles.css +1382 -0
- package/packages/web/src/ui/components/activityBar.js +35 -0
- package/packages/web/src/ui/components/activityPanel.js +132 -0
- package/packages/web/src/ui/components/commandPalette.js +154 -0
- package/packages/web/src/ui/components/editorArea.js +75 -0
- package/packages/web/src/ui/components/launcher.js +392 -0
- package/packages/web/src/ui/components/openers/index.js +212 -0
- package/packages/web/src/ui/components/openers/markdown.js +222 -0
- package/packages/web/src/ui/components/overlays.js +255 -0
- package/packages/web/src/ui/components/phoneChrome.js +188 -0
- package/packages/web/src/ui/components/pluginReview.js +151 -0
- package/packages/web/src/ui/components/pluginsView.js +120 -0
- package/packages/web/src/ui/components/settingsView.js +258 -0
- package/packages/web/src/ui/components/social.js +290 -0
- package/packages/web/src/ui/components/statusBar.js +198 -0
- package/packages/web/src/ui/components/views/grid.js +115 -0
- package/packages/web/src/ui/components/views/index.js +155 -0
- package/packages/web/src/ui/components/views/list.js +50 -0
- package/packages/web/src/ui/components/views/parts.js +58 -0
- package/packages/web/src/ui/compositions/workbench.js +125 -0
- package/packages/web/src/ui/format.js +33 -0
- package/packages/web/src/ui/icon.js +81 -0
- package/packages/web/src/ui/media.js +114 -0
- package/packages/web/src/ui/sanitize.js +86 -0
- package/packages/web/src/workbench.js +205 -0
|
@@ -0,0 +1,1066 @@
|
|
|
1
|
+
// The Trove HTTP API, mapped onto Vfs methods. JSON in/out except downloads
|
|
2
|
+
// (bytes, range-aware) and direct part uploads (raw body). Downloads redirect to
|
|
3
|
+
// a presigned URL when the backend supports it, otherwise stream through here.
|
|
4
|
+
|
|
5
|
+
import { Router, json, parseRange } from './router.js';
|
|
6
|
+
import {
|
|
7
|
+
TroveError, assertSafePluginSql, concatBytes, metadataUrl, publicOrigin,
|
|
8
|
+
} from '@3sln/trove/core';
|
|
9
|
+
import { parseContribUri, CORE_DOMAIN } from '@3sln/trove/core/plugins/identity.js';
|
|
10
|
+
|
|
11
|
+
const ENV = typeof process !== 'undefined' ? (process.env || {}) : {};
|
|
12
|
+
// Cap JSON request bodies so a giant payload can't exhaust server memory. Uploads
|
|
13
|
+
// don't use body() (their bytes stream straight to storage), so this is safe to keep small.
|
|
14
|
+
const MAX_JSON_BYTES = Number(ENV.TROVE_MAX_JSON_BYTES || 4 * 1024 * 1024);
|
|
15
|
+
// Clamp any client-supplied result limit to a sane ceiling (DoS via huge scans).
|
|
16
|
+
const MAX_PAGE = Number(ENV.TROVE_MAX_PAGE || 1000);
|
|
17
|
+
// How many signed URLs one request may mint. A gallery asks for what it is about to
|
|
18
|
+
// draw, not for the whole drive — and each one costs an authorization check.
|
|
19
|
+
const URL_MINT_BATCH = Number(ENV.TROVE_URL_BATCH || 200);
|
|
20
|
+
|
|
21
|
+
async function body(req) {
|
|
22
|
+
const text = await readCapped(req, MAX_JSON_BYTES);
|
|
23
|
+
if (!text) return {};
|
|
24
|
+
try {
|
|
25
|
+
return JSON.parse(text);
|
|
26
|
+
} catch {
|
|
27
|
+
throw TroveError.invalid('Body must be valid JSON');
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Read the body as text, aborting if it exceeds `max` bytes (checks Content-Length
|
|
32
|
+
// first, then enforces while streaming in case the header lies or is absent).
|
|
33
|
+
async function readCapped(req, max) {
|
|
34
|
+
const declared = Number(req.headers.get('content-length') || 0);
|
|
35
|
+
if (declared && declared > max) throw TroveError.invalid('Request body too large');
|
|
36
|
+
const reader = req.body?.getReader?.();
|
|
37
|
+
if (!reader) {
|
|
38
|
+
const text = await req.text();
|
|
39
|
+
if (text.length > max) throw TroveError.invalid('Request body too large');
|
|
40
|
+
return text;
|
|
41
|
+
}
|
|
42
|
+
const chunks = [];
|
|
43
|
+
let total = 0;
|
|
44
|
+
for (;;) {
|
|
45
|
+
const { done, value } = await reader.read();
|
|
46
|
+
if (done) break;
|
|
47
|
+
total += value.byteLength;
|
|
48
|
+
if (total > max) { await reader.cancel().catch(() => {}); throw TroveError.invalid('Request body too large'); }
|
|
49
|
+
chunks.push(value);
|
|
50
|
+
}
|
|
51
|
+
return new TextDecoder().decode(concatBytes(chunks));
|
|
52
|
+
}
|
|
53
|
+
// Read a raw binary body (e.g. an uploaded plugin package), capped like readCapped —
|
|
54
|
+
// which means enforcing WHILE streaming, not after. Checking `.byteLength` on the result
|
|
55
|
+
// of `arrayBuffer()` is a check that happens once the whole body is already resident, so
|
|
56
|
+
// a chunked upload with no Content-Length could park 400 MB in the heap and only then be
|
|
57
|
+
// told it was too large.
|
|
58
|
+
async function readBytesCapped(req, max) {
|
|
59
|
+
const declared = Number(req.headers.get('content-length') || 0);
|
|
60
|
+
if (declared && declared > max) throw TroveError.invalid('Request body too large');
|
|
61
|
+
const reader = req.body?.getReader?.();
|
|
62
|
+
if (!reader) {
|
|
63
|
+
const buf = new Uint8Array(await req.arrayBuffer());
|
|
64
|
+
if (buf.byteLength > max) throw TroveError.invalid('Request body too large');
|
|
65
|
+
return buf;
|
|
66
|
+
}
|
|
67
|
+
const chunks = [];
|
|
68
|
+
let total = 0;
|
|
69
|
+
for (;;) {
|
|
70
|
+
const { done, value } = await reader.read();
|
|
71
|
+
if (done) break;
|
|
72
|
+
total += value.byteLength;
|
|
73
|
+
if (total > max) { await reader.cancel().catch(() => {}); throw TroveError.invalid('Request body too large'); }
|
|
74
|
+
chunks.push(value);
|
|
75
|
+
}
|
|
76
|
+
return concatBytes(chunks);
|
|
77
|
+
}
|
|
78
|
+
function clampLimit(value, dflt) {
|
|
79
|
+
const n = Number(value);
|
|
80
|
+
if (!Number.isFinite(n) || n <= 0) return dflt;
|
|
81
|
+
return Math.min(Math.floor(n), MAX_PAGE);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Content types safe to render inline in the app's own origin. Anything else
|
|
85
|
+
// (HTML, SVG, XML, scripts…) is forced to download so it can't execute as
|
|
86
|
+
// same-origin script when opened directly.
|
|
87
|
+
function inlineSafe(ct) {
|
|
88
|
+
const t = String(ct || '').toLowerCase().split(';')[0].trim();
|
|
89
|
+
if (t === 'image/svg+xml') return false;
|
|
90
|
+
return /^image\//.test(t) || /^audio\//.test(t) || /^video\//.test(t) || t === 'application/pdf' || t === 'text/plain';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Build a Content-Disposition header (RFC 6266).
|
|
94
|
+
//
|
|
95
|
+
// `filename` is a quoted-string, so a browser takes the bytes literally — percent-
|
|
96
|
+
// encoding it, which is what this used to do, is not a decoding any client performs.
|
|
97
|
+
// "Q3 report, final.pdf" arrived as "Q3%20report%2C%20final.pdf" and that is the name
|
|
98
|
+
// that landed on disk, for essentially every real filename. So: an ASCII fallback in
|
|
99
|
+
// `filename` (with the two characters that would break the quoting removed) and the
|
|
100
|
+
// real name in `filename*`, which IS percent-encoded by specification.
|
|
101
|
+
function contentDisposition(type, name) {
|
|
102
|
+
const clean = String(name || 'download').replace(/[\\"]/g, '').replace(/[\x00-\x1f\x7f]/g, '');
|
|
103
|
+
const ascii = clean.replace(/[^\x20-\x7e]/g, '_') || 'download';
|
|
104
|
+
return `${type}; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(clean)}`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Reject SSRF-prone hosts for the server-side assetlinks fetch: IP literals,
|
|
108
|
+
// loopback, link-local (cloud metadata), and internal TLDs. DNS names that resolve
|
|
109
|
+
// to private IPs are a residual (rebinding) risk, documented in the README.
|
|
110
|
+
function assertPublicHost(hostname) {
|
|
111
|
+
// Normalise the way `fetch` will. This tested the RAW string against a dotted-quad
|
|
112
|
+
// regex, but the WHATWG URL parser accepts `127.1`, `0x7f.0.0.1`, `0177.0.0.1` and
|
|
113
|
+
// `2130706433` and turns them all into 127.0.0.1 — so anything that wasn't already
|
|
114
|
+
// dotted-quad walked straight past the private-address check.
|
|
115
|
+
let h;
|
|
116
|
+
try {
|
|
117
|
+
h = new URL(`https://${hostname}`).hostname.toLowerCase();
|
|
118
|
+
} catch {
|
|
119
|
+
throw TroveError.invalid('That is not a valid host');
|
|
120
|
+
}
|
|
121
|
+
if (h === 'localhost' || h.endsWith('.localhost') || h.endsWith('.local') || h.endsWith('.internal')) {
|
|
122
|
+
throw TroveError.invalid('Refusing to fetch from an internal host');
|
|
123
|
+
}
|
|
124
|
+
// IPv4 literal → block private/loopback/link-local ranges; block IPv6 literals wholesale.
|
|
125
|
+
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(h)) {
|
|
126
|
+
const [a, b] = h.split('.').map(Number);
|
|
127
|
+
if (a === 10 || a === 127 || a === 0 || (a === 192 && b === 168) || (a === 172 && b >= 16 && b <= 31) || (a === 169 && b === 254) || a >= 224) {
|
|
128
|
+
throw TroveError.invalid('Refusing to fetch from a private address');
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (h.includes(':') || h.startsWith('[')) throw TroveError.invalid('Refusing to fetch from an IP literal');
|
|
132
|
+
return h;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Which collection a request targets. There is no folder to infer one from any more,
|
|
136
|
+
// so it's named explicitly or it's the default.
|
|
137
|
+
function collectionOf(src) {
|
|
138
|
+
return src?.collection || src?.collectionId || 'default';
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function createRouter() {
|
|
142
|
+
const r = new Router();
|
|
143
|
+
|
|
144
|
+
// Liveness: the process is up and serving.
|
|
145
|
+
r.get('/api/health', [], () => ({ ok: true, service: 'trove', time: Date.now() }));
|
|
146
|
+
|
|
147
|
+
// Readiness: the backing store actually answers — for load-balancer / k8s gating.
|
|
148
|
+
r.get('/api/ready', ['sqlite'], async ({ sqlite }) => {
|
|
149
|
+
try {
|
|
150
|
+
if (sqlite) { const db = await sqlite.obtain({ key: 'metadata' }); await db.get('SELECT 1'); }
|
|
151
|
+
return { ok: true };
|
|
152
|
+
} catch (err) {
|
|
153
|
+
throw TroveError.transient('Storage not ready', { cause: err });
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
r.get('/api/capabilities', ['auth', 'collections', 'notifications', 'sidecar', 'vfs'], async (ctx) => {
|
|
158
|
+
const { vfs, config, sidecar, notifications, principal, query, auth, mcp } = ctx;
|
|
159
|
+
// Storage is per-collection, so report the backend for the requested collection
|
|
160
|
+
// (else the client picks the wrong upload strategy on a non-default collection).
|
|
161
|
+
let storage = vfs.storage;
|
|
162
|
+
if (query.collection) {
|
|
163
|
+
storage = await (await ctx.access.collection(query.collection, 'read')).storage();
|
|
164
|
+
}
|
|
165
|
+
return {
|
|
166
|
+
collection: query.collection || 'default',
|
|
167
|
+
storage: storage.capabilities,
|
|
168
|
+
indexers: vfs.indexers.list(),
|
|
169
|
+
partSize: vfs.uploads.partSize,
|
|
170
|
+
features: {
|
|
171
|
+
semanticSearch: !!vfs.search,
|
|
172
|
+
conversations: !!sidecar,
|
|
173
|
+
notifications: !!notifications,
|
|
174
|
+
webPush: !!notifications?.vapidPublicKey(),
|
|
175
|
+
auth: !!principal && !principal.anonymous,
|
|
176
|
+
},
|
|
177
|
+
principal: principal || null,
|
|
178
|
+
search: vfs.search ? vfs.search.describe() : null,
|
|
179
|
+
// What this deployment's search box actually accepts. The transformer owns the
|
|
180
|
+
// grammar, so it owns the prompt — a client that hardcodes "# filter by tag"
|
|
181
|
+
// tells people the wrong thing the moment a different transformer is configured.
|
|
182
|
+
searchPrompt: vfs.searchTransformer?.describe?.() || null,
|
|
183
|
+
|
|
184
|
+
// Where a refused client is sent, and where an agent connects. Both are DEPLOYMENT
|
|
185
|
+
// facts — env, or a field the library caller passed — so they are reported here
|
|
186
|
+
// rather than made editable: pointing the drive at a different authorization
|
|
187
|
+
// server changes who can reach every file in it, which is a deploy-time decision,
|
|
188
|
+
// not a preference.
|
|
189
|
+
auth: {
|
|
190
|
+
authorizationServers: auth?.authorizationServers || [],
|
|
191
|
+
// Which of TROVE_AUTH_SERVER / TROVE_JWT_ISSUER the value came from, or 'none'.
|
|
192
|
+
// "We inferred this from your issuer" is a different fact from "you set this",
|
|
193
|
+
// and someone debugging a mismatch needs to know which.
|
|
194
|
+
source: auth?.source || 'none',
|
|
195
|
+
metadataUrl: metadataUrl(publicOrigin(ctx.req, config)),
|
|
196
|
+
},
|
|
197
|
+
mcp: mcp ? {
|
|
198
|
+
enabled: true,
|
|
199
|
+
endpoint: mcp.endpoint(ctx.req),
|
|
200
|
+
metadataUrl: metadataUrl(mcp.endpoint(ctx.req)),
|
|
201
|
+
requiresAuth: mcp.requiresAuth(),
|
|
202
|
+
// The one state that makes the endpoint unusable while looking configured: a
|
|
203
|
+
// token is required and there is nowhere to go and get one. Named as a problem
|
|
204
|
+
// rather than left to be inferred from an empty array.
|
|
205
|
+
needsAuthorizationServer: mcp.requiresAuth() && !(auth?.authorizationServers || []).length,
|
|
206
|
+
} : { enabled: false },
|
|
207
|
+
|
|
208
|
+
...(config?.clientConfig || {}),
|
|
209
|
+
};
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
// --- collections -----------------------------------------------------------
|
|
213
|
+
|
|
214
|
+
r.get('/api/collections', ['collections'], async (ctx) => {
|
|
215
|
+
const { collections, principal } = ctx;
|
|
216
|
+
if (!collectionsEnabled(ctx)) return { collections: [{ id: 'default', name: 'My Drive', capabilities: ['read', 'write', 'delete', 'admin'] }] };
|
|
217
|
+
return { collections: await collections.list(principal), canCreate: collections.canCreate(principal) };
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
r.get('/api/collections/:id', ['collections'], async (ctx) => {
|
|
221
|
+
const { collections, principal, params } = ctx;
|
|
222
|
+
if (!collectionsEnabled(ctx)) return { collection: { id: 'default', name: 'My Drive' } };
|
|
223
|
+
const c = await collections.assert(principal, params.id, 'read');
|
|
224
|
+
return { collection: collections.describe(c, principal) };
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
r.post('/api/collections', ['collections'], async (ctx) => {
|
|
228
|
+
requireCollections(ctx);
|
|
229
|
+
return { collection: await ctx.collections.create(await body(ctx.req), ctx.principal) };
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
r.post('/api/collections/:id', ['collections'], async (ctx) => {
|
|
233
|
+
requireCollections(ctx);
|
|
234
|
+
return { collection: await ctx.collections.update(ctx.params.id, await body(ctx.req), ctx.principal) };
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
r.delete('/api/collections/:id', ['collections', 'vfs'], async (ctx) => {
|
|
238
|
+
requireCollections(ctx);
|
|
239
|
+
const { collections, vfs, principal, params } = ctx;
|
|
240
|
+
// A collection record is the only thing that knows where its items' BYTES live, so
|
|
241
|
+
// deleting it while items still reference it stranded every one of them: `storageFor`
|
|
242
|
+
// throws "Collection not found", and since reindex walks the whole metadata store,
|
|
243
|
+
// every rebuild — including the one at boot — failed on them forever, raising a
|
|
244
|
+
// retryable issue whose Retry re-ran the same failure. Refuse, and say what to do.
|
|
245
|
+
const n = await vfs.metadata.countItems?.(params.id);
|
|
246
|
+
if (n) {
|
|
247
|
+
throw TroveError.conflict(
|
|
248
|
+
`“${params.id}” still holds ${n.toLocaleString()} item${n === 1 ? '' : 's'}. `
|
|
249
|
+
+ 'Move or delete them first — removing the collection would leave them with no '
|
|
250
|
+
+ 'store to read their bytes from.',
|
|
251
|
+
{ details: { collectionId: params.id, items: n } },
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
return collections.remove(params.id, principal);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
r.post('/api/collections/:id/grants', ['collections'], async (ctx) => {
|
|
258
|
+
requireCollections(ctx);
|
|
259
|
+
return { collection: await ctx.collections.setGrant(ctx.params.id, await body(ctx.req), ctx.principal) };
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
// --- browse ----------------------------------------------------------------
|
|
263
|
+
|
|
264
|
+
// --- items -----------------------------------------------------------------
|
|
265
|
+
// One noun: there is no filesystem and no folders, so everything addressable is an
|
|
266
|
+
// item in a collection. Collection-level verbs sit at `/api/items/<verb>` and
|
|
267
|
+
// item-scoped ones at `/api/items/:id/…`. The router matches in registration order,
|
|
268
|
+
// so these literal routes must stay ABOVE any same-length `:id` route added later.
|
|
269
|
+
|
|
270
|
+
// Every item in a collection. There is nothing to descend into — a drive is browsed
|
|
271
|
+
// by search and by following links, and this is the "show me everything" fallback.
|
|
272
|
+
r.get('/api/items', ['vfs'], async (ctx) => {
|
|
273
|
+
const { vfs, query } = ctx;
|
|
274
|
+
const collectionId = collectionOf(query);
|
|
275
|
+
const collection = await ctx.access.collection(collectionId, 'read');
|
|
276
|
+
const { items, nextCursor } = await collection.list({
|
|
277
|
+
sort: query.sort, order: query.order,
|
|
278
|
+
limit: clampLimit(query.limit, 500),
|
|
279
|
+
cursor: query.cursor,
|
|
280
|
+
});
|
|
281
|
+
// `stats` describes the COLLECTION; `items` is one page of it. Without this the
|
|
282
|
+
// client can only report the page it happens to be holding, which on a drive with
|
|
283
|
+
// more items than fit in a page is simply a wrong number on screen.
|
|
284
|
+
const stats = await vfs.metadata.collectionStats?.(collectionId).catch(() => null) ?? null;
|
|
285
|
+
// Space left on the backing store, when it can say. Null for object stores, which
|
|
286
|
+
// have no such number — and a UI that showed a made-up gauge for S3 would be worse
|
|
287
|
+
// than one that shows nothing.
|
|
288
|
+
const usage = await collection.usage().catch(() => null);
|
|
289
|
+
return { items, nextCursor, collectionId, stats, usage };
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
// Resolve an item: by id, by `?name=` within a collection, or by a `trove:` URI.
|
|
293
|
+
r.get('/api/items/resolve', [], async (ctx) => {
|
|
294
|
+
const ref = ctx.query.id || ctx.query.uri || ctx.query.name;
|
|
295
|
+
if (!ref) throw TroveError.invalid('id, name or uri is required');
|
|
296
|
+
// A name is only unique within a collection, so the hint goes to `stat`; the
|
|
297
|
+
// capability is asserted on the collection the node turns out to be in.
|
|
298
|
+
const handle = await ctx.access.node(ref, 'read', { collectionId: collectionOf(ctx.query) });
|
|
299
|
+
return { node: handle.node };
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
// What links to this item — the inverse of the links its own content declares, and
|
|
303
|
+
// what replaces "which folder is it in?".
|
|
304
|
+
r.get('/api/items/backlinks', ['collections'], async (ctx) => {
|
|
305
|
+
const node = await ctx.access.node(ctx.query.id, 'read');
|
|
306
|
+
// Scoped in the query, not filtered after: backlinks cross collections, so a limit
|
|
307
|
+
// spent on unreadable rows would report "nothing links here" while something the
|
|
308
|
+
// caller can see sits just past the cut.
|
|
309
|
+
const items = await node.backlinks({
|
|
310
|
+
limit: clampLimit(ctx.query.limit, 100),
|
|
311
|
+
collectionIds: await readableCollectionIds(ctx),
|
|
312
|
+
});
|
|
313
|
+
return { items };
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
r.post('/api/items/rename', [], async (ctx) => {
|
|
317
|
+
const b = await body(ctx.req);
|
|
318
|
+
if (!b.id || !b.newName) throw TroveError.invalid('id and newName are required');
|
|
319
|
+
const node = await ctx.access.node(b.id, 'write');
|
|
320
|
+
return { node: await node.rename(b.newName) };
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
r.post('/api/items/delete', [], async (ctx) => {
|
|
324
|
+
const b = await body(ctx.req);
|
|
325
|
+
if (!b.id) throw TroveError.invalid('id is required');
|
|
326
|
+
return (await ctx.access.node(b.id, 'delete')).remove();
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
// --- download (presign redirect or range-aware proxy) ----------------------
|
|
330
|
+
|
|
331
|
+
r.get('/api/items/download', [], async (ctx) => {
|
|
332
|
+
const { query, req } = ctx;
|
|
333
|
+
if (!query.id) throw TroveError.invalid('id is required');
|
|
334
|
+
// A signed URL brings its own grant, because the things that need one — an <img
|
|
335
|
+
// src>, a <video src>, cache.add(), an external service an indexer handed a URL to
|
|
336
|
+
// — cannot send an Authorization header at all. It grants `read` on exactly the node
|
|
337
|
+
// it names; see engine/providers/access.js and docs/design/signed-urls.md.
|
|
338
|
+
const signature = query.sig ? { op: query.op, exp: query.exp, sig: query.sig } : null;
|
|
339
|
+
const node = await ctx.access.node(query.id, 'read', { signature });
|
|
340
|
+
const ct = node.contentType || 'application/octet-stream';
|
|
341
|
+
// Force a download for anything not safe to render inline in our own origin
|
|
342
|
+
// (HTML/SVG/etc. would otherwise be same-origin XSS when opened directly).
|
|
343
|
+
const attach = query.disposition === 'attachment' || !inlineSafe(ct);
|
|
344
|
+
const range = parseRange(req.headers.get('range'));
|
|
345
|
+
|
|
346
|
+
// Ranged requests must proxy (we can't add Range to a bare redirect safely
|
|
347
|
+
// for all clients), so only redirect for full-file GETs.
|
|
348
|
+
if (!range) {
|
|
349
|
+
const d = await node.download({ download: attach });
|
|
350
|
+
if (d.mode === 'redirect') return Response.redirect(d.url, 302);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const { stream, size, contentType, etag, range: served } = await node.read({ range });
|
|
354
|
+
const headers = {
|
|
355
|
+
'content-type': contentType || ct,
|
|
356
|
+
'accept-ranges': 'bytes',
|
|
357
|
+
'content-length': String(size),
|
|
358
|
+
'x-content-type-options': 'nosniff',
|
|
359
|
+
...(etag ? { etag } : {}),
|
|
360
|
+
'content-disposition': contentDisposition(attach ? 'attachment' : 'inline', node.name),
|
|
361
|
+
'cache-control': 'private, max-age=0',
|
|
362
|
+
};
|
|
363
|
+
if (served) {
|
|
364
|
+
headers['content-range'] = `bytes ${served.start}-${served.end}/${served.total}`;
|
|
365
|
+
return new Response(stream, { status: 206, headers });
|
|
366
|
+
}
|
|
367
|
+
return new Response(stream, { status: 200, headers });
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
// Mint URLs that carry their own authorization, for the things that cannot send a
|
|
371
|
+
// header. Batched on purpose: a gallery draws hundreds of tiles, and per-object
|
|
372
|
+
// signing is right for scoping but hopeless as one round trip each.
|
|
373
|
+
//
|
|
374
|
+
// Every id is authorized individually — the batch is a transport convenience, never a
|
|
375
|
+
// widening. An id the caller may not read is simply absent from the answer, with its
|
|
376
|
+
// reason alongside, so a partly-visible batch still returns the visible part.
|
|
377
|
+
r.post('/api/items/urls', [], async (ctx) => {
|
|
378
|
+
const b = await body(ctx.req);
|
|
379
|
+
const ids = Array.isArray(b.ids) ? b.ids.filter((i) => typeof i === 'string' && i) : [];
|
|
380
|
+
if (!ids.length) throw TroveError.invalid('ids is required');
|
|
381
|
+
if (ids.length > URL_MINT_BATCH) throw TroveError.invalid(`At most ${URL_MINT_BATCH} ids at a time`);
|
|
382
|
+
const op = b.op === 'media' || b.op === 'download' ? b.op : 'download';
|
|
383
|
+
const urls = {};
|
|
384
|
+
const failed = {};
|
|
385
|
+
for (const id of new Set(ids)) {
|
|
386
|
+
try {
|
|
387
|
+
const node = await ctx.access.node(id, 'read');
|
|
388
|
+
const { url, expiresAt } = await node.mintUrl({ op, download: op === 'download' });
|
|
389
|
+
urls[id] = { url, expiresAt };
|
|
390
|
+
} catch (err) {
|
|
391
|
+
failed[id] = err.code || 'internal';
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
return { urls, failed, op };
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
// --- uploads ---------------------------------------------------------------
|
|
398
|
+
|
|
399
|
+
r.post('/api/uploads', [], async (ctx) => {
|
|
400
|
+
const b = await body(ctx.req);
|
|
401
|
+
if (!b.name) throw TroveError.invalid('name is required');
|
|
402
|
+
const collection = await ctx.access.collection(collectionOf(b), 'write');
|
|
403
|
+
return uploadDescriptor(await collection.createUpload({
|
|
404
|
+
name: b.name, size: Number(b.size ?? 0), contentType: b.contentType,
|
|
405
|
+
overwrite: b.overwrite === true,
|
|
406
|
+
}));
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
// An upload spans several requests keyed only by an unguessable id, so each one
|
|
410
|
+
// re-obtains the handle — which re-asserts `write` on the session's collection. A
|
|
411
|
+
// grant revoked mid-upload stops the next part, which is the point.
|
|
412
|
+
|
|
413
|
+
r.get('/api/uploads/:id/status', [], async (ctx) => (await ctx.access.upload(ctx.params.id)).status());
|
|
414
|
+
|
|
415
|
+
r.post('/api/uploads/:id/parts/:n/sign', [], async (ctx) => {
|
|
416
|
+
const upload = await ctx.access.upload(ctx.params.id);
|
|
417
|
+
return { url: await upload.signPart(Number(ctx.params.n)) };
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
r.post('/api/uploads/:id/parts/:n/report', [], async (ctx) => {
|
|
421
|
+
const upload = await ctx.access.upload(ctx.params.id);
|
|
422
|
+
const b = await body(ctx.req);
|
|
423
|
+
return upload.reportPart(Number(ctx.params.n), b.etag);
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
// Direct part upload — raw body streamed to storage.
|
|
427
|
+
r.put('/api/uploads/:id/parts/:n', [], async (ctx) => {
|
|
428
|
+
const upload = await ctx.access.upload(ctx.params.id);
|
|
429
|
+
return json(await upload.uploadPart(Number(ctx.params.n), ctx.req.body ?? new Uint8Array(0)));
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
r.post('/api/uploads/:id/complete', [], async (ctx) => {
|
|
433
|
+
const upload = await ctx.access.upload(ctx.params.id);
|
|
434
|
+
const b = await body(ctx.req);
|
|
435
|
+
return { node: await upload.complete(b.parts) };
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
r.delete('/api/uploads/:id', [], async (ctx) => {
|
|
439
|
+
await (await ctx.access.upload(ctx.params.id)).abort();
|
|
440
|
+
return { ok: true };
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
// --- search & indexing -----------------------------------------------------
|
|
444
|
+
|
|
445
|
+
r.get('/api/search', ['collections', 'vfs'], async (ctx) => {
|
|
446
|
+
const { vfs, query } = ctx;
|
|
447
|
+
if (!query.q) throw TroveError.invalid('q is required');
|
|
448
|
+
const collectionIds = await readableCollectionIds(ctx, query.collection);
|
|
449
|
+
const results = await vfs.searchQuery(query.q, {
|
|
450
|
+
mode: query.mode, limit: clampLimit(query.limit, 40),
|
|
451
|
+
indexers: query.indexers ? query.indexers.split(',') : undefined,
|
|
452
|
+
collectionIds,
|
|
453
|
+
});
|
|
454
|
+
return { query: query.q, results };
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
// Unified query: a raw user string is run through the search transformer (default
|
|
458
|
+
// parses `#tag` syntax; a plugged-in one may use an LLM), then dispatched. Returns
|
|
459
|
+
// the results AND the `resolved` query (what was actually searched) so the client
|
|
460
|
+
// can honestly show it.
|
|
461
|
+
r.post('/api/query', ['collections', 'vfs'], async (ctx) => {
|
|
462
|
+
const b = await body(ctx.req);
|
|
463
|
+
if (typeof b.q !== 'string' || !b.q.trim()) throw TroveError.invalid('q is required');
|
|
464
|
+
const collectionIds = await readableCollectionIds(ctx, b.collection);
|
|
465
|
+
const { results, resolved } = await ctx.vfs.query(b.q, {
|
|
466
|
+
mode: b.mode, limit: clampLimit(b.limit, 40), collectionIds,
|
|
467
|
+
// Which views this client can draw with, so the transformer can suggest one of
|
|
468
|
+
// them. Passed through as-is — the transformer bounds it before use, and a client
|
|
469
|
+
// that sends nonsense only fails to get a suggestion.
|
|
470
|
+
views: Array.isArray(b.views) ? b.views : undefined,
|
|
471
|
+
});
|
|
472
|
+
return { query: b.q, results, resolved };
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
// Drive-wide tag/property filter (the launcher's `#tag` / `#key:op:value`).
|
|
476
|
+
r.post('/api/tags/search', ['collections', 'vfs'], async (ctx) => {
|
|
477
|
+
const b = await body(ctx.req);
|
|
478
|
+
const filters = Array.isArray(b.filters) ? b.filters : [];
|
|
479
|
+
const collectionIds = await readableCollectionIds(ctx, b.collection);
|
|
480
|
+
const items = await ctx.vfs.findByTags(filters, {
|
|
481
|
+
q: b.q, collectionIds, limit: clampLimit(b.limit, 100),
|
|
482
|
+
});
|
|
483
|
+
return { items };
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
r.get('/api/indexers', ['vfs'], ({ vfs }) => ({ indexers: vfs.indexers.list() }));
|
|
487
|
+
|
|
488
|
+
// Plugin indexers push a namespaced contribution here (semanticTexts / tags /
|
|
489
|
+
// metadata; legacy documents/facet accepted). The namespace is the path param,
|
|
490
|
+
// so a plugin can only ever write under its own id.
|
|
491
|
+
// Push a contribution under a contributor namespace. TWO gates, because they answer
|
|
492
|
+
// different questions: `write` on the collection says you may change this item at
|
|
493
|
+
// all, and namespace ownership says you may speak AS this contributor. Without the
|
|
494
|
+
// second, anyone who can write anywhere could overwrite `core.links` and quietly
|
|
495
|
+
// break every backlink, or impersonate another plugin's index.
|
|
496
|
+
r.post('/api/index/:indexerId', ['plugins'], async (ctx) => {
|
|
497
|
+
const b = await body(ctx.req);
|
|
498
|
+
if (!b.nodeId) throw TroveError.invalid('nodeId is required');
|
|
499
|
+
await assertContributorOwned(ctx, ctx.params.indexerId);
|
|
500
|
+
const node = await ctx.access.node(b.nodeId, 'write');
|
|
501
|
+
return node.contribute(ctx.params.indexerId, {
|
|
502
|
+
semanticTexts: b.semanticTexts, tags: b.tags, metadata: b.metadata,
|
|
503
|
+
documents: b.documents, facet: b.facet, // legacy
|
|
504
|
+
});
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
// --- background work, and problems that outlived it ------------------------
|
|
508
|
+
//
|
|
509
|
+
// Two endpoints that look similar and mean different things. `/api/tasks` is work
|
|
510
|
+
// happening NOW — it is in memory, it is gone when the server restarts, and that is
|
|
511
|
+
// correct, because the work is gone too. `/api/issues` is what a failure LEFT BEHIND:
|
|
512
|
+
// durable, because a file that failed to index is still unindexed tomorrow.
|
|
513
|
+
//
|
|
514
|
+
// Both are polled rather than streamed. There is no streaming transport anywhere in
|
|
515
|
+
// this server yet, and adding one for a progress bar would mean SSE plumbing through
|
|
516
|
+
// three adapters; the client polls fast only while something is running and goes
|
|
517
|
+
// silent otherwise, which costs nothing at rest.
|
|
518
|
+
|
|
519
|
+
// Every call through `ctx.tasks` is awaited. In a single long-lived process the
|
|
520
|
+
// registry is a local object and these resolve immediately; where the work runs
|
|
521
|
+
// somewhere else — a Durable Object, because a Worker isolate cannot own work that
|
|
522
|
+
// outlives a request — the same calls cross a boundary. Awaiting costs nothing in
|
|
523
|
+
// the first case and is the difference between working and not in the second.
|
|
524
|
+
|
|
525
|
+
r.get('/api/tasks', ['collections', 'tasks'], async (ctx) => {
|
|
526
|
+
const collectionIds = await readableCollectionIds(ctx);
|
|
527
|
+
return {
|
|
528
|
+
tasks: await ctx.tasks.list({
|
|
529
|
+
collectionIds,
|
|
530
|
+
// A drive-wide task (a full reindex) names no collection, so scoping can't
|
|
531
|
+
// place it — only someone who can act on the whole drive is shown one.
|
|
532
|
+
includeGlobal: await canWholeDrive(ctx),
|
|
533
|
+
}),
|
|
534
|
+
};
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
r.post('/api/tasks/:id/cancel', ['collections', 'tasks'], async (ctx) => {
|
|
538
|
+
await assertTaskAccess(ctx, await ctx.tasks.get(ctx.params.id), 'cancel');
|
|
539
|
+
return { cancelled: await ctx.tasks.cancel(ctx.params.id) };
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
r.delete('/api/tasks/:id', ['collections', 'tasks'], async (ctx) => {
|
|
543
|
+
await assertTaskAccess(ctx, await ctx.tasks.get(ctx.params.id), 'dismiss');
|
|
544
|
+
await ctx.tasks.dismiss(ctx.params.id);
|
|
545
|
+
return { ok: true };
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
r.get('/api/issues', ['collections', 'issues'], async (ctx) => {
|
|
549
|
+
const collectionIds = await readableCollectionIds(ctx);
|
|
550
|
+
const admin = await canWholeDrive(ctx);
|
|
551
|
+
const issues = await ctx.issues.list({ collectionIds, includeGlobal: admin });
|
|
552
|
+
// `retryable` is computed here, not stored: whether a fix can be attempted depends
|
|
553
|
+
// on which handlers this deployment registered, and the client should offer a Retry
|
|
554
|
+
// button only when pressing it will do something.
|
|
555
|
+
return { issues: issues.map((i) => ({ ...i, retryable: ctx.issues.canRetry(i) })) };
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
// Retrying starts a task and returns it immediately — the fix may take minutes, and
|
|
559
|
+
// holding the request open for it would just time out. The issue is NOT cleared here;
|
|
560
|
+
// it clears when the work actually succeeds.
|
|
561
|
+
r.post('/api/issues/:id/retry', ['collections', 'issues', 'tasks'], async (ctx) => {
|
|
562
|
+
const issue = await ctx.issues.get(ctx.params.id);
|
|
563
|
+
if (!issue) throw TroveError.notFound('Issue');
|
|
564
|
+
await assertIssueAccess(ctx, issue, 'write');
|
|
565
|
+
const started = ctx.issues.retry(ctx.params.id);
|
|
566
|
+
started.catch(() => {}); // the task record carries the failure; don't reject globally
|
|
567
|
+
// Hand back the task list so the client can adopt the new task without a round trip.
|
|
568
|
+
// Scoped exactly like GET /api/tasks — a task title names the file it is working on,
|
|
569
|
+
// so being allowed to retry one issue must not hand back the drive-wide list.
|
|
570
|
+
return {
|
|
571
|
+
ok: true,
|
|
572
|
+
// Awaited, like every other call through `ctx.tasks`. Where the registry is a
|
|
573
|
+
// Durable Object this returns a promise, and an unawaited one serialises to `{}`
|
|
574
|
+
// — a client that adopted "the new task list" would replace it with nothing.
|
|
575
|
+
tasks: await ctx.tasks.list({
|
|
576
|
+
collectionIds: await readableCollectionIds(ctx),
|
|
577
|
+
includeGlobal: await canWholeDrive(ctx),
|
|
578
|
+
}),
|
|
579
|
+
};
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
// Dismissing is not fixing. Allowed because a problem can become irrelevant (the file
|
|
583
|
+
// was deleted, the plugin uninstalled) and a list you can't clear stops being read —
|
|
584
|
+
// but if the underlying failure recurs, it comes straight back.
|
|
585
|
+
r.delete('/api/issues/:id', ['collections', 'issues'], async (ctx) => {
|
|
586
|
+
const issue = await ctx.issues.get(ctx.params.id);
|
|
587
|
+
if (!issue) return { ok: true };
|
|
588
|
+
await assertIssueAccess(ctx, issue, 'write');
|
|
589
|
+
await ctx.issues.remove(ctx.params.id);
|
|
590
|
+
return { ok: true };
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
// Rebuild the search index on demand. Admin-only: it re-reads every object in the
|
|
594
|
+
// drive, so it is a real load, and it is drive-wide rather than scoped to anything
|
|
595
|
+
// the caller owns. Returns the task, which is how the caller watches it.
|
|
596
|
+
r.post('/api/reindex', ['backgroundWork', 'collections', 'tasks'], async (ctx) => {
|
|
597
|
+
await requireWholeDrive(ctx, 'rebuild the search index');
|
|
598
|
+
if (!ctx.backgroundWork) throw TroveError.unsupported('Reindexing is not available on this deployment');
|
|
599
|
+
// Two concurrent full rebuilds would double the work to reach the same place, so
|
|
600
|
+
// `beginReindex` claims the drive first and says whether it got it. The claim is
|
|
601
|
+
// shared state rather than this process's task list — the other rebuild may be in
|
|
602
|
+
// another isolate, and a check that can only see local memory would not find it.
|
|
603
|
+
const { task, alreadyRunning } = await ctx.backgroundWork.beginReindex({ reason: 'Started manually' });
|
|
604
|
+
if (alreadyRunning) {
|
|
605
|
+
const local = (await ctx.tasks.list()).find((t) => t.kind === 'index' && t.status === 'running');
|
|
606
|
+
return { task: local || null, alreadyRunning: true };
|
|
607
|
+
}
|
|
608
|
+
return { task };
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
// --- trash -----------------------------------------------------------------
|
|
612
|
+
// Deleting moves an item here rather than destroying it. Everything below needs
|
|
613
|
+
// `delete` on the collection, the same capability the delete itself needed — seeing
|
|
614
|
+
// what you deleted, and undoing it, are not lesser rights than deleting.
|
|
615
|
+
|
|
616
|
+
r.get('/api/trash', [], async (ctx) => {
|
|
617
|
+
const collectionId = collectionOf(ctx.query);
|
|
618
|
+
const collection = await ctx.access.collection(collectionId, 'delete');
|
|
619
|
+
return { items: await collection.listTrash({ limit: clampLimit(ctx.query.limit, 200) }), collectionId };
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
r.post('/api/trash/restore', [], async (ctx) => {
|
|
623
|
+
const b = await body(ctx.req);
|
|
624
|
+
if (!b.id) throw TroveError.invalid('id is required');
|
|
625
|
+
// `trashed` — the item is out of the drive, which is the only reason to restore it.
|
|
626
|
+
const node = await ctx.access.node(b.id, 'delete', { trashed: true });
|
|
627
|
+
return { node: await node.restore() };
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
// Destroy for real. Separate from DELETE /api/items so that emptying the trash can
|
|
631
|
+
// never be something you reach by accident from the ordinary delete path.
|
|
632
|
+
r.post('/api/trash/purge', [], async (ctx) => {
|
|
633
|
+
const b = await body(ctx.req);
|
|
634
|
+
if (b.id) {
|
|
635
|
+
const node = await ctx.access.node(b.id, 'delete', { trashed: true });
|
|
636
|
+
await node.remove({ permanent: true });
|
|
637
|
+
return { purged: 1 };
|
|
638
|
+
}
|
|
639
|
+
const collection = await ctx.access.collection(collectionOf(b), 'delete');
|
|
640
|
+
return collection.purgeTrash({ limit: MAX_PAGE });
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
// Reconcile a collection against the bytes actually in its store — how files added,
|
|
644
|
+
// replaced, or removed by anything other than Trove get noticed. Needs `write` on the
|
|
645
|
+
// collection, because a scan can create items in it.
|
|
646
|
+
r.post('/api/collections/:id/scan', ['backgroundWork', 'tasks'], async (ctx) => {
|
|
647
|
+
await ctx.access.collection(ctx.params.id, 'write');
|
|
648
|
+
if (!ctx.backgroundWork) throw TroveError.unsupported('Scanning is not available on this deployment');
|
|
649
|
+
const { task, alreadyRunning } = await ctx.backgroundWork.beginScan(ctx.params.id, { reason: 'Started manually' });
|
|
650
|
+
if (alreadyRunning) {
|
|
651
|
+
const local = (await ctx.tasks.list())
|
|
652
|
+
.find((t) => t.kind === 'scan' && t.collectionId === ctx.params.id && t.status === 'running');
|
|
653
|
+
return { task: local || null, alreadyRunning: true };
|
|
654
|
+
}
|
|
655
|
+
return { task };
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
// --- identity --------------------------------------------------------------
|
|
659
|
+
|
|
660
|
+
r.get('/api/me', ['collections'], (ctx) => ({
|
|
661
|
+
principal: ctx.principal || null,
|
|
662
|
+
// Authenticated means SOMEONE signed in — not merely that a principal object
|
|
663
|
+
// exists. The shared anonymous user is a stand-in for "no identity configured", and
|
|
664
|
+
// reporting it as authenticated would have the client show a profile for nobody.
|
|
665
|
+
authenticated: !!ctx.principal && !ctx.principal.anonymous,
|
|
666
|
+
// From config like every other answer about the ACL layer. This one only decides
|
|
667
|
+
// which UI the client offers — the routes enforce regardless — but a `collections`
|
|
668
|
+
// that went missing would tell every visitor they were an administrator, which is
|
|
669
|
+
// a worse lie than an error.
|
|
670
|
+
admin: collectionsEnabled(ctx) ? ctx.collections.isAdmin(ctx.principal) : !!ctx.principal,
|
|
671
|
+
}));
|
|
672
|
+
|
|
673
|
+
// --- conversations, tags, sidecar (per file) -------------------------------
|
|
674
|
+
// The :id is a file node id; the sidecar is that file's CRDT document.
|
|
675
|
+
|
|
676
|
+
r.get('/api/items/:id/sidecar', [], async (ctx) => {
|
|
677
|
+
// The handle is the sidecar for this node: obtaining it resolved the node
|
|
678
|
+
// (404 if gone) and asserted `read` on its collection.
|
|
679
|
+
return (await ctx.access.node(ctx.params.id, 'read')).view();
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
r.post('/api/items/:id/comments', [], async (ctx) => {
|
|
683
|
+
requirePrincipal(ctx.principal);
|
|
684
|
+
const node = await ctx.access.node(ctx.params.id, 'write');
|
|
685
|
+
const b = await body(ctx.req);
|
|
686
|
+
return { comment: await node.comment({ body: b.body, parentId: b.parentId, mentions: b.mentions }, ctx.principal) };
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
r.post('/api/items/:id/comments/:cid/edit', [], async (ctx) => {
|
|
690
|
+
requirePrincipal(ctx.principal);
|
|
691
|
+
const node = await ctx.access.node(ctx.params.id, 'write'); // + authorship checked in the service
|
|
692
|
+
const b = await body(ctx.req);
|
|
693
|
+
return { comment: await node.editComment(ctx.params.cid, b.body, ctx.principal) };
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
r.delete('/api/items/:id/comments/:cid', [], async (ctx) => {
|
|
697
|
+
requirePrincipal(ctx.principal);
|
|
698
|
+
const node = await ctx.access.node(ctx.params.id, 'write'); // + authorship checked in the service
|
|
699
|
+
return node.deleteComment(ctx.params.cid, ctx.principal);
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
r.post('/api/items/:id/comments/:cid/react', [], async (ctx) => {
|
|
703
|
+
requirePrincipal(ctx.principal);
|
|
704
|
+
const node = await ctx.access.node(ctx.params.id, 'write');
|
|
705
|
+
const b = await body(ctx.req);
|
|
706
|
+
if (!b.emoji) throw TroveError.invalid('emoji is required');
|
|
707
|
+
return { comment: await node.react(ctx.params.cid, b.emoji, b.on !== false, ctx.principal) };
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
r.post('/api/items/:id/tags', [], async (ctx) => {
|
|
711
|
+
const node = await ctx.access.node(ctx.params.id, 'write');
|
|
712
|
+
const b = await body(ctx.req);
|
|
713
|
+
if (!b.name) throw TroveError.invalid('name is required');
|
|
714
|
+
// The façade sets the CRDT tag AND its queryable mirror together (no swallow).
|
|
715
|
+
return node.setTag(b.name, b.value, ctx.principal);
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
r.delete('/api/items/:id/tags/:name', [], async (ctx) => {
|
|
719
|
+
// Removing a tag is a write — a read handle has no removeTag, so a read-only
|
|
720
|
+
// user cannot strip tags off files they cannot modify.
|
|
721
|
+
const node = await ctx.access.node(ctx.params.id, 'write');
|
|
722
|
+
return node.removeTag(ctx.params.name, ctx.principal);
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
r.post('/api/items/:id/subscribe', [], async (ctx) => {
|
|
726
|
+
requirePrincipal(ctx.principal);
|
|
727
|
+
const node = await ctx.access.node(ctx.params.id, 'read');
|
|
728
|
+
const b = await body(ctx.req);
|
|
729
|
+
return node.subscribe(ctx.principal, !!b.muted);
|
|
730
|
+
});
|
|
731
|
+
r.delete('/api/items/:id/subscribe', [], async (ctx) => {
|
|
732
|
+
requirePrincipal(ctx.principal);
|
|
733
|
+
const node = await ctx.access.node(ctx.params.id, 'read');
|
|
734
|
+
return node.unsubscribe(ctx.principal);
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
// --- notifications & web push ----------------------------------------------
|
|
738
|
+
|
|
739
|
+
r.get('/api/notifications', ['notifications'], async ({ notifications, principal }) => {
|
|
740
|
+
requireNotifications(notifications);
|
|
741
|
+
requirePrincipal(principal);
|
|
742
|
+
return notifications.inbox(principal.id);
|
|
743
|
+
});
|
|
744
|
+
r.post('/api/notifications/read', ['notifications'], async ({ notifications, principal, req }) => {
|
|
745
|
+
requireNotifications(notifications);
|
|
746
|
+
requirePrincipal(principal);
|
|
747
|
+
const b = await body(req);
|
|
748
|
+
return notifications.markRead(principal.id, b.ids);
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
r.get('/api/push/vapid', ['notifications'], ({ notifications }) => ({ publicKey: notifications?.vapidPublicKey() || null }));
|
|
752
|
+
|
|
753
|
+
r.post('/api/push/subscribe', ['notifications'], async ({ notifications, principal, req }) => {
|
|
754
|
+
requireNotifications(notifications);
|
|
755
|
+
requirePrincipal(principal);
|
|
756
|
+
const b = await body(req);
|
|
757
|
+
return notifications.subscribePush(principal.id, b.subscription);
|
|
758
|
+
});
|
|
759
|
+
r.delete('/api/push/subscribe', ['notifications'], async ({ notifications, principal, req }) => {
|
|
760
|
+
requireNotifications(notifications);
|
|
761
|
+
requirePrincipal(principal);
|
|
762
|
+
const b = await body(req);
|
|
763
|
+
return notifications.unsubscribePush(principal.id, b.endpoint);
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
// --- plugins: domain verification proxy + per-plugin server storage --------
|
|
767
|
+
|
|
768
|
+
// Fetch a plugin domain's assetlinks doc server-side (avoids browser CORS).
|
|
769
|
+
r.get('/api/plugins/assetlinks', [], async ({ query, principal }) => {
|
|
770
|
+
requirePrincipal(principal); // don't expose an open fetch proxy to the world
|
|
771
|
+
const domain = String(query.domain || '');
|
|
772
|
+
if (!/^[a-z0-9.-]+$/i.test(domain) || !domain.includes('.')) throw TroveError.invalid('Invalid domain');
|
|
773
|
+
assertPublicHost(domain); // block loopback / private / metadata targets
|
|
774
|
+
const url = `https://${domain}/.well-known/trove-assetlinks.json`;
|
|
775
|
+
try {
|
|
776
|
+
// No redirects: a public host must not bounce us onto an internal target.
|
|
777
|
+
const res = await fetch(url, { redirect: 'error' });
|
|
778
|
+
if (!res.ok) return { assetlinks: null };
|
|
779
|
+
const body = await readCapped(res, 256 * 1024); // small, well-known doc
|
|
780
|
+
return { assetlinks: JSON.parse(body) };
|
|
781
|
+
} catch {
|
|
782
|
+
return { assetlinks: null };
|
|
783
|
+
}
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
// --- server-installed plugins: package store + install records --------------
|
|
787
|
+
// Account-scoped plugins upload their full package to the server (blob → pluggable
|
|
788
|
+
// PackageStore, record → SQLite) so they sync across the user's devices, the server
|
|
789
|
+
// can enforce their capabilities, and removal cleans up. Device-only plugins never
|
|
790
|
+
// touch these routes.
|
|
791
|
+
|
|
792
|
+
// Install: upload the raw package zip; grants via ?grants=files,storage. The server
|
|
793
|
+
// re-parses + validates and gates on scope (admin for server indexers / shared
|
|
794
|
+
// resources), then stores the blob (deduped by digest) + the install record.
|
|
795
|
+
r.post('/api/plugins/install', ['plugins'], async ({ plugins, principal, req, query }) => {
|
|
796
|
+
requirePlugins(plugins);
|
|
797
|
+
requirePrincipal(principal);
|
|
798
|
+
const bytes = await readBytesCapped(req, plugins.maxPackageBytes || 32 * 1024 * 1024);
|
|
799
|
+
const grants = query.grants ? String(query.grants).split(',').map((s) => s.trim()).filter(Boolean) : undefined;
|
|
800
|
+
return { install: await plugins.install({ principal, bytes, grants }) };
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
// List this account's server-installed plugins (for cross-device sync).
|
|
804
|
+
r.get('/api/plugins/installed', ['plugins'], async ({ plugins, principal }) => {
|
|
805
|
+
requirePlugins(plugins);
|
|
806
|
+
requirePrincipal(principal);
|
|
807
|
+
return { plugins: await plugins.list(principal) };
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
// Download a plugin's package blob so another device can enable it locally.
|
|
811
|
+
r.get('/api/plugins/:pluginId/package', ['plugins'], async ({ plugins, principal, params }) => {
|
|
812
|
+
requirePlugins(plugins);
|
|
813
|
+
requirePrincipal(principal);
|
|
814
|
+
const { stream, size } = await plugins.getPackage(principal, params.pluginId);
|
|
815
|
+
return new Response(stream, { status: 200, headers: {
|
|
816
|
+
'content-type': 'application/zip', 'content-length': String(size),
|
|
817
|
+
'content-disposition': `attachment; filename="${encodeURIComponent(params.pluginId)}.zip"`,
|
|
818
|
+
'x-content-type-options': 'nosniff',
|
|
819
|
+
} });
|
|
820
|
+
});
|
|
821
|
+
|
|
822
|
+
// Account uninstall: drop the record + blob, then wipe the plugin-private store.
|
|
823
|
+
r.delete('/api/plugins/:pluginId/install', ['plugins', 'sqlite'], async ({ plugins, sqlite, principal, params }) => {
|
|
824
|
+
requirePlugins(plugins);
|
|
825
|
+
requirePrincipal(principal);
|
|
826
|
+
const res = await plugins.remove(principal, params.pluginId);
|
|
827
|
+
if (sqlite) await sqlite.drop({ key: `pstore:${principal.id}:plg:${params.pluginId}` }).catch(() => {});
|
|
828
|
+
return res;
|
|
829
|
+
});
|
|
830
|
+
|
|
831
|
+
// Server-backed plugin storage: an isolated SQLite database per scope, keyed by
|
|
832
|
+
// (user, plugin) for the private scope or (user, verified domain) for the shared
|
|
833
|
+
// scope, so ownership is tracked and it can be wiped on uninstall. The sandboxed
|
|
834
|
+
// plugin reaches this only through the host (which sets `scope`/`domain` from the
|
|
835
|
+
// install record); we scope by the authenticated principal for cross-user
|
|
836
|
+
// isolation, and only expose a fixed set of SQL ops against that one scoped db.
|
|
837
|
+
const PLUGIN_SQL_OPS = new Set(['exec', 'run', 'get', 'all', 'batch']);
|
|
838
|
+
const storeKey = (principal, pluginId, scope, domain) =>
|
|
839
|
+
scope === 'domain'
|
|
840
|
+
? `pstore:${principal.id}:dom:${domain}`
|
|
841
|
+
: `pstore:${principal.id}:plg:${pluginId}`;
|
|
842
|
+
|
|
843
|
+
/**
|
|
844
|
+
* A plugin may only open the DOMAIN store of its own domain.
|
|
845
|
+
*
|
|
846
|
+
* A plugin id is `<domain>/<name>`, so the domain it is entitled to is not something
|
|
847
|
+
* the caller needs to tell us — and letting them tell us meant naming any domain and
|
|
848
|
+
* reading another vendor's shared store outright.
|
|
849
|
+
*/
|
|
850
|
+
const assertOwnDomain = (pluginId, domain) => {
|
|
851
|
+
const own = String(pluginId || '').split('/')[0];
|
|
852
|
+
if (!own || own !== domain) {
|
|
853
|
+
throw TroveError.forbidden(`"${pluginId}" may only use the domain store for "${own || '(none)'}"`);
|
|
854
|
+
}
|
|
855
|
+
};
|
|
856
|
+
|
|
857
|
+
r.post('/api/plugins/:pluginId/sql', ['plugins', 'sqlite'], async ({ sqlite, plugins, principal, params, req }) => {
|
|
858
|
+
requirePluginStore(sqlite, principal);
|
|
859
|
+
// Authoritative capability check when the plugin is server-installed (transitional:
|
|
860
|
+
// allowed if there's no install record — device plugins predate this).
|
|
861
|
+
await plugins.assertCapability(principal, params.pluginId, 'storage');
|
|
862
|
+
const { scope = 'plugin', op, sql, params: args = [], statements, domain } = await body(req);
|
|
863
|
+
if (!PLUGIN_SQL_OPS.has(op)) throw TroveError.invalid(`Unknown storage op "${op}"`);
|
|
864
|
+
if (scope !== 'plugin' && scope !== 'domain') throw TroveError.invalid(`Unknown storage scope "${scope}"`);
|
|
865
|
+
if (scope === 'domain' && !domain) throw TroveError.invalid('domain scope requires a domain');
|
|
866
|
+
// The domain store is SHARED across a vendor's plugins, so opening it is a claim to
|
|
867
|
+
// be one of them — and `pluginId` comes from the caller. `assertOwnDomain` ties the
|
|
868
|
+
// two together but both are the caller's words; the install record is what makes
|
|
869
|
+
// either mean anything. (The plugin-private scope needs no such check: its key is
|
|
870
|
+
// already scoped to this principal, so the worst it reaches is their own data.)
|
|
871
|
+
if (scope === 'domain') {
|
|
872
|
+
assertOwnDomain(params.pluginId, domain);
|
|
873
|
+
// Installed AND approved for the shared scope — the two are different questions,
|
|
874
|
+
// and only the second is the one an administrator was asked about.
|
|
875
|
+
await plugins.assertSharedStorage(principal, params.pluginId);
|
|
876
|
+
}
|
|
877
|
+
const db = await sqlite.obtain({ key: storeKey(principal, params.pluginId, scope, domain) });
|
|
878
|
+
return { result: await runPluginSql(db, op, { sql, args, statements }) };
|
|
879
|
+
});
|
|
880
|
+
|
|
881
|
+
// Uninstall cleanup: wipe the plugin-private scope. The domain scope is shared
|
|
882
|
+
// across a vendor's plugins and deliberately outlives any single uninstall.
|
|
883
|
+
r.delete('/api/plugins/:pluginId/data', ['plugins', 'sqlite'], async ({ sqlite, plugins, principal, params }) => {
|
|
884
|
+
requirePluginStore(sqlite, principal);
|
|
885
|
+
// Same gate as the /sql route. Without it this was destroy-any-plugin's-data for
|
|
886
|
+
// anyone who could reach the app origin — the one route in the pair that checked
|
|
887
|
+
// nothing at all.
|
|
888
|
+
await plugins.assertCapability(principal, params.pluginId, 'storage');
|
|
889
|
+
await sqlite.drop({ key: storeKey(principal, params.pluginId, 'plugin') });
|
|
890
|
+
return { ok: true };
|
|
891
|
+
});
|
|
892
|
+
|
|
893
|
+
return r;
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
async function runPluginSql(db, op, { sql, args, statements }) {
|
|
897
|
+
const params = Array.isArray(args) ? args : [];
|
|
898
|
+
switch (op) {
|
|
899
|
+
case 'exec': assertSafePluginSql(sql); await db.exec(sql); return { ok: true };
|
|
900
|
+
case 'run': assertSafePluginSql(sql); return db.run(sql, ...params);
|
|
901
|
+
case 'get': assertSafePluginSql(sql); return db.get(sql, ...params);
|
|
902
|
+
case 'all': assertSafePluginSql(sql); return db.all(sql, ...params);
|
|
903
|
+
case 'batch': {
|
|
904
|
+
const stmts = (Array.isArray(statements) ? statements : []).map((s) => {
|
|
905
|
+
assertSafePluginSql(s?.sql);
|
|
906
|
+
return { sql: s.sql, params: Array.isArray(s.params) ? s.params : [] };
|
|
907
|
+
});
|
|
908
|
+
await db.batch(stmts);
|
|
909
|
+
return { ok: true };
|
|
910
|
+
}
|
|
911
|
+
default: throw TroveError.invalid(`Unknown storage op "${op}"`);
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
function requirePluginStore(sqlite, principal) {
|
|
916
|
+
if (!sqlite) throw TroveError.unsupported('Server plugin storage is not enabled');
|
|
917
|
+
if (!principal) throw TroveError.unauthorized('Authentication required');
|
|
918
|
+
}
|
|
919
|
+
function requirePlugins(plugins) {
|
|
920
|
+
if (!plugins) throw TroveError.unsupported('Server plugin installs are not enabled');
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
// Turn the core upload plan into a fully self-describing descriptor: how to send
|
|
924
|
+
// the bytes (presigned straight to storage, or proxied through us), the limits/quota,
|
|
925
|
+
// the auth headers a proxied transfer needs, and every lifecycle endpoint (status,
|
|
926
|
+
// (re)sign, report, complete "finished" hook, abort). `{partNumber}` is a template.
|
|
927
|
+
function uploadDescriptor(plan) {
|
|
928
|
+
const base = `/api/uploads/${encodeURIComponent(plan.uploadId)}`;
|
|
929
|
+
const transfer = plan.presigned
|
|
930
|
+
? {
|
|
931
|
+
mode: 'presigned', // client uploads directly to storage; we never see the bytes
|
|
932
|
+
// `single` returns one `url`; multipart `presign` returns `parts[{partNumber,url}]`.
|
|
933
|
+
...(plan.url ? { url: plan.url } : {}),
|
|
934
|
+
...(plan.parts ? { parts: plan.parts } : {}),
|
|
935
|
+
requiredHeaders: plan.strategy === 'single' && plan.contentType ? { 'content-type': plan.contentType } : {},
|
|
936
|
+
}
|
|
937
|
+
: {
|
|
938
|
+
mode: 'proxied', // client PUTs each part to us; we stream it to storage
|
|
939
|
+
partUrl: `${base}/parts/{partNumber}`,
|
|
940
|
+
// Proxied PUTs hit our own origin, so they carry the session's ambient auth
|
|
941
|
+
// (cookie/proxy header) automatically — no extra headers needed by default.
|
|
942
|
+
authHeaders: {},
|
|
943
|
+
};
|
|
944
|
+
const endpoints = {
|
|
945
|
+
status: `${base}/status`,
|
|
946
|
+
complete: `${base}/complete`, // the "upload finished" hook — POST reported parts here
|
|
947
|
+
abort: base, // DELETE
|
|
948
|
+
sign: plan.strategy === 'presign' ? `${base}/parts/{partNumber}/sign` : null,
|
|
949
|
+
report: plan.strategy === 'presign' ? `${base}/parts/{partNumber}/report` : null,
|
|
950
|
+
};
|
|
951
|
+
// Drop the now-internal raw transfer fields in favour of `transfer`.
|
|
952
|
+
const { presigned, url, parts, ...rest } = plan;
|
|
953
|
+
return { ...rest, transfer, endpoints };
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
/**
|
|
957
|
+
* A contributor namespace may only be written by whoever owns it.
|
|
958
|
+
*
|
|
959
|
+
* Only a plugin can push through the API, and only under its own contribution URI —
|
|
960
|
+
* which is unforgeable, since it is scoped to the plugin's verified domain and name.
|
|
961
|
+
* That rules out the two things a bare string would allow: writing a built-in's
|
|
962
|
+
* namespace (`core.*`, whose contributions the server produces and nobody else may
|
|
963
|
+
* touch), and writing another plugin's.
|
|
964
|
+
*/
|
|
965
|
+
async function assertContributorOwned(ctx, contributorId) {
|
|
966
|
+
const parsed = parseContribUri(contributorId);
|
|
967
|
+
if (!parsed || parsed.domain === CORE_DOMAIN) {
|
|
968
|
+
throw TroveError.forbidden(`"${contributorId}" is not a namespace you can contribute to`);
|
|
969
|
+
}
|
|
970
|
+
// No presence check. `plugins` is always built — there is no configuration that
|
|
971
|
+
// switches it off — so `if (!ctx.plugins) return` guarded a condition that could
|
|
972
|
+
// not occur, and the only thing it ever did was fail OPEN when the wiring was
|
|
973
|
+
// wrong: this check silently became a no-op and any authenticated caller could
|
|
974
|
+
// contribute under any vendor's name. Enforcement decides from configuration,
|
|
975
|
+
// never from whether an object happens to be here; a missing service throws.
|
|
976
|
+
// The namespace is only unforgeable if we check that the plugin is actually installed.
|
|
977
|
+
// `assertCapability` alone allows when there is no install record (transitional, for
|
|
978
|
+
// device-installed plugins), which made "unforgeable" false in the shipped default:
|
|
979
|
+
// any authenticated caller could contribute under any vendor's name.
|
|
980
|
+
await ctx.plugins.assertInstalled(ctx.principal, parsed.pluginId);
|
|
981
|
+
await ctx.plugins.assertCapability(ctx.principal, parsed.pluginId, 'indexer');
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
/**
|
|
985
|
+
* The collections this caller may read, optionally narrowed to one they asked for.
|
|
986
|
+
* `undefined` when collections are disabled, which means "don't scope" downstream.
|
|
987
|
+
*
|
|
988
|
+
* Every drive-wide query needs this, and it has to be applied INSIDE the query rather
|
|
989
|
+
* than by filtering results: a LIMIT spent on rows the caller can't see would report
|
|
990
|
+
* "no matches" while matches they can see sit just past the cut.
|
|
991
|
+
*/
|
|
992
|
+
async function readableCollectionIds(ctx, narrowTo) {
|
|
993
|
+
if (!collectionsEnabled(ctx)) return undefined;
|
|
994
|
+
const readable = (await ctx.collections.list(ctx.principal)).map((c) => c.id);
|
|
995
|
+
return narrowTo ? readable.filter((id) => id === narrowTo) : readable;
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
/**
|
|
999
|
+
* Whether this deployment has an ACL layer at all.
|
|
1000
|
+
*
|
|
1001
|
+
* Read from configuration, not from whether `ctx.collections` is truthy. The two
|
|
1002
|
+
* agree when everything is wired correctly, and diverge exactly when it is not —
|
|
1003
|
+
* and a security check that stands down because a service is missing is one that
|
|
1004
|
+
* stops enforcing at the worst possible moment. Configuration says whether to
|
|
1005
|
+
* enforce; the service does the enforcing, and if it is absent this throws.
|
|
1006
|
+
*/
|
|
1007
|
+
const collectionsEnabled = (ctx) => ctx.config?.collections !== false;
|
|
1008
|
+
|
|
1009
|
+
/**
|
|
1010
|
+
* Managing collections is only meaningful where there is an ACL layer to manage.
|
|
1011
|
+
*
|
|
1012
|
+
* From config, not from `ctx.collections` being null — the two agree today only
|
|
1013
|
+
* because the provider derives one from the other, and a build failure would make
|
|
1014
|
+
* "Collections are not enabled" a lie about a drive that has them.
|
|
1015
|
+
*/
|
|
1016
|
+
function requireCollections(ctx) {
|
|
1017
|
+
if (!collectionsEnabled(ctx)) throw TroveError.unsupported('Collections are not enabled');
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
async function assertCap(ctx, collectionId, capability) {
|
|
1021
|
+
if (!collectionsEnabled(ctx)) return; // no ACL layer configured
|
|
1022
|
+
await ctx.collections.assert(ctx.principal, collectionId, capability);
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
/**
|
|
1026
|
+
* Gate an operation that acts on the whole drive rather than on anything the caller
|
|
1027
|
+
* owns — rebuilding the index, cancelling someone else's task. See
|
|
1028
|
+
* CollectionService.hasWholeDrive for why this isn't plain `isAdmin`.
|
|
1029
|
+
*/
|
|
1030
|
+
async function requireWholeDrive(ctx, what) {
|
|
1031
|
+
const allowed = collectionsEnabled(ctx)
|
|
1032
|
+
? await ctx.collections.hasWholeDrive(ctx.principal)
|
|
1033
|
+
: !!ctx.principal;
|
|
1034
|
+
if (!allowed) throw TroveError.forbidden(`You do not have permission to ${what}`);
|
|
1035
|
+
}
|
|
1036
|
+
const canWholeDrive = (ctx) =>
|
|
1037
|
+
(collectionsEnabled(ctx)
|
|
1038
|
+
? ctx.collections.hasWholeDrive(ctx.principal)
|
|
1039
|
+
: Promise.resolve(!!ctx.principal));
|
|
1040
|
+
|
|
1041
|
+
/**
|
|
1042
|
+
* Who may act on an issue: whoever may act on the thing it is about.
|
|
1043
|
+
*
|
|
1044
|
+
* An issue names a file ("welcome.md could not be indexed"), so it leaks that file's
|
|
1045
|
+
* existence and name — it has to be scoped exactly like the file is. A drive-wide issue
|
|
1046
|
+
* belongs to no collection, so it takes admin. This is the same reasoning as
|
|
1047
|
+
* readableCollectionIds, applied to a different surface, and it must not be skipped
|
|
1048
|
+
* just because an issue "is only an error message".
|
|
1049
|
+
*/
|
|
1050
|
+
async function assertIssueAccess(ctx, issue, capability) {
|
|
1051
|
+
if (issue.collectionId == null) return requireWholeDrive(ctx, 'act on a drive-wide problem');
|
|
1052
|
+
await assertCap(ctx, issue.collectionId, capability);
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
/** Same rule for tasks: a task about a collection follows that collection. */
|
|
1056
|
+
async function assertTaskAccess(ctx, task, what) {
|
|
1057
|
+
if (!task) throw TroveError.notFound('Task');
|
|
1058
|
+
if (task.collectionId == null) return requireWholeDrive(ctx, `${what} a drive-wide task`);
|
|
1059
|
+
await assertCap(ctx, task.collectionId, 'write');
|
|
1060
|
+
}
|
|
1061
|
+
function requirePrincipal(principal) {
|
|
1062
|
+
if (!principal) throw TroveError.unauthorized('Authentication required');
|
|
1063
|
+
}
|
|
1064
|
+
function requireNotifications(n) {
|
|
1065
|
+
if (!n) throw TroveError.unsupported('Notifications are not enabled on this server');
|
|
1066
|
+
}
|