@alteran/astro 0.7.7 → 0.8.1
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 +25 -25
- package/migrations/0010_eminent_klaw.sql +37 -0
- package/migrations/0011_chief_darwin.sql +31 -0
- package/migrations/0012_backfill_blob_usage.sql +39 -0
- package/migrations/meta/0010_snapshot.json +790 -0
- package/migrations/meta/0011_snapshot.json +813 -0
- package/migrations/meta/_journal.json +22 -1
- package/package.json +24 -41
- package/src/db/blob.ts +323 -0
- package/src/db/dal.ts +224 -78
- package/src/db/repo.ts +205 -25
- package/src/db/schema.ts +14 -5
- package/src/handlers/debug.ts +4 -3
- package/src/lib/appview/auth-policy.ts +7 -24
- package/src/lib/appview/proxy.ts +56 -23
- package/src/lib/appview/types.ts +1 -6
- package/src/lib/auth-scope.ts +399 -0
- package/src/lib/auth.ts +40 -39
- package/src/lib/commit.ts +37 -15
- package/src/lib/did-document.ts +4 -5
- package/src/lib/jwt.ts +3 -1
- package/src/lib/mime.ts +9 -0
- package/src/lib/oauth/resource.ts +49 -0
- package/src/lib/preference-policy.ts +45 -0
- package/src/lib/preferences.ts +0 -4
- package/src/lib/public-host.ts +127 -0
- package/src/lib/ratelimit.ts +37 -12
- package/src/lib/relay.ts +7 -27
- package/src/lib/repo-write-blob-constraints.ts +141 -0
- package/src/lib/repo-write-data.ts +195 -0
- package/src/lib/repo-write-error.ts +46 -0
- package/src/lib/repo-write-validation.ts +463 -0
- package/src/lib/session-tokens.ts +22 -5
- package/src/lib/unsupported-routes.ts +32 -0
- package/src/lib/util.ts +57 -2
- package/src/pages/.well-known/atproto-did.ts +15 -3
- package/src/pages/.well-known/did.json.ts +13 -7
- package/src/pages/debug/db/bootstrap.ts +4 -3
- package/src/pages/debug/gc/blobs.ts +11 -8
- package/src/pages/debug/record.ts +11 -0
- package/src/pages/xrpc/[...nsid].ts +17 -9
- package/src/pages/xrpc/app.bsky.actor.getPreferences.ts +9 -3
- package/src/pages/xrpc/app.bsky.actor.putPreferences.ts +17 -4
- package/src/pages/xrpc/app.bsky.unspecced.getAgeAssuranceState.ts +4 -2
- package/src/pages/xrpc/chat.bsky.convo.getLog.ts +4 -2
- package/src/pages/xrpc/chat.bsky.convo.listConvos.ts +4 -2
- package/src/pages/xrpc/com.atproto.identity.getRecommendedDidCredentials.ts +10 -6
- package/src/pages/xrpc/com.atproto.identity.requestPlcOperationSignature.ts +4 -3
- package/src/pages/xrpc/com.atproto.identity.resolveHandle.ts +13 -5
- package/src/pages/xrpc/com.atproto.identity.signPlcOperation.ts +4 -2
- package/src/pages/xrpc/com.atproto.identity.submitPlcOperation.ts +4 -2
- package/src/pages/xrpc/com.atproto.identity.updateHandle.ts +12 -36
- package/src/pages/xrpc/com.atproto.repo.applyWrites.ts +90 -139
- package/src/pages/xrpc/com.atproto.repo.createRecord.ts +74 -47
- package/src/pages/xrpc/com.atproto.repo.deleteRecord.ts +119 -46
- package/src/pages/xrpc/com.atproto.repo.describeRepo.ts +21 -20
- package/src/pages/xrpc/com.atproto.repo.getRecord.ts +6 -1
- package/src/pages/xrpc/com.atproto.repo.listMissingBlobs.ts +4 -2
- package/src/pages/xrpc/com.atproto.repo.putRecord.ts +84 -47
- package/src/pages/xrpc/com.atproto.repo.uploadBlob.ts +199 -78
- package/src/pages/xrpc/com.atproto.server.checkAccountStatus.ts +4 -2
- package/src/pages/xrpc/com.atproto.server.getServiceAuth.ts +88 -21
- package/src/pages/xrpc/com.atproto.server.getSession.ts +3 -13
- package/src/pages/xrpc/com.atproto.sync.getBlob.ts +92 -74
- package/src/pages/xrpc/com.atproto.sync.listBlobs.ts +45 -23
- package/src/services/car.ts +13 -0
- package/src/services/repo/apply-prepared-writes.ts +185 -0
- package/src/services/repo/blob-refs.ts +48 -0
- package/src/services/repo/blockstore-ops.ts +59 -17
- package/src/services/repo/list-blobs.ts +43 -0
- package/src/services/repo-manager.ts +221 -78
- package/src/worker/runtime.ts +1 -1
- package/src/worker/sequencer/upgrade.ts +4 -1
|
@@ -1,15 +1,34 @@
|
|
|
1
1
|
import type { APIContext } from 'astro';
|
|
2
|
-
import {
|
|
2
|
+
import { NSID, ensureValidDid } from '@atproto/syntax';
|
|
3
|
+
import { verifyResourceRequestHybrid, dpopResourceUnauthorized, handleResourceAuthError, insufficientScopeResponse } from '../../lib/oauth/resource';
|
|
3
4
|
import { createServiceAuthToken } from '../../lib/appview';
|
|
5
|
+
import { canMakeRpcCall, type AuthAccessContext } from '../../lib/auth-scope';
|
|
6
|
+
import { PRIVILEGED_METHODS, PROTECTED_METHODS } from '../../lib/appview/auth-policy';
|
|
4
7
|
|
|
5
8
|
export const prerender = false;
|
|
6
9
|
|
|
10
|
+
const METHOD_SCOPED_MAX_EXPIRATION_SECONDS = 60 * 60;
|
|
11
|
+
const METHODLESS_MAX_EXPIRATION_SECONDS = 60;
|
|
12
|
+
|
|
13
|
+
const SERVICE_AUTH_PROTECTED_METHODS: ReadonlySet<string> = new Set([
|
|
14
|
+
...PROTECTED_METHODS,
|
|
15
|
+
'com.atproto.admin.deleteAccount',
|
|
16
|
+
'com.atproto.identity.submitPlcOperation',
|
|
17
|
+
'com.atproto.repo.importRepo',
|
|
18
|
+
'com.atproto.server.deleteAccount',
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
const APP_PASSWORD_DENIED_SERVICE_AUTH_METHODS: ReadonlySet<string> = new Set([
|
|
22
|
+
'com.atproto.server.createAccount',
|
|
23
|
+
]);
|
|
24
|
+
|
|
7
25
|
export async function GET({ locals, request }: APIContext) {
|
|
8
26
|
const { env } = locals.runtime;
|
|
9
|
-
let auth:
|
|
27
|
+
let auth: NonNullable<Awaited<ReturnType<typeof verifyResourceRequestHybrid>>>;
|
|
10
28
|
try {
|
|
11
|
-
|
|
12
|
-
if (!
|
|
29
|
+
const verified = await verifyResourceRequestHybrid(env, request);
|
|
30
|
+
if (!verified) return dpopResourceUnauthorized(env);
|
|
31
|
+
auth = verified;
|
|
13
32
|
} catch (error) {
|
|
14
33
|
const handled = await handleResourceAuthError(env, error);
|
|
15
34
|
if (handled) return handled;
|
|
@@ -23,35 +42,39 @@ export async function GET({ locals, request }: APIContext) {
|
|
|
23
42
|
|
|
24
43
|
const audience = audienceParam?.trim();
|
|
25
44
|
if (!audience) {
|
|
26
|
-
return
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
45
|
+
return jsonError('MissingAudience', undefined, 400);
|
|
46
|
+
}
|
|
47
|
+
if (!isValidServiceAudience(audience)) {
|
|
48
|
+
return jsonError('InvalidRequest', 'aud must be a DID or DID service reference', 400);
|
|
30
49
|
}
|
|
31
50
|
|
|
32
51
|
const lexiconMethod = lexParam && lexParam.trim() !== '' ? lexParam.trim() : null;
|
|
52
|
+
if (lexiconMethod !== null && !NSID.isValid(lexiconMethod)) {
|
|
53
|
+
return jsonError('InvalidRequest', 'lxm must be a valid NSID', 400);
|
|
54
|
+
}
|
|
55
|
+
if (!isAccountActiveForServiceAuth(auth.access)) {
|
|
56
|
+
return jsonError('AccountInactive', 'Account is not active', 403);
|
|
57
|
+
}
|
|
58
|
+
if (!canIssueServiceAuth(auth.access, lexiconMethod, audience)) {
|
|
59
|
+
return insufficientScopeResponse();
|
|
60
|
+
}
|
|
33
61
|
|
|
34
62
|
let expiresIn = 60;
|
|
63
|
+
const maxExpiresIn = lexiconMethod ? METHOD_SCOPED_MAX_EXPIRATION_SECONDS : METHODLESS_MAX_EXPIRATION_SECONDS;
|
|
35
64
|
const now = Math.floor(Date.now() / 1000);
|
|
36
65
|
if (expParam !== null) {
|
|
37
66
|
if (!/^-?\d+$/.test(expParam)) {
|
|
38
|
-
return
|
|
39
|
-
status: 400,
|
|
40
|
-
headers: { 'Content-Type': 'application/json' },
|
|
41
|
-
});
|
|
67
|
+
return jsonError('BadExpiration', 'expiration must be an integer timestamp', 400);
|
|
42
68
|
}
|
|
43
69
|
const exp = Number(expParam);
|
|
70
|
+
if (!Number.isSafeInteger(exp)) {
|
|
71
|
+
return jsonError('BadExpiration', 'expiration must be a safe integer timestamp', 400);
|
|
72
|
+
}
|
|
44
73
|
if (exp <= now) {
|
|
45
|
-
return
|
|
46
|
-
status: 400,
|
|
47
|
-
headers: { 'Content-Type': 'application/json' },
|
|
48
|
-
});
|
|
74
|
+
return jsonError('BadExpiration', 'expiration is in the past', 400);
|
|
49
75
|
}
|
|
50
|
-
if (exp - now >
|
|
51
|
-
return
|
|
52
|
-
status: 400,
|
|
53
|
-
headers: { 'Content-Type': 'application/json' },
|
|
54
|
-
});
|
|
76
|
+
if (exp - now > maxExpiresIn) {
|
|
77
|
+
return jsonError('BadExpiration', 'expiration too far in future', 400);
|
|
55
78
|
}
|
|
56
79
|
expiresIn = Math.max(1, exp - now);
|
|
57
80
|
}
|
|
@@ -69,3 +92,47 @@ export async function GET({ locals, request }: APIContext) {
|
|
|
69
92
|
});
|
|
70
93
|
}
|
|
71
94
|
}
|
|
95
|
+
|
|
96
|
+
function canIssueServiceAuth(
|
|
97
|
+
access: AuthAccessContext,
|
|
98
|
+
lexiconMethod: string | null,
|
|
99
|
+
audience: string,
|
|
100
|
+
): boolean {
|
|
101
|
+
if (lexiconMethod === null) {
|
|
102
|
+
return access.isFullAccess || access.isAppPassword;
|
|
103
|
+
}
|
|
104
|
+
if (SERVICE_AUTH_PROTECTED_METHODS.has(lexiconMethod)) return false;
|
|
105
|
+
if (access.isOAuth) return canMakeRpcCall(access, lexiconMethod, audience);
|
|
106
|
+
if (APP_PASSWORD_DENIED_SERVICE_AUTH_METHODS.has(lexiconMethod)) {
|
|
107
|
+
return access.isFullAccess;
|
|
108
|
+
}
|
|
109
|
+
if (PRIVILEGED_METHODS.has(lexiconMethod)) {
|
|
110
|
+
return access.isPrivileged;
|
|
111
|
+
}
|
|
112
|
+
return canMakeRpcCall(access, lexiconMethod, audience);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function isAccountActiveForServiceAuth(access: AuthAccessContext): boolean {
|
|
116
|
+
if (access.isTakendown || access.isSignupQueued) return false;
|
|
117
|
+
return access.accountStatus === 'active';
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function isValidServiceAudience(audience: string): boolean {
|
|
121
|
+
const parts = audience.split('#');
|
|
122
|
+
if (parts.length > 2) return false;
|
|
123
|
+
const [did, fragment] = parts;
|
|
124
|
+
if (!did) return false;
|
|
125
|
+
try {
|
|
126
|
+
ensureValidDid(did);
|
|
127
|
+
} catch {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
return fragment === undefined || /^[A-Za-z][A-Za-z0-9._:-]*$/.test(fragment);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function jsonError(error: string, message: string | undefined, status: number): Response {
|
|
134
|
+
return new Response(JSON.stringify(message ? { error, message } : { error }), {
|
|
135
|
+
status,
|
|
136
|
+
headers: { 'Content-Type': 'application/json' },
|
|
137
|
+
});
|
|
138
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { APIContext } from 'astro';
|
|
2
2
|
import { authErrorResponse, authenticateRequest, unauthorized } from '../../lib/auth';
|
|
3
3
|
import { getAccountByIdentifier } from '../../db/account';
|
|
4
|
+
import { buildDidDocument } from '../../lib/did-document';
|
|
4
5
|
|
|
5
6
|
export const prerender = false;
|
|
6
7
|
|
|
@@ -27,6 +28,7 @@ export async function GET({ locals, request }: APIContext) {
|
|
|
27
28
|
const did = authContext.claims.sub;
|
|
28
29
|
const account = await getAccountByIdentifier(env, did);
|
|
29
30
|
const handle = account?.handle ?? (env.PDS_HANDLE as string) ?? 'user.example.com';
|
|
31
|
+
const didDoc = await buildDidDocument(env, did, handle);
|
|
30
32
|
|
|
31
33
|
return new Response(
|
|
32
34
|
JSON.stringify({
|
|
@@ -35,19 +37,7 @@ export async function GET({ locals, request }: APIContext) {
|
|
|
35
37
|
email: account?.email ?? (env.PDS_EMAIL as string | undefined) ?? 'user@example.com',
|
|
36
38
|
emailConfirmed: true,
|
|
37
39
|
emailAuthFactor: false,
|
|
38
|
-
didDoc
|
|
39
|
-
'@context': ['https://www.w3.org/ns/did/v1'],
|
|
40
|
-
id: did,
|
|
41
|
-
alsoKnownAs: [`at://${handle}`],
|
|
42
|
-
verificationMethod: [],
|
|
43
|
-
service: [
|
|
44
|
-
{
|
|
45
|
-
id: '#atproto_pds',
|
|
46
|
-
type: 'AtprotoPersonalDataServer',
|
|
47
|
-
serviceEndpoint: `https://${env.PDS_HOSTNAME ?? handle}`,
|
|
48
|
-
},
|
|
49
|
-
],
|
|
50
|
-
},
|
|
40
|
+
didDoc,
|
|
51
41
|
}),
|
|
52
42
|
{
|
|
53
43
|
status: 200,
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import type { APIContext } from 'astro';
|
|
2
|
+
import { ensureValidDid } from '@atproto/syntax';
|
|
3
|
+
import { CID } from 'multiformats/cid';
|
|
2
4
|
import { getDb } from '../../db/client';
|
|
3
5
|
import { blob_ref } from '../../db/schema';
|
|
4
|
-
import { eq } from 'drizzle-orm';
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import { putBlobRef } from '../../db/dal';
|
|
8
|
-
import { isAccountActive } from '../../db/dal';
|
|
6
|
+
import { and, eq } from 'drizzle-orm';
|
|
7
|
+
import { blobKeyHasUsage, isAccountActive } from '../../db/dal';
|
|
8
|
+
import { resolveSecret } from '../../lib/secrets';
|
|
9
9
|
|
|
10
10
|
export const prerender = false;
|
|
11
11
|
|
|
@@ -23,98 +23,50 @@ export async function GET({ locals, url }: APIContext) {
|
|
|
23
23
|
const { env } = locals.runtime;
|
|
24
24
|
|
|
25
25
|
try {
|
|
26
|
-
const configuredDid =
|
|
27
|
-
const did = url.searchParams.get('did')
|
|
26
|
+
const configuredDid = await resolveSecret(env.PDS_DID);
|
|
27
|
+
const did = url.searchParams.get('did');
|
|
28
28
|
const cid = url.searchParams.get('cid');
|
|
29
29
|
if (!did || !cid) {
|
|
30
|
-
return
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
);
|
|
30
|
+
return jsonError('InvalidRequest', 'did and cid parameters are required');
|
|
31
|
+
}
|
|
32
|
+
if (!isValidDid(did) || !isValidCid(cid)) {
|
|
33
|
+
return jsonError('InvalidRequest', 'did and cid parameters must be valid');
|
|
34
|
+
}
|
|
35
|
+
if (!configuredDid || did !== configuredDid) {
|
|
36
|
+
return jsonError('RepoNotFound', 'Repo not found');
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
const active = await isAccountActive(env, did);
|
|
40
40
|
if (!active) {
|
|
41
|
-
return
|
|
42
|
-
JSON.stringify({ error: 'AccountInactive', message: 'Account is not active' }),
|
|
43
|
-
{ status: 403, headers: { 'Content-Type': 'application/json' } }
|
|
44
|
-
);
|
|
41
|
+
return jsonError('RepoDeactivated', 'Repo is deactivated');
|
|
45
42
|
}
|
|
46
43
|
|
|
47
44
|
const db = getDb(env);
|
|
48
45
|
|
|
49
|
-
|
|
50
|
-
let blobMeta = await db
|
|
46
|
+
const blobMeta = await db
|
|
51
47
|
.select()
|
|
52
48
|
.from(blob_ref)
|
|
53
|
-
.where(eq(blob_ref.cid, cid))
|
|
49
|
+
.where(and(eq(blob_ref.did, did), eq(blob_ref.cid, cid)))
|
|
54
50
|
.get();
|
|
55
51
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
let size: number | null = blobMeta?.size ?? null;
|
|
59
|
-
|
|
60
|
-
// Fallback for older uploads: derive R2 key from CID (raw/sha256) if DB row missing
|
|
61
|
-
if (!key) {
|
|
62
|
-
try {
|
|
63
|
-
const link = CID.parse(cid);
|
|
64
|
-
// blob CIDs are raw (0x55) with sha256 multihash
|
|
65
|
-
if (link.multihash.code !== sha256.code) {
|
|
66
|
-
return new Response(
|
|
67
|
-
JSON.stringify({ error: 'InvalidRequest', message: 'Unsupported multihash' }),
|
|
68
|
-
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
|
69
|
-
);
|
|
70
|
-
}
|
|
71
|
-
// Recreate legacy R2 key scheme used by store.put()
|
|
72
|
-
const digest = link.multihash.digest; // Uint8Array
|
|
73
|
-
// base64url encode
|
|
74
|
-
let s = '';
|
|
75
|
-
for (const b of digest) s += String.fromCharCode(b);
|
|
76
|
-
const b64url = btoa(s).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');
|
|
77
|
-
key = `blobs/by-cid/${b64url}`;
|
|
78
|
-
} catch {
|
|
79
|
-
key = null;
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
if (!key) {
|
|
84
|
-
return new Response(
|
|
85
|
-
JSON.stringify({ error: 'InvalidRequest', message: 'Blob not found' }),
|
|
86
|
-
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
|
87
|
-
);
|
|
52
|
+
if (!blobMeta || !(await blobKeyHasUsage(env, did, blobMeta.key))) {
|
|
53
|
+
return blobNotFound();
|
|
88
54
|
}
|
|
89
55
|
|
|
90
56
|
// Fetch blob from R2
|
|
91
57
|
const r2 = env.ALTERAN_BLOBS;
|
|
92
|
-
const object = await r2.get(key);
|
|
58
|
+
const object = await r2.get(blobMeta.key);
|
|
93
59
|
|
|
94
|
-
if (!object) {
|
|
95
|
-
return
|
|
96
|
-
JSON.stringify({ error: 'InvalidRequest', message: 'Blob not found' }),
|
|
97
|
-
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
|
98
|
-
);
|
|
60
|
+
if (!object || object.size !== Number(blobMeta.size)) {
|
|
61
|
+
return blobNotFound();
|
|
99
62
|
}
|
|
100
63
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
size = object.size ?? size ?? 0;
|
|
104
|
-
await putBlobRef(env, did, cid, key, mime, Number(size ?? 0));
|
|
105
|
-
} catch (backfillError) {
|
|
106
|
-
// Backfill is opportunistic; serving the blob is the priority.
|
|
107
|
-
console.warn('getBlob backfill failed:', backfillError);
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// workers-types' ReadableStream lacks the DOM-types readMany member; the
|
|
112
|
-
// shapes are wire-compatible at runtime, so widen through unknown.
|
|
113
|
-
return new Response(object.body as unknown as ReadableStream<Uint8Array>, {
|
|
64
|
+
const body = await responseBody(object);
|
|
65
|
+
return new Response(body, {
|
|
114
66
|
status: 200,
|
|
115
67
|
headers: {
|
|
116
|
-
'Content-Type': mime,
|
|
117
|
-
|
|
68
|
+
'Content-Type': blobMeta.mime,
|
|
69
|
+
'Content-Length': String(blobMeta.size),
|
|
118
70
|
'Cache-Control': 'public, max-age=31536000, immutable',
|
|
119
71
|
'x-content-type-options': 'nosniff',
|
|
120
72
|
'content-security-policy': "default-src 'none'; sandbox",
|
|
@@ -131,3 +83,69 @@ export async function GET({ locals, url }: APIContext) {
|
|
|
131
83
|
);
|
|
132
84
|
}
|
|
133
85
|
}
|
|
86
|
+
|
|
87
|
+
function blobNotFound(): Response {
|
|
88
|
+
return jsonError('BlobNotFound', 'Blob not found');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function jsonError(error: string, message: string, status = 400): Response {
|
|
92
|
+
return new Response(
|
|
93
|
+
JSON.stringify({ error, message }),
|
|
94
|
+
{ status, headers: { 'Content-Type': 'application/json' } },
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function isValidDid(did: string): boolean {
|
|
99
|
+
try {
|
|
100
|
+
ensureValidDid(did);
|
|
101
|
+
return true;
|
|
102
|
+
} catch {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function isValidCid(cid: string): boolean {
|
|
108
|
+
try {
|
|
109
|
+
CID.parse(cid);
|
|
110
|
+
return true;
|
|
111
|
+
} catch {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function responseBody(object: {
|
|
117
|
+
body: unknown;
|
|
118
|
+
arrayBuffer(): Promise<ArrayBuffer>;
|
|
119
|
+
}): Promise<BodyInit> {
|
|
120
|
+
try {
|
|
121
|
+
return localReadableStream(object.body as ReadableStream<Uint8Array>);
|
|
122
|
+
} catch (error) {
|
|
123
|
+
// Miniflare exposes R2 bodies via a workerd object that can't be cloned
|
|
124
|
+
// across the worker boundary; fall back to a buffered read when that happens.
|
|
125
|
+
if (error instanceof Error && error.message.includes('can not be cloned')) {
|
|
126
|
+
return object.arrayBuffer();
|
|
127
|
+
}
|
|
128
|
+
throw error;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function localReadableStream(source: ReadableStream<Uint8Array>): ReadableStream<Uint8Array> {
|
|
133
|
+
const reader = source.getReader();
|
|
134
|
+
return new ReadableStream<Uint8Array>({
|
|
135
|
+
async pull(controller) {
|
|
136
|
+
try {
|
|
137
|
+
const chunk = await reader.read();
|
|
138
|
+
if (chunk.done) {
|
|
139
|
+
controller.close();
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
controller.enqueue(new Uint8Array(chunk.value));
|
|
143
|
+
} catch (error) {
|
|
144
|
+
controller.error(error);
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
cancel(reason) {
|
|
148
|
+
return reader.cancel(reason);
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { APIContext } from 'astro';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
2
|
+
import { ensureValidDid, isValidTid } from '@atproto/syntax';
|
|
3
|
+
import { isAccountActive } from '../../db/dal';
|
|
4
|
+
import { listBlobCids } from '../../services/repo/list-blobs';
|
|
5
|
+
import { resolveSecret } from '../../lib/secrets';
|
|
5
6
|
|
|
6
7
|
export const prerender = false;
|
|
7
8
|
|
|
@@ -12,31 +13,29 @@ export const prerender = false;
|
|
|
12
13
|
export async function GET({ locals, url }: APIContext) {
|
|
13
14
|
const { env } = locals.runtime;
|
|
14
15
|
|
|
15
|
-
const
|
|
16
|
-
const
|
|
17
|
-
const
|
|
16
|
+
const configuredDid = await resolveSecret(env.PDS_DID);
|
|
17
|
+
const did = url.searchParams.get('did') ?? undefined;
|
|
18
|
+
const since = url.searchParams.get('since') ?? undefined;
|
|
19
|
+
const cursor = url.searchParams.get('cursor') ?? undefined;
|
|
20
|
+
const limit = parseLimit(url.searchParams.get('limit'));
|
|
21
|
+
if (!did) return jsonError('InvalidRequest', 'did is required');
|
|
22
|
+
if (!isValidDid(did)) return jsonError('InvalidRequest', 'did must be valid');
|
|
23
|
+
if (!limit) return jsonError('InvalidRequest', 'limit must be an integer between 1 and 1000');
|
|
24
|
+
if (since && !isValidTid(since)) return jsonError('InvalidRequest', 'since must be a valid TID');
|
|
25
|
+
if (!configuredDid || did !== configuredDid) return jsonError('RepoNotFound', 'Repo not found');
|
|
18
26
|
|
|
19
27
|
try {
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
.where(and(eq(blob_ref.did, did), gt(blob_ref.cid, since)))
|
|
27
|
-
.limit(limit)
|
|
28
|
-
.all()
|
|
29
|
-
: await db
|
|
30
|
-
.select()
|
|
31
|
-
.from(blob_ref)
|
|
32
|
-
.where(eq(blob_ref.did, did))
|
|
33
|
-
.limit(limit)
|
|
34
|
-
.all();
|
|
28
|
+
const active = await isAccountActive(env, did);
|
|
29
|
+
if (!active) {
|
|
30
|
+
return jsonError('RepoDeactivated', 'Repo is deactivated');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const blobs = await listBlobCids(env, { did, since, cursor, limit });
|
|
35
34
|
|
|
36
35
|
return new Response(
|
|
37
36
|
JSON.stringify({
|
|
38
|
-
cids: blobs.
|
|
39
|
-
|
|
37
|
+
cids: blobs.cids,
|
|
38
|
+
...(blobs.cursor ? { cursor: blobs.cursor } : {}),
|
|
40
39
|
}),
|
|
41
40
|
{
|
|
42
41
|
status: 200,
|
|
@@ -51,3 +50,26 @@ export async function GET({ locals, url }: APIContext) {
|
|
|
51
50
|
);
|
|
52
51
|
}
|
|
53
52
|
}
|
|
53
|
+
|
|
54
|
+
function parseLimit(value: string | null): number | null {
|
|
55
|
+
if (value === null || value === '') return 500;
|
|
56
|
+
if (!/^\d+$/.test(value)) return null;
|
|
57
|
+
const limit = Number(value);
|
|
58
|
+
return Number.isInteger(limit) && limit >= 1 && limit <= 1000 ? limit : null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function jsonError(error: string, message: string): Response {
|
|
62
|
+
return new Response(
|
|
63
|
+
JSON.stringify({ error, message }),
|
|
64
|
+
{ status: 400, headers: { 'Content-Type': 'application/json' } },
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function isValidDid(did: string): boolean {
|
|
69
|
+
try {
|
|
70
|
+
ensureValidDid(did);
|
|
71
|
+
return true;
|
|
72
|
+
} catch {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
}
|
package/src/services/car.ts
CHANGED
|
@@ -174,10 +174,15 @@ export async function encodeBlocksForCommit(
|
|
|
174
174
|
mstRoot: CID,
|
|
175
175
|
ops: Array<{ path: string; cid: CID | null }>,
|
|
176
176
|
newMstBlocks?: Array<[CID, Uint8Array]>,
|
|
177
|
+
commitBlock?: Uint8Array,
|
|
178
|
+
newRecordBlocks?: Array<readonly [CID, Uint8Array]>,
|
|
177
179
|
): Promise<Uint8Array> {
|
|
178
180
|
const blockstore = new D1Blockstore(env);
|
|
179
181
|
const blocks: { cid: CID; bytes: Uint8Array }[] = [];
|
|
180
182
|
const seen = new Set<string>();
|
|
183
|
+
const stagedRecordBlocks = new Map(
|
|
184
|
+
(newRecordBlocks ?? []).map(([cid, bytes]) => [cid.toString(), bytes]),
|
|
185
|
+
);
|
|
181
186
|
|
|
182
187
|
// Helper to add block if not already seen
|
|
183
188
|
const addBlock = async (cid: CID) => {
|
|
@@ -185,6 +190,14 @@ export async function encodeBlocksForCommit(
|
|
|
185
190
|
if (seen.has(cidStr)) return;
|
|
186
191
|
seen.add(cidStr);
|
|
187
192
|
let bytes = await blockstore.get(cid);
|
|
193
|
+
if (!bytes) {
|
|
194
|
+
bytes = stagedRecordBlocks.get(cidStr) ?? null;
|
|
195
|
+
}
|
|
196
|
+
if (!bytes) {
|
|
197
|
+
if (cidStr === commitCid.toString() && commitBlock) {
|
|
198
|
+
bytes = commitBlock;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
188
201
|
if (!bytes) {
|
|
189
202
|
// Attempt to reconstruct commit block from commit_log if this is the commit cid
|
|
190
203
|
if (cidStr === commitCid.toString()) {
|