@hatk/hatk 0.0.1-alpha.1 → 0.0.1-alpha.100
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/dist/adapter.d.ts +19 -0
- package/dist/adapter.d.ts.map +1 -0
- package/dist/adapter.js +113 -0
- package/dist/backfill.d.ts +90 -1
- package/dist/backfill.d.ts.map +1 -1
- package/dist/backfill.js +254 -55
- package/dist/car.d.ts +59 -1
- package/dist/car.d.ts.map +1 -1
- package/dist/car.js +179 -7
- package/dist/cbor.d.ts +37 -0
- package/dist/cbor.d.ts.map +1 -1
- package/dist/cbor.js +36 -3
- package/dist/cid.d.ts +37 -0
- package/dist/cid.d.ts.map +1 -1
- package/dist/cid.js +38 -3
- package/dist/cli.js +247 -986
- package/dist/config.d.ts +145 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +62 -9
- package/dist/database/adapter-factory.d.ts +6 -0
- package/dist/database/adapter-factory.d.ts.map +1 -0
- package/dist/database/adapter-factory.js +20 -0
- package/dist/database/adapters/duckdb-search.d.ts +12 -0
- package/dist/database/adapters/duckdb-search.d.ts.map +1 -0
- package/dist/database/adapters/duckdb-search.js +27 -0
- package/dist/database/adapters/duckdb.d.ts +25 -0
- package/dist/database/adapters/duckdb.d.ts.map +1 -0
- package/dist/database/adapters/duckdb.js +161 -0
- package/dist/database/adapters/sqlite-search.d.ts +16 -0
- package/dist/database/adapters/sqlite-search.d.ts.map +1 -0
- package/dist/database/adapters/sqlite-search.js +76 -0
- package/dist/database/adapters/sqlite.d.ts +18 -0
- package/dist/database/adapters/sqlite.d.ts.map +1 -0
- package/dist/database/adapters/sqlite.js +88 -0
- package/dist/{db.d.ts → database/db.d.ts} +94 -6
- package/dist/database/db.d.ts.map +1 -0
- package/dist/database/db.js +1833 -0
- package/dist/database/dialect.d.ts +45 -0
- package/dist/database/dialect.d.ts.map +1 -0
- package/dist/database/dialect.js +72 -0
- package/dist/{fts.d.ts → database/fts.d.ts} +7 -0
- package/dist/database/fts.d.ts.map +1 -0
- package/dist/{fts.js → database/fts.js} +122 -34
- package/dist/database/index.d.ts +7 -0
- package/dist/database/index.d.ts.map +1 -0
- package/dist/database/index.js +6 -0
- package/dist/database/ports.d.ts +50 -0
- package/dist/database/ports.d.ts.map +1 -0
- package/dist/database/ports.js +1 -0
- package/dist/database/schema.d.ts +91 -0
- package/dist/database/schema.d.ts.map +1 -0
- package/dist/{schema.js → database/schema.js} +153 -54
- package/dist/dev-entry.d.ts +8 -0
- package/dist/dev-entry.d.ts.map +1 -0
- package/dist/dev-entry.js +126 -0
- package/dist/feeds.d.ts +26 -8
- package/dist/feeds.d.ts.map +1 -1
- package/dist/feeds.js +72 -16
- package/dist/hooks.d.ts +102 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +186 -0
- package/dist/hydrate.d.ts +55 -6
- package/dist/hydrate.d.ts.map +1 -1
- package/dist/hydrate.js +21 -20
- package/dist/indexer.d.ts +195 -3
- package/dist/indexer.d.ts.map +1 -1
- package/dist/indexer.js +634 -94
- package/dist/jetstream.d.ts +118 -0
- package/dist/jetstream.d.ts.map +1 -0
- package/dist/jetstream.js +238 -0
- package/dist/labels.d.ts +36 -0
- package/dist/labels.d.ts.map +1 -1
- package/dist/labels.js +71 -6
- package/dist/lex-types.d.ts +2 -0
- package/dist/lex-types.d.ts.map +1 -1
- package/dist/lexicon-resolve.d.ts.map +1 -1
- package/dist/lexicon-resolve.js +27 -112
- package/dist/lexicons/com/atproto/label/defs.json +75 -0
- package/dist/lexicons/com/atproto/moderation/defs.json +30 -0
- package/dist/lexicons/com/atproto/repo/strongRef.json +21 -0
- package/dist/lexicons/dev/hatk/applyWrites.json +87 -0
- package/dist/lexicons/dev/hatk/createRecord.json +40 -0
- package/dist/lexicons/dev/hatk/createReport.json +48 -0
- package/dist/lexicons/dev/hatk/deleteRecord.json +25 -0
- package/dist/lexicons/dev/hatk/describeCollections.json +41 -0
- package/dist/lexicons/dev/hatk/describeFeeds.json +29 -0
- package/dist/lexicons/dev/hatk/describeLabels.json +45 -0
- package/dist/lexicons/dev/hatk/getFeed.json +30 -0
- package/dist/lexicons/dev/hatk/getPreferences.json +19 -0
- package/dist/lexicons/dev/hatk/getRecord.json +26 -0
- package/dist/lexicons/dev/hatk/getRecords.json +32 -0
- package/dist/lexicons/dev/hatk/putPreference.json +28 -0
- package/dist/lexicons/dev/hatk/putRecord.json +41 -0
- package/dist/lexicons/dev/hatk/searchRecords.json +32 -0
- package/dist/lexicons/dev/hatk/uploadBlob.json +23 -0
- package/dist/logger.d.ts +29 -0
- package/dist/logger.d.ts.map +1 -1
- package/dist/logger.js +29 -0
- package/dist/main.js +197 -75
- package/dist/mst.d.ts +18 -1
- package/dist/mst.d.ts.map +1 -1
- package/dist/mst.js +19 -8
- package/dist/oauth/db.d.ts +27 -1
- package/dist/oauth/db.d.ts.map +1 -1
- package/dist/oauth/db.js +80 -19
- package/dist/oauth/discovery.d.ts +10 -0
- package/dist/oauth/discovery.d.ts.map +1 -1
- package/dist/oauth/discovery.js +54 -2
- package/dist/oauth/server.d.ts +131 -1
- package/dist/oauth/server.d.ts.map +1 -1
- package/dist/oauth/server.js +504 -39
- package/dist/oauth/service-describe.d.ts +42 -0
- package/dist/oauth/service-describe.d.ts.map +1 -0
- package/dist/oauth/service-describe.js +108 -0
- package/dist/oauth/session.d.ts +32 -0
- package/dist/oauth/session.d.ts.map +1 -0
- package/dist/oauth/session.js +131 -0
- package/dist/opengraph.d.ts +10 -0
- package/dist/opengraph.d.ts.map +1 -1
- package/dist/opengraph.js +94 -42
- package/dist/pds-proxy.d.ts +92 -0
- package/dist/pds-proxy.d.ts.map +1 -0
- package/dist/pds-proxy.js +396 -0
- package/dist/private-collections.d.ts +13 -0
- package/dist/private-collections.d.ts.map +1 -0
- package/dist/private-collections.js +27 -0
- package/dist/push.d.ts +54 -0
- package/dist/push.d.ts.map +1 -0
- package/dist/push.js +367 -0
- package/dist/renderer.d.ts +27 -0
- package/dist/renderer.d.ts.map +1 -0
- package/dist/renderer.js +46 -0
- package/dist/resolve-hatk.d.ts +6 -0
- package/dist/resolve-hatk.d.ts.map +1 -0
- package/dist/resolve-hatk.js +20 -0
- package/dist/response.d.ts +16 -0
- package/dist/response.d.ts.map +1 -0
- package/dist/response.js +69 -0
- package/dist/scanner.d.ts +21 -0
- package/dist/scanner.d.ts.map +1 -0
- package/dist/scanner.js +88 -0
- package/dist/seed.d.ts +19 -0
- package/dist/seed.d.ts.map +1 -1
- package/dist/seed.js +43 -4
- package/dist/server-init.d.ts +8 -0
- package/dist/server-init.d.ts.map +1 -0
- package/dist/server-init.js +62 -0
- package/dist/server.d.ts +34 -3
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +1053 -626
- package/dist/setup.d.ts +28 -1
- package/dist/setup.d.ts.map +1 -1
- package/dist/setup.js +50 -3
- package/dist/spaces/blob.d.ts +112 -0
- package/dist/spaces/blob.d.ts.map +1 -0
- package/dist/spaces/blob.js +460 -0
- package/dist/spaces/credential.d.ts +62 -0
- package/dist/spaces/credential.d.ts.map +1 -0
- package/dist/spaces/credential.js +184 -0
- package/dist/spaces/engine.d.ts +97 -0
- package/dist/spaces/engine.d.ts.map +1 -0
- package/dist/spaces/engine.js +559 -0
- package/dist/spaces/guard.d.ts +41 -0
- package/dist/spaces/guard.d.ts.map +1 -0
- package/dist/spaces/guard.js +79 -0
- package/dist/spaces/identity.d.ts +37 -0
- package/dist/spaces/identity.d.ts.map +1 -0
- package/dist/spaces/identity.js +88 -0
- package/dist/spaces/index.d.ts +55 -0
- package/dist/spaces/index.d.ts.map +1 -0
- package/dist/spaces/index.js +133 -0
- package/dist/spaces/notify.d.ts +51 -0
- package/dist/spaces/notify.d.ts.map +1 -0
- package/dist/spaces/notify.js +121 -0
- package/dist/spaces/records.d.ts +37 -0
- package/dist/spaces/records.d.ts.map +1 -0
- package/dist/spaces/records.js +63 -0
- package/dist/spaces/store.d.ts +51 -0
- package/dist/spaces/store.d.ts.map +1 -0
- package/dist/spaces/store.js +99 -0
- package/dist/spaces/uri.d.ts +60 -0
- package/dist/spaces/uri.d.ts.map +1 -0
- package/dist/spaces/uri.js +97 -0
- package/dist/spaces/verify.d.ts +46 -0
- package/dist/spaces/verify.d.ts.map +1 -0
- package/dist/spaces/verify.js +94 -0
- package/dist/spaces/viewer.d.ts +51 -0
- package/dist/spaces/viewer.d.ts.map +1 -0
- package/dist/spaces/viewer.js +167 -0
- package/dist/spaces/visibility.d.ts +57 -0
- package/dist/spaces/visibility.d.ts.map +1 -0
- package/dist/spaces/visibility.js +81 -0
- package/dist/templates/feed.tpl +18 -0
- package/dist/templates/hook.tpl +5 -0
- package/dist/templates/label.tpl +15 -0
- package/dist/templates/og.tpl +17 -0
- package/dist/templates/seed.tpl +11 -0
- package/dist/templates/setup.tpl +5 -0
- package/dist/templates/test-feed.tpl +19 -0
- package/dist/templates/test-xrpc.tpl +19 -0
- package/dist/templates/xrpc.tpl +53 -0
- package/dist/test.d.ts +3 -1
- package/dist/test.d.ts.map +1 -1
- package/dist/test.js +60 -36
- package/dist/views.js +1 -1
- package/dist/vite-plugin.d.ts +1 -1
- package/dist/vite-plugin.d.ts.map +1 -1
- package/dist/vite-plugin.js +254 -65
- package/dist/xrpc.d.ts +122 -12
- package/dist/xrpc.d.ts.map +1 -1
- package/dist/xrpc.js +270 -55
- package/package.json +34 -10
- package/public/admin-auth.js +271 -306
- package/public/admin.html +177 -61
- package/public/robots.txt +2 -0
- package/dist/db.d.ts.map +0 -1
- package/dist/db.js +0 -1361
- package/dist/fts.d.ts.map +0 -1
- package/dist/oauth/hooks.d.ts +0 -10
- package/dist/oauth/hooks.d.ts.map +0 -1
- package/dist/oauth/hooks.js +0 -40
- package/dist/schema.d.ts +0 -51
- package/dist/schema.d.ts.map +0 -1
- package/dist/test-browser.d.ts +0 -14
- package/dist/test-browser.d.ts.map +0 -1
- package/dist/test-browser.js +0 -26
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serving a blob that lives in a permissioned space.
|
|
3
|
+
*
|
|
4
|
+
* Every other image hatk hands out has a public URL — a CDN path, or the
|
|
5
|
+
* repo's own `com.atproto.sync.getBlob`. A space's blob has neither, by
|
|
6
|
+
* design: `com.atproto.space.getBlob` serves it only to a credential holder,
|
|
7
|
+
* and only for a space the blob is actually referenced from. Giving one a
|
|
8
|
+
* public URL would turn that URL into the capability the credential exists to
|
|
9
|
+
* replace.
|
|
10
|
+
*
|
|
11
|
+
* So the bytes come through here, and every request is authorized before any
|
|
12
|
+
* of them move: the viewer's credential for the space, or the same 404 a
|
|
13
|
+
* missing blob gets. What that authorization guards is served two ways —
|
|
14
|
+
* `private` so the viewer's own browser may hold it and no shared cache may,
|
|
15
|
+
* and out of a bounded in-memory cache so the same photo is fetched from its
|
|
16
|
+
* repo once rather than once per member looking at it.
|
|
17
|
+
*/
|
|
18
|
+
import { emit } from "../logger.js";
|
|
19
|
+
import { repoEndpoint } from "./identity.js";
|
|
20
|
+
import { parseSpaceRef } from "./uri.js";
|
|
21
|
+
import { viewerCredential } from "./viewer.js";
|
|
22
|
+
/**
|
|
23
|
+
* What we are willing to hand back, whatever the repo claims it is.
|
|
24
|
+
*
|
|
25
|
+
* The record naming this blob was written by an account hatk does not control,
|
|
26
|
+
* and so was its mime type. Echoing that unchecked would let a writer choose
|
|
27
|
+
* what a browser executes in this origin.
|
|
28
|
+
*/
|
|
29
|
+
const SERVABLE = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/avif', 'image/gif']);
|
|
30
|
+
/**
|
|
31
|
+
* How long a browser may reuse a blob it has before it asks again.
|
|
32
|
+
*
|
|
33
|
+
* Every request that does arrive is authorized, so this is the window in which
|
|
34
|
+
* a viewer who has just lost access still sees what they had. A minute is
|
|
35
|
+
* small against what the surrounding design already accepts: the readable set
|
|
36
|
+
* is cached for five, and the space host lets a minted credential outlive a
|
|
37
|
+
* revocation by up to two hours.
|
|
38
|
+
*/
|
|
39
|
+
const MAX_AGE_S = 60;
|
|
40
|
+
/**
|
|
41
|
+
* Bytes held across every cached blob, and the largest single one worth
|
|
42
|
+
* holding. A blob past the entry limit streams straight through instead —
|
|
43
|
+
* caching it would evict a great many small ones to save one fetch.
|
|
44
|
+
*/
|
|
45
|
+
const CACHE_BYTES = 64 * 1024 * 1024;
|
|
46
|
+
const MAX_ENTRY_BYTES = 4 * 1024 * 1024;
|
|
47
|
+
/**
|
|
48
|
+
* How much of an original we will read in order to make a smaller one.
|
|
49
|
+
*
|
|
50
|
+
* Larger than what we hold, and deliberately: a photograph straight off a
|
|
51
|
+
* camera is exactly the case where resizing pays, and refusing to read it
|
|
52
|
+
* would be refusing the request that needs answering most. What comes back out
|
|
53
|
+
* is a derivative small enough to keep.
|
|
54
|
+
*/
|
|
55
|
+
const MAX_SOURCE_BYTES = 20 * 1024 * 1024;
|
|
56
|
+
/**
|
|
57
|
+
* The sizes a space blob is served at.
|
|
58
|
+
*
|
|
59
|
+
* The same words hatk's public path uses for imgproxy, with the same numbers,
|
|
60
|
+
* so an app naming a size says it once and means it either side of the line
|
|
61
|
+
* between a public blob and a permissioned one. What differs is who resizes:
|
|
62
|
+
* a space blob may not pass through an image CDN, so it happens here.
|
|
63
|
+
*
|
|
64
|
+
* A request naming none gets the original, which is what every caller written
|
|
65
|
+
* before presets existed asks for.
|
|
66
|
+
*/
|
|
67
|
+
const PRESETS = {
|
|
68
|
+
avatar: { width: 1000, height: 1000, fit: 'cover', quality: 85 },
|
|
69
|
+
avatar_thumbnail: { width: 128, height: 128, fit: 'cover', quality: 70 },
|
|
70
|
+
feed_thumbnail: { width: 1000, fit: 'inside', quality: 70 },
|
|
71
|
+
feed_fullsize: { quality: 90 },
|
|
72
|
+
banner: { width: 3000, height: 1000, fit: 'cover', quality: 85 },
|
|
73
|
+
};
|
|
74
|
+
/**
|
|
75
|
+
* Only a name from that list is a preset.
|
|
76
|
+
*
|
|
77
|
+
* Free-form dimensions would let anyone mint unlimited distinct cache keys and
|
|
78
|
+
* evict everything real, and would put the cost of an arbitrary resize behind
|
|
79
|
+
* a URL. An unrecognised name is treated as none rather than refused: a broken
|
|
80
|
+
* image is a worse answer than a large one.
|
|
81
|
+
*/
|
|
82
|
+
export function asPreset(name) {
|
|
83
|
+
return name && name in PRESETS ? name : undefined;
|
|
84
|
+
}
|
|
85
|
+
/** Resizing is skipped for these: one frame of an animation is not the animation. */
|
|
86
|
+
const ANIMATED = new Set(['image/gif']);
|
|
87
|
+
/**
|
|
88
|
+
* The URL this appview serves a space blob at, for whoever is asking.
|
|
89
|
+
*
|
|
90
|
+
* Built here rather than by the app, because the route is hatk's: a blob in a
|
|
91
|
+
* space has no public address by design, and this is the one address it does
|
|
92
|
+
* have — answered only to a viewer who may read the space it names.
|
|
93
|
+
*/
|
|
94
|
+
export function spaceBlobUrl(space, repo, cid, preset) {
|
|
95
|
+
const params = new URLSearchParams({ space, repo, cid });
|
|
96
|
+
if (preset)
|
|
97
|
+
params.set('preset', preset);
|
|
98
|
+
return `/space-blob?${params}`;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* The CID a blob reference names, in whichever spelling the read used.
|
|
102
|
+
*
|
|
103
|
+
* A repo read gives the lexicon's `{ ref: { $link } }`; a space read comes
|
|
104
|
+
* back through a lex client that decodes the same field into a CID object
|
|
105
|
+
* whose `/` is the string. One blob, two spellings, and no caller should have
|
|
106
|
+
* to know which it got.
|
|
107
|
+
*/
|
|
108
|
+
export function blobCid(blob) {
|
|
109
|
+
const ref = blob?.ref;
|
|
110
|
+
if (!ref || typeof ref !== 'object')
|
|
111
|
+
return undefined;
|
|
112
|
+
if (typeof ref.$link === 'string')
|
|
113
|
+
return ref.$link;
|
|
114
|
+
if (typeof ref['/'] === 'string')
|
|
115
|
+
return ref['/'];
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
export function parseSpaceBlobRequest(params) {
|
|
119
|
+
const space = params.get('space');
|
|
120
|
+
const repo = params.get('repo') ?? params.get('did');
|
|
121
|
+
const cid = params.get('cid');
|
|
122
|
+
if (!space || !repo || !cid)
|
|
123
|
+
return null;
|
|
124
|
+
if (!parseSpaceRef(space) || !repo.startsWith('did:'))
|
|
125
|
+
return null;
|
|
126
|
+
const preset = asPreset(params.get('preset'));
|
|
127
|
+
return { space, repo, cid, ...(preset ? { preset } : {}) };
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Blobs held in memory, least recently used first.
|
|
131
|
+
*
|
|
132
|
+
* In memory and not on disk on purpose. These are members' private photos, and
|
|
133
|
+
* a cache that survives a restart is a store of them: this one is bounded,
|
|
134
|
+
* forgotten when the process ends, and never written anywhere a backup would
|
|
135
|
+
* find it.
|
|
136
|
+
*/
|
|
137
|
+
const cache = new Map();
|
|
138
|
+
let cachedBytes = 0;
|
|
139
|
+
/**
|
|
140
|
+
* Fetches in flight, so a page opening with the same image in it twenty times
|
|
141
|
+
* fetches it once. Keyed like the cache.
|
|
142
|
+
*/
|
|
143
|
+
const inFlight = new Map();
|
|
144
|
+
/**
|
|
145
|
+
* The space is part of the key, and has to be.
|
|
146
|
+
*
|
|
147
|
+
* A CID names bytes, so caching by CID alone would hit across spaces — and the
|
|
148
|
+
* check that a blob is actually referenced from the space being named lives
|
|
149
|
+
* upstream, in `com.atproto.space.getBlob`. A viewer who may read one space
|
|
150
|
+
* could then name any CID they had heard of and be served it out of the cache
|
|
151
|
+
* without that check ever running.
|
|
152
|
+
*/
|
|
153
|
+
const keyFor = (request) => `${request.space}\n${request.cid}\n${request.preset ?? ''}`;
|
|
154
|
+
function cacheGet(key) {
|
|
155
|
+
const entry = cache.get(key);
|
|
156
|
+
if (!entry)
|
|
157
|
+
return undefined;
|
|
158
|
+
// Re-inserted so iteration order is least-recently-used first.
|
|
159
|
+
cache.delete(key);
|
|
160
|
+
cache.set(key, entry);
|
|
161
|
+
return entry;
|
|
162
|
+
}
|
|
163
|
+
function cachePut(key, entry) {
|
|
164
|
+
if (entry.bytes.byteLength > MAX_ENTRY_BYTES)
|
|
165
|
+
return;
|
|
166
|
+
const existing = cache.get(key);
|
|
167
|
+
if (existing)
|
|
168
|
+
cachedBytes -= existing.bytes.byteLength;
|
|
169
|
+
cache.set(key, entry);
|
|
170
|
+
cachedBytes += entry.bytes.byteLength;
|
|
171
|
+
while (cachedBytes > CACHE_BYTES) {
|
|
172
|
+
const oldest = cache.keys().next();
|
|
173
|
+
if (oldest.done)
|
|
174
|
+
break;
|
|
175
|
+
cachedBytes -= cache.get(oldest.value).bytes.byteLength;
|
|
176
|
+
cache.delete(oldest.value);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
/** Drop everything held. For tests, and for an operator who wants it gone. */
|
|
180
|
+
export function resetSpaceBlobCache() {
|
|
181
|
+
cache.clear();
|
|
182
|
+
inFlight.clear();
|
|
183
|
+
cachedBytes = 0;
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* A CID is a hash of the bytes it names, which is exactly what an ETag is for.
|
|
187
|
+
* Strong, free, and stable for as long as the blob exists. The preset is part
|
|
188
|
+
* of it because it is part of what comes back.
|
|
189
|
+
*/
|
|
190
|
+
const etagFor = (request) => request.preset ? `"${request.cid}-${request.preset}"` : `"${request.cid}"`;
|
|
191
|
+
/** Whether the browser already holds this blob. `*` matches anything it has. */
|
|
192
|
+
function matches(ifNoneMatch, etag) {
|
|
193
|
+
if (!ifNoneMatch)
|
|
194
|
+
return false;
|
|
195
|
+
return ifNoneMatch
|
|
196
|
+
.split(',')
|
|
197
|
+
.map((candidate) => candidate.trim().replace(/^W\//, ''))
|
|
198
|
+
.some((candidate) => candidate === '*' || candidate === etag);
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Headers every answer carries, whether it has a body or not.
|
|
202
|
+
*
|
|
203
|
+
* `private` is the whole of the caching posture: the viewer's own browser may
|
|
204
|
+
* keep what it was shown, and no cache between here and there may. `vary`
|
|
205
|
+
* keeps it honest on a browser where two people sign in — the response turns
|
|
206
|
+
* on who asked, and who asked is the cookie or the token.
|
|
207
|
+
*/
|
|
208
|
+
function cacheHeaders(request) {
|
|
209
|
+
return {
|
|
210
|
+
etag: etagFor(request),
|
|
211
|
+
'cache-control': `private, max-age=${MAX_AGE_S}, must-revalidate`,
|
|
212
|
+
vary: 'cookie, authorization',
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
function serve(entry, request) {
|
|
216
|
+
return new Response(entry.bytes, {
|
|
217
|
+
status: 200,
|
|
218
|
+
headers: {
|
|
219
|
+
'content-type': entry.contentType,
|
|
220
|
+
...cacheHeaders(request),
|
|
221
|
+
'content-disposition': 'inline',
|
|
222
|
+
'content-security-policy': "default-src 'none'; sandbox",
|
|
223
|
+
'x-content-type-options': 'nosniff',
|
|
224
|
+
},
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
/** A refusal from the repo host, carried out through the in-flight promise. */
|
|
228
|
+
class UpstreamRefusal extends Error {
|
|
229
|
+
status;
|
|
230
|
+
constructor(status) {
|
|
231
|
+
super(`upstream refused with ${status}`);
|
|
232
|
+
this.status = status;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Fetch a space blob as this viewer, or answer why not.
|
|
237
|
+
*
|
|
238
|
+
* A viewer who cannot read the space gets the same 404 as a blob that is not
|
|
239
|
+
* there. The distinction between "you may not" and "it does not exist" is
|
|
240
|
+
* itself information about a private space, and the space host draws the line
|
|
241
|
+
* the same way when it reports a non-member's repo as simply absent.
|
|
242
|
+
*
|
|
243
|
+
* The credential is checked on every request, including the ones answered from
|
|
244
|
+
* the cache and the ones answered with a 304. Nothing here is ever served to
|
|
245
|
+
* somebody the authority has not just said yes to.
|
|
246
|
+
*/
|
|
247
|
+
export async function serveSpaceBlob(oauth, viewer, request, ifNoneMatch) {
|
|
248
|
+
if (!viewer)
|
|
249
|
+
return new Response('Unauthorized', { status: 401 });
|
|
250
|
+
const credential = await viewerCredential(oauth, viewer, request.space);
|
|
251
|
+
if (!credential)
|
|
252
|
+
return new Response('Not found', { status: 404 });
|
|
253
|
+
// The browser has it already. Authorized above, so this is a viewer who may
|
|
254
|
+
// go on looking at what they hold — and it costs neither of us the bytes.
|
|
255
|
+
if (matches(ifNoneMatch, etagFor(request))) {
|
|
256
|
+
return new Response(null, { status: 304, headers: cacheHeaders(request) });
|
|
257
|
+
}
|
|
258
|
+
const key = keyFor(request);
|
|
259
|
+
const hit = cacheGet(key);
|
|
260
|
+
if (hit)
|
|
261
|
+
return serve(hit, request);
|
|
262
|
+
let entry;
|
|
263
|
+
try {
|
|
264
|
+
entry = await (inFlight.get(key) ?? fetchOnce(key, credential, request));
|
|
265
|
+
}
|
|
266
|
+
catch (err) {
|
|
267
|
+
if (err instanceof UpstreamRefusal)
|
|
268
|
+
return new Response('Not found', { status: err.status });
|
|
269
|
+
emit('spaces', 'blob_error', { space: request.space, repo: request.repo, error: err.message });
|
|
270
|
+
return new Response('Blob unavailable', { status: 502 });
|
|
271
|
+
}
|
|
272
|
+
// Too large to hold, which the read above discovered from its headers and
|
|
273
|
+
// then dropped. Asking again costs a round trip and no bytes, and buys a
|
|
274
|
+
// body that is streamed rather than gathered into memory to be served once.
|
|
275
|
+
if (!entry)
|
|
276
|
+
return streamThrough(credential, request);
|
|
277
|
+
return serve(entry, request);
|
|
278
|
+
}
|
|
279
|
+
function blobUrlFor(endpoint, request) {
|
|
280
|
+
const url = new URL(`${endpoint}/xrpc/com.atproto.space.getBlob`);
|
|
281
|
+
url.searchParams.set('space', request.space);
|
|
282
|
+
// `repo`, not `did`: a space read names the repo holding the record the
|
|
283
|
+
// same way everywhere. A server hosting one account falls back to it,
|
|
284
|
+
// which is what makes `did` ever appear to work.
|
|
285
|
+
url.searchParams.set('repo', request.repo);
|
|
286
|
+
url.searchParams.set('cid', request.cid);
|
|
287
|
+
return url;
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* The upstream read, shared by everyone who asked for this blob at once.
|
|
291
|
+
*
|
|
292
|
+
* Resolves to null for a blob too large to hold, which the caller streams
|
|
293
|
+
* instead. A failure rejects, and rejects for all of them: one refusal is
|
|
294
|
+
* every caller's refusal, because the credential each of them presented had
|
|
295
|
+
* already been accepted for this space.
|
|
296
|
+
*/
|
|
297
|
+
function fetchOnce(key, credential, request) {
|
|
298
|
+
const task = (async () => {
|
|
299
|
+
const upstream = await credential.fetch(blobUrlFor(await repoEndpoint(request.repo), request));
|
|
300
|
+
if (!upstream.ok) {
|
|
301
|
+
// A credential the repo host rejects reads as absent, not as
|
|
302
|
+
// unauthorized: the viewer holds one, so what they are being told is
|
|
303
|
+
// that this blob is not theirs to see.
|
|
304
|
+
throw new UpstreamRefusal(upstream.status === 401 ? 404 : upstream.status);
|
|
305
|
+
}
|
|
306
|
+
const contentType = servableType(upstream);
|
|
307
|
+
const limit = request.preset ? MAX_SOURCE_BYTES : MAX_ENTRY_BYTES;
|
|
308
|
+
const declared = Number(upstream.headers.get('content-length') ?? NaN);
|
|
309
|
+
if (declared > limit) {
|
|
310
|
+
await upstream.body?.cancel();
|
|
311
|
+
return null;
|
|
312
|
+
}
|
|
313
|
+
// Read against the limit rather than trusting the length, which a repo host
|
|
314
|
+
// is free not to send and free to get wrong.
|
|
315
|
+
const source = await readBounded(upstream, limit);
|
|
316
|
+
if (!source)
|
|
317
|
+
return null;
|
|
318
|
+
// A resize that cannot happen is not a failure to serve. Sharp missing, an
|
|
319
|
+
// animation, or an image it cannot read all end here with the original,
|
|
320
|
+
// which is the answer this route gave before presets existed.
|
|
321
|
+
const bytes = request.preset ? ((await resize(source, contentType, request.preset)) ?? source) : source;
|
|
322
|
+
const entry = { bytes, contentType };
|
|
323
|
+
cachePut(key, entry);
|
|
324
|
+
return entry;
|
|
325
|
+
})().finally(() => {
|
|
326
|
+
if (inFlight.get(key) === task)
|
|
327
|
+
inFlight.delete(key);
|
|
328
|
+
});
|
|
329
|
+
inFlight.set(key, task);
|
|
330
|
+
return task;
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* The whole body, or null once it is plainly too big to hold.
|
|
334
|
+
*
|
|
335
|
+
* Bounded as it reads: a body that goes past the limit is abandoned there, so
|
|
336
|
+
* the most this ever holds is one blob's worth over the limit by one chunk.
|
|
337
|
+
*/
|
|
338
|
+
async function readBounded(upstream, limit) {
|
|
339
|
+
if (!upstream.body)
|
|
340
|
+
return new Uint8Array(0);
|
|
341
|
+
const reader = upstream.body.getReader();
|
|
342
|
+
const chunks = [];
|
|
343
|
+
let total = 0;
|
|
344
|
+
try {
|
|
345
|
+
while (true) {
|
|
346
|
+
const { done, value } = await reader.read();
|
|
347
|
+
if (done)
|
|
348
|
+
break;
|
|
349
|
+
total += value.byteLength;
|
|
350
|
+
if (total > limit) {
|
|
351
|
+
await reader.cancel();
|
|
352
|
+
return null;
|
|
353
|
+
}
|
|
354
|
+
chunks.push(value);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
finally {
|
|
358
|
+
reader.releaseLock();
|
|
359
|
+
}
|
|
360
|
+
const bytes = new Uint8Array(total);
|
|
361
|
+
let at = 0;
|
|
362
|
+
for (const chunk of chunks) {
|
|
363
|
+
bytes.set(chunk, at);
|
|
364
|
+
at += chunk.byteLength;
|
|
365
|
+
}
|
|
366
|
+
return bytes;
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Sharp, if this deployment has it.
|
|
370
|
+
*
|
|
371
|
+
* An optional peer dependency: hatk is a library, and most of what installs it
|
|
372
|
+
* never serves a permissioned space at all. Resolved once — a missing module
|
|
373
|
+
* is answered from the same settled promise rather than re-attempted per
|
|
374
|
+
* image.
|
|
375
|
+
*/
|
|
376
|
+
let sharpModule;
|
|
377
|
+
function loadSharp() {
|
|
378
|
+
sharpModule ??= import('sharp')
|
|
379
|
+
.then((m) => m.default)
|
|
380
|
+
.catch(() => {
|
|
381
|
+
emit('spaces', 'resize_unavailable', { reason: 'sharp is not installed' });
|
|
382
|
+
return null;
|
|
383
|
+
});
|
|
384
|
+
return sharpModule;
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* The blob at the size it is drawn, or null to serve the original instead.
|
|
388
|
+
*
|
|
389
|
+
* Orientation is applied before the resize and the metadata that carried it is
|
|
390
|
+
* dropped with everything else — which also means the GPS in a ride photo's
|
|
391
|
+
* EXIF stops being served to everyone the space admits.
|
|
392
|
+
*
|
|
393
|
+
* The format is kept rather than normalised to JPEG. Re-encoding a PNG that
|
|
394
|
+
* way silently flattens its transparency, and no size is worth that.
|
|
395
|
+
*
|
|
396
|
+
* Null when the original is the better answer — an animation, an image sharp
|
|
397
|
+
* cannot read, sharp absent, or a derivative that came out no smaller.
|
|
398
|
+
*/
|
|
399
|
+
async function resize(source, contentType, preset) {
|
|
400
|
+
if (ANIMATED.has(contentType))
|
|
401
|
+
return null;
|
|
402
|
+
const sharp = await loadSharp();
|
|
403
|
+
if (!sharp)
|
|
404
|
+
return null;
|
|
405
|
+
const { width, height, fit, quality } = PRESETS[preset];
|
|
406
|
+
try {
|
|
407
|
+
let pipeline = sharp(source).rotate();
|
|
408
|
+
if (width)
|
|
409
|
+
pipeline = pipeline.resize({ width, height, fit, withoutEnlargement: true });
|
|
410
|
+
// Named explicitly rather than left to sharp, which keeps the input format
|
|
411
|
+
// but not the quality — without this a preset that only sets quality, like
|
|
412
|
+
// a full-size one, would re-encode at sharp's default and do nothing.
|
|
413
|
+
if (contentType === 'image/jpeg')
|
|
414
|
+
pipeline = pipeline.jpeg({ quality, mozjpeg: true });
|
|
415
|
+
else if (contentType === 'image/webp')
|
|
416
|
+
pipeline = pipeline.webp({ quality });
|
|
417
|
+
else if (contentType === 'image/avif')
|
|
418
|
+
pipeline = pipeline.avif({ quality });
|
|
419
|
+
else if (contentType === 'image/png')
|
|
420
|
+
pipeline = pipeline.png({ compressionLevel: 9 });
|
|
421
|
+
const out = await pipeline.toBuffer();
|
|
422
|
+
// A derivative bigger than what it came from is a worse answer than the
|
|
423
|
+
// original — small images re-encoded at a higher quality can land there.
|
|
424
|
+
if (out.byteLength >= source.byteLength)
|
|
425
|
+
return null;
|
|
426
|
+
return new Uint8Array(out.buffer, out.byteOffset, out.byteLength);
|
|
427
|
+
}
|
|
428
|
+
catch (err) {
|
|
429
|
+
emit('spaces', 'resize_failed', { preset, content_type: contentType, error: err?.message ?? String(err) });
|
|
430
|
+
return null;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
/** A blob too large to cache, passed through as it arrives. */
|
|
434
|
+
async function streamThrough(credential, request) {
|
|
435
|
+
let upstream;
|
|
436
|
+
try {
|
|
437
|
+
upstream = await credential.fetch(blobUrlFor(await repoEndpoint(request.repo), request));
|
|
438
|
+
}
|
|
439
|
+
catch (err) {
|
|
440
|
+
emit('spaces', 'blob_error', { space: request.space, repo: request.repo, error: err.message });
|
|
441
|
+
return new Response('Blob unavailable', { status: 502 });
|
|
442
|
+
}
|
|
443
|
+
if (!upstream.ok) {
|
|
444
|
+
return new Response('Not found', { status: upstream.status === 401 ? 404 : upstream.status });
|
|
445
|
+
}
|
|
446
|
+
return new Response(upstream.body, {
|
|
447
|
+
status: 200,
|
|
448
|
+
headers: {
|
|
449
|
+
'content-type': servableType(upstream),
|
|
450
|
+
...cacheHeaders(request),
|
|
451
|
+
'content-disposition': 'inline',
|
|
452
|
+
'content-security-policy': "default-src 'none'; sandbox",
|
|
453
|
+
'x-content-type-options': 'nosniff',
|
|
454
|
+
},
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
function servableType(upstream) {
|
|
458
|
+
const claimed = (upstream.headers.get('content-type') ?? '').split(';')[0].trim();
|
|
459
|
+
return SERVABLE.has(claimed) ? claimed : 'application/octet-stream';
|
|
460
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Getting into a space to read it.
|
|
3
|
+
*
|
|
4
|
+
* hatk is not a member of anything. It has no DID that any community has
|
|
5
|
+
* admitted, and asking every authority to admit an indexer would make indexing
|
|
6
|
+
* a per-community act of trust before a single record could be read.
|
|
7
|
+
*
|
|
8
|
+
* So it reads as somebody who is already inside: a viewer whose OAuth session
|
|
9
|
+
* it already holds, whose PDS will mint a delegation token for the space, and
|
|
10
|
+
* whom the authority already lets in. The exchange is the same one the
|
|
11
|
+
* community's own web app makes from the browser —
|
|
12
|
+
*
|
|
13
|
+
* the reader's PDS signs a delegation token (60s, single-use, addressed to
|
|
14
|
+
* the authority's space host)
|
|
15
|
+
* the authority checks it, checks the reader against the space's policy, and
|
|
16
|
+
* returns a credential bound to the key that signed our DPoP proof (2h)
|
|
17
|
+
*
|
|
18
|
+
* — and the credential is then presented to every writer's host in the space.
|
|
19
|
+
* It carries no audience, which is why it must be bound to a key rather than
|
|
20
|
+
* held as a bearer token: a host handed a bearer credential to serve its own
|
|
21
|
+
* repo could replay it against every other host in the space.
|
|
22
|
+
*
|
|
23
|
+
* The consequence to be honest about: a space is only readable while somebody
|
|
24
|
+
* who can read it has a live session here. That is the same bound the data has
|
|
25
|
+
* anyway — nobody outside the space's audience was ever entitled to this — but
|
|
26
|
+
* it does mean indexing lapses when the last member logs out.
|
|
27
|
+
*/
|
|
28
|
+
import type { OAuthConfig } from '../config.ts';
|
|
29
|
+
export declare class SpaceCredentialError extends Error {
|
|
30
|
+
readonly status: number;
|
|
31
|
+
readonly code: string;
|
|
32
|
+
constructor(status: number, code: string, message: string);
|
|
33
|
+
}
|
|
34
|
+
/** A refusal about who is asking, rather than about the space or the network. */
|
|
35
|
+
export declare function isNotAuthorized(err: unknown): boolean;
|
|
36
|
+
export declare function isSpaceGone(err: unknown): boolean;
|
|
37
|
+
export interface SpaceCredential {
|
|
38
|
+
/** The space this credential reads, and the only one it is accepted for. */
|
|
39
|
+
space: string;
|
|
40
|
+
/** The account whose delegation bought it. */
|
|
41
|
+
readerDid: string;
|
|
42
|
+
expiresAt: number;
|
|
43
|
+
/** A fetch that presents the credential with a fresh DPoP proof per request. */
|
|
44
|
+
fetch: (input: string | URL, init?: RequestInit) => Promise<Response>;
|
|
45
|
+
}
|
|
46
|
+
/** Mint a credential for `space` using `readerDid`'s stored session. */
|
|
47
|
+
export declare function mintSpaceCredential(oauthConfig: OAuthConfig, space: string, readerDid: string): Promise<SpaceCredential>;
|
|
48
|
+
/**
|
|
49
|
+
* A credential for `space`, minted with whichever candidate session works.
|
|
50
|
+
*
|
|
51
|
+
* Candidates are tried in order and the first that succeeds is cached. A
|
|
52
|
+
* refusal about the reader — they were ejected, or never belonged — moves on to
|
|
53
|
+
* the next candidate; a refusal about the space itself stops the whole attempt,
|
|
54
|
+
* since no other reader would fare better.
|
|
55
|
+
*/
|
|
56
|
+
export declare function getSpaceCredential(oauthConfig: OAuthConfig, space: string, candidates: string[], opts?: {
|
|
57
|
+
refresh?: boolean;
|
|
58
|
+
}): Promise<SpaceCredential>;
|
|
59
|
+
export declare function forgetSpaceCredential(space: string): void;
|
|
60
|
+
/** For tests: drop every cached credential and the process binding key. */
|
|
61
|
+
export declare function resetSpaceCredentials(): void;
|
|
62
|
+
//# sourceMappingURL=credential.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"credential.d.ts","sourceRoot":"","sources":["../../src/spaces/credential.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAA;AAsB/C,qBAAa,oBAAqB,SAAQ,KAAK;IAE3C,QAAQ,CAAC,MAAM,EAAE,MAAM;IACvB,QAAQ,CAAC,IAAI,EAAE,MAAM;gBADZ,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM,EACrB,OAAO,EAAE,MAAM;CAIlB;AAED,iFAAiF;AACjF,wBAAgB,eAAe,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAErD;AAED,wBAAgB,WAAW,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAEjD;AAED,MAAM,WAAW,eAAe;IAC9B,4EAA4E;IAC5E,KAAK,EAAE,MAAM,CAAA;IACb,8CAA8C;IAC9C,SAAS,EAAE,MAAM,CAAA;IACjB,SAAS,EAAE,MAAM,CAAA;IACjB,gFAAgF;IAChF,KAAK,EAAE,CAAC,KAAK,EAAE,MAAM,GAAG,GAAG,EAAE,IAAI,CAAC,EAAE,WAAW,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAA;CACtE;AAiDD,wEAAwE;AACxE,wBAAsB,mBAAmB,CACvC,WAAW,EAAE,WAAW,EACxB,KAAK,EAAE,MAAM,EACb,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,eAAe,CAAC,CAiC1B;AAUD;;;;;;;GAOG;AACH,wBAAsB,kBAAkB,CACtC,WAAW,EAAE,WAAW,EACxB,KAAK,EAAE,MAAM,EACb,UAAU,EAAE,MAAM,EAAE,EACpB,IAAI,GAAE;IAAE,OAAO,CAAC,EAAE,OAAO,CAAA;CAAO,GAC/B,OAAO,CAAC,eAAe,CAAC,CAwB1B;AAED,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAEzD;AAED,2EAA2E;AAC3E,wBAAgB,qBAAqB,IAAI,IAAI,CAG5C"}
|