@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,5 +1,11 @@
|
|
|
1
1
|
import type { APIContext } from 'astro';
|
|
2
|
-
import {
|
|
2
|
+
import { buildDidDocument } from '../../lib/did-document';
|
|
3
|
+
import {
|
|
4
|
+
configuredDid,
|
|
5
|
+
configuredHandle,
|
|
6
|
+
didDocClaimsHandle,
|
|
7
|
+
handleResolvesToDid,
|
|
8
|
+
} from '../../lib/public-host';
|
|
3
9
|
|
|
4
10
|
export const prerender = false;
|
|
5
11
|
|
|
@@ -10,30 +16,25 @@ export const prerender = false;
|
|
|
10
16
|
export async function GET({ locals, url }: APIContext) {
|
|
11
17
|
const { env } = locals.runtime;
|
|
12
18
|
|
|
13
|
-
const repo = url.searchParams.get('repo')
|
|
14
|
-
const did = env
|
|
15
|
-
const handle = env
|
|
19
|
+
const repo = url.searchParams.get('repo');
|
|
20
|
+
const did = await configuredDid(env);
|
|
21
|
+
const handle = await configuredHandle(env);
|
|
22
|
+
if (repo && repo !== did && repo.toLowerCase() !== handle.toLowerCase()) {
|
|
23
|
+
return new Response(
|
|
24
|
+
JSON.stringify({ error: 'NotFound', message: 'Repo not found' }),
|
|
25
|
+
{ status: 404, headers: { 'Content-Type': 'application/json' } },
|
|
26
|
+
);
|
|
27
|
+
}
|
|
16
28
|
|
|
17
|
-
|
|
18
|
-
const
|
|
29
|
+
const didDoc = await buildDidDocument(env, did, handle);
|
|
30
|
+
const handleIsCorrect = didDocClaimsHandle(didDoc, handle) &&
|
|
31
|
+
await handleResolvesToDid(env, handle, did);
|
|
19
32
|
|
|
20
33
|
return new Response(
|
|
21
34
|
JSON.stringify({
|
|
22
35
|
did,
|
|
23
36
|
handle,
|
|
24
|
-
didDoc
|
|
25
|
-
'@context': ['https://www.w3.org/ns/did/v1'],
|
|
26
|
-
id: did,
|
|
27
|
-
alsoKnownAs: [`at://${handle}`],
|
|
28
|
-
verificationMethod: [],
|
|
29
|
-
service: [
|
|
30
|
-
{
|
|
31
|
-
id: '#atproto_pds',
|
|
32
|
-
type: 'AtprotoPersonalDataServer',
|
|
33
|
-
serviceEndpoint: `https://${handle}`,
|
|
34
|
-
},
|
|
35
|
-
],
|
|
36
|
-
},
|
|
37
|
+
didDoc,
|
|
37
38
|
collections: [
|
|
38
39
|
'app.bsky.feed.post',
|
|
39
40
|
'app.bsky.feed.like',
|
|
@@ -41,7 +42,7 @@ export async function GET({ locals, url }: APIContext) {
|
|
|
41
42
|
'app.bsky.graph.follow',
|
|
42
43
|
'app.bsky.actor.profile',
|
|
43
44
|
],
|
|
44
|
-
handleIsCorrect
|
|
45
|
+
handleIsCorrect,
|
|
45
46
|
}),
|
|
46
47
|
{
|
|
47
48
|
status: 200,
|
|
@@ -30,7 +30,12 @@ export async function GET({ locals, request }: APIContext) {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
const row = await dalGetRecord(env, uri);
|
|
33
|
-
if (!row)
|
|
33
|
+
if (!row) {
|
|
34
|
+
return new Response(
|
|
35
|
+
JSON.stringify({ error: 'RecordNotFound', message: `Could not locate record: ${uri}` }),
|
|
36
|
+
{ status: 400, headers: { 'Content-Type': 'application/json' } },
|
|
37
|
+
);
|
|
38
|
+
}
|
|
34
39
|
|
|
35
40
|
return new Response(JSON.stringify({ uri: row.uri, cid: row.cid, value: JSON.parse(row.json) }), {
|
|
36
41
|
headers: { 'Content-Type': 'application/json' },
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { APIContext } from 'astro';
|
|
2
2
|
import { errorMessage } from '../../lib/errors';
|
|
3
|
-
import { authErrorResponse,
|
|
3
|
+
import { authErrorResponse, authenticateRequest, unauthorized } from '../../lib/auth';
|
|
4
|
+
import { canUseAppPasswordLevelAccess } from '../../lib/auth-scope';
|
|
4
5
|
import { getDb } from '../../db/client';
|
|
5
6
|
import { record, blob_ref } from '../../db/schema';
|
|
6
7
|
import { eq } from 'drizzle-orm';
|
|
@@ -18,7 +19,8 @@ export async function GET({ locals, request, url }: APIContext) {
|
|
|
18
19
|
const { env } = locals.runtime;
|
|
19
20
|
|
|
20
21
|
try {
|
|
21
|
-
|
|
22
|
+
const auth = await authenticateRequest(request, env);
|
|
23
|
+
if (!auth || !canUseAppPasswordLevelAccess(auth.access)) return unauthorized();
|
|
22
24
|
} catch (error) {
|
|
23
25
|
const handled = await authErrorResponse(env, error);
|
|
24
26
|
if (handled) return handled;
|
|
@@ -1,72 +1,109 @@
|
|
|
1
1
|
import type { APIContext } from 'astro';
|
|
2
|
-
import { errorCode
|
|
3
|
-
import { verifyResourceRequestHybrid, dpopResourceUnauthorized, handleResourceAuthError } from '../../lib/oauth/resource';
|
|
2
|
+
import { errorCode } from '../../lib/errors';
|
|
3
|
+
import { verifyResourceRequestHybrid, dpopResourceUnauthorized, handleResourceAuthError, insufficientScopeResponse } from '../../lib/oauth/resource';
|
|
4
|
+
import { canWriteRepo } from '../../lib/auth-scope';
|
|
4
5
|
import { checkRate } from '../../lib/ratelimit';
|
|
5
6
|
import { readJsonBounded } from '../../lib/util';
|
|
6
|
-
import { RepoManager } from '../../services/repo-manager';
|
|
7
7
|
import { notifySequencer } from '../../lib/sequencer';
|
|
8
|
+
import { deleteUnreferencedBlobKeys, isAccountActive, sweepEligibleUnreferencedBlobKeys } from '../../db/dal';
|
|
9
|
+
import {
|
|
10
|
+
assertRepoWriteInput,
|
|
11
|
+
handleRepoWriteError,
|
|
12
|
+
jsonError,
|
|
13
|
+
preparePutRecord,
|
|
14
|
+
putRecordAuthorizations,
|
|
15
|
+
RepoWriteError,
|
|
16
|
+
retryNoSwapCommit,
|
|
17
|
+
} from '../../lib/repo-write-validation';
|
|
8
18
|
|
|
9
19
|
export const prerender = false;
|
|
10
20
|
|
|
11
21
|
export async function POST({ locals, request }: APIContext) {
|
|
12
|
-
const { env } = locals.runtime;
|
|
22
|
+
const { env, ctx } = locals.runtime;
|
|
23
|
+
let auth: NonNullable<Awaited<ReturnType<typeof verifyResourceRequestHybrid>>>;
|
|
13
24
|
try {
|
|
14
|
-
const
|
|
15
|
-
if (!
|
|
25
|
+
const verified = await verifyResourceRequestHybrid(env, request);
|
|
26
|
+
if (!verified) return dpopResourceUnauthorized(env);
|
|
27
|
+
auth = verified;
|
|
16
28
|
} catch (error) {
|
|
17
29
|
const handled = await handleResourceAuthError(env, error);
|
|
18
30
|
if (handled) return handled;
|
|
19
31
|
throw error;
|
|
20
32
|
}
|
|
21
33
|
|
|
22
|
-
|
|
23
|
-
if (rateLimitResponse) return rateLimitResponse;
|
|
24
|
-
|
|
25
|
-
let body: any;
|
|
34
|
+
let body: unknown;
|
|
26
35
|
try {
|
|
27
36
|
body = await readJsonBounded(env, request);
|
|
28
|
-
} catch (
|
|
29
|
-
|
|
30
|
-
|
|
37
|
+
} catch (error) {
|
|
38
|
+
const rateLimitResponse = await checkRate(env, request, 'writes', { key: auth.did });
|
|
39
|
+
if (rateLimitResponse) return rateLimitResponse;
|
|
40
|
+
if (errorCode(error) === 'PayloadTooLarge') {
|
|
41
|
+
return jsonError('PayloadTooLarge', undefined, 413);
|
|
31
42
|
}
|
|
32
|
-
return
|
|
43
|
+
return jsonError('BadRequest');
|
|
33
44
|
}
|
|
34
|
-
const { collection, rkey } = body ?? {};
|
|
35
|
-
let { record } = body ?? {};
|
|
36
|
-
if (!collection || !rkey || !record) return new Response(JSON.stringify({ error: 'BadRequest' }), { status: 400 });
|
|
37
45
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
record.createdAt = new Date().toISOString();
|
|
46
|
+
let writeRateCharged = false;
|
|
47
|
+
try {
|
|
48
|
+
const input = assertRepoWriteInput('com.atproto.repo.putRecord', body);
|
|
49
|
+
for (const write of putRecordAuthorizations(input)) {
|
|
50
|
+
if (!canWriteRepo(auth.access, write.collection, write.action)) return insufficientScopeResponse();
|
|
44
51
|
}
|
|
45
|
-
}
|
|
46
52
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
did: env.PDS_DID as string,
|
|
51
|
-
commitCid: result.commitCid,
|
|
52
|
-
rev: result.rev,
|
|
53
|
-
data: result.commitData,
|
|
54
|
-
sig: result.sig,
|
|
55
|
-
ops: result.ops,
|
|
56
|
-
blocks: result.blocks
|
|
57
|
-
});
|
|
53
|
+
const rateLimitResponse = await checkRate(env, request, 'writes', { key: auth.did });
|
|
54
|
+
writeRateCharged = true;
|
|
55
|
+
if (rateLimitResponse) return rateLimitResponse;
|
|
58
56
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
commit: {
|
|
63
|
-
cid: result.commitCid,
|
|
64
|
-
rev: result.rev,
|
|
65
|
-
},
|
|
66
|
-
validationStatus: 'unknown' as const,
|
|
67
|
-
};
|
|
57
|
+
if (!(await isAccountActive(env, auth.did))) {
|
|
58
|
+
return jsonError('AccountDeactivated', 'Account is deactivated. Activate it before making changes.', 403);
|
|
59
|
+
}
|
|
68
60
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
61
|
+
const { prepared, result } = await retryNoSwapCommit(input, async () => {
|
|
62
|
+
const prepared = await preparePutRecord(env, auth, input);
|
|
63
|
+
const { write, repo } = prepared;
|
|
64
|
+
const result = await repo.putRecord(
|
|
65
|
+
write.collection,
|
|
66
|
+
write.rkey,
|
|
67
|
+
write.record,
|
|
68
|
+
write.blobKeys,
|
|
69
|
+
prepared.expectedCommitCid,
|
|
70
|
+
);
|
|
71
|
+
return { prepared, result };
|
|
72
|
+
});
|
|
73
|
+
if (result.commitCid && result.rev && result.commitData && result.sig && result.blocks) {
|
|
74
|
+
await notifySequencer(env, {
|
|
75
|
+
did: prepared.did,
|
|
76
|
+
commitCid: result.commitCid,
|
|
77
|
+
rev: result.rev,
|
|
78
|
+
data: result.commitData,
|
|
79
|
+
sig: result.sig,
|
|
80
|
+
ops: result.ops,
|
|
81
|
+
blocks: result.blocks
|
|
82
|
+
});
|
|
83
|
+
await deleteUnreferencedBlobKeys(env, result.dereferencedBlobKeys).catch((error) => {
|
|
84
|
+
console.warn('[putRecord] Failed to clean dereferenced blobs:', error);
|
|
85
|
+
});
|
|
86
|
+
ctx?.waitUntil(sweepEligibleUnreferencedBlobKeys(env).catch((error) => {
|
|
87
|
+
console.warn('[putRecord] Failed to sweep dereferenced blobs:', error);
|
|
88
|
+
}));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return new Response(JSON.stringify({
|
|
92
|
+
uri: result.uri,
|
|
93
|
+
cid: result.cid,
|
|
94
|
+
...(result.commitCid && result.rev ? { commit: {
|
|
95
|
+
cid: result.commitCid,
|
|
96
|
+
rev: result.rev,
|
|
97
|
+
} } : {}),
|
|
98
|
+
validationStatus: prepared.write.validationStatus,
|
|
99
|
+
}), {
|
|
100
|
+
headers: { 'Content-Type': 'application/json' },
|
|
101
|
+
});
|
|
102
|
+
} catch (error) {
|
|
103
|
+
if (!writeRateCharged && error instanceof RepoWriteError) {
|
|
104
|
+
const rateLimitResponse = await checkRate(env, request, 'writes', { key: auth.did });
|
|
105
|
+
if (rateLimitResponse) return rateLimitResponse;
|
|
106
|
+
}
|
|
107
|
+
return handleRepoWriteError(error);
|
|
108
|
+
}
|
|
72
109
|
}
|
|
@@ -1,52 +1,48 @@
|
|
|
1
1
|
import type { APIContext } from 'astro';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import type { Env } from '../../env';
|
|
3
|
+
import { PayloadTooLarge } from '../../lib/errors';
|
|
4
|
+
import {
|
|
5
|
+
verifyResourceRequestHybrid,
|
|
6
|
+
dpopResourceUnauthorized,
|
|
7
|
+
handleResourceAuthError,
|
|
8
|
+
insufficientScopeResponse,
|
|
9
|
+
type ResourceAuthContext,
|
|
10
|
+
} from '../../lib/oauth/resource';
|
|
11
|
+
import { canUploadBlob } from '../../lib/auth-scope';
|
|
4
12
|
import { verifyServiceAuth, isServiceAuthToken } from '../../lib/service-auth';
|
|
5
13
|
import { checkRate } from '../../lib/ratelimit';
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
|
|
14
|
+
import { sniffMime, baseMime, readBodyBounded, readStreamBounded } from '../../lib/util';
|
|
15
|
+
import {
|
|
16
|
+
deleteUnreferencedBlobRef,
|
|
17
|
+
isAccountActive,
|
|
18
|
+
registerBlobRefWithQuota,
|
|
19
|
+
sweepEligibleUnreferencedBlobKeys,
|
|
20
|
+
} from '../../db/dal';
|
|
9
21
|
import { resolveSecret } from '../../lib/secrets';
|
|
10
22
|
import { CID } from 'multiformats/cid';
|
|
11
23
|
import { sha256 } from 'multiformats/hashes/sha2';
|
|
24
|
+
import { jsonError } from '../../lib/repo-write-error';
|
|
12
25
|
|
|
13
26
|
export const prerender = false;
|
|
14
27
|
|
|
28
|
+
const UPLOAD_BLOB_LXM = 'com.atproto.repo.uploadBlob';
|
|
29
|
+
|
|
30
|
+
type UploadAuth =
|
|
31
|
+
| { tag: 'service' }
|
|
32
|
+
| { tag: 'user'; auth: ResourceAuthContext };
|
|
33
|
+
|
|
15
34
|
export async function POST({ locals, request }: APIContext) {
|
|
16
35
|
const { env } = locals.runtime;
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7).trim() : null;
|
|
21
|
-
|
|
22
|
-
let isServiceAuth = false;
|
|
23
|
-
if (token && isServiceAuthToken(token)) {
|
|
24
|
-
const serviceAuth = await verifyServiceAuth(env, request);
|
|
25
|
-
if (serviceAuth) {
|
|
26
|
-
isServiceAuth = true;
|
|
27
|
-
} else {
|
|
28
|
-
return new Response(
|
|
29
|
-
JSON.stringify({ error: 'AuthRequired', message: 'Invalid service auth token' }),
|
|
30
|
-
{ status: 401, headers: { 'Content-Type': 'application/json' } }
|
|
31
|
-
);
|
|
32
|
-
}
|
|
36
|
+
const did = await resolveSecret(env.PDS_DID);
|
|
37
|
+
if (!did) {
|
|
38
|
+
return jsonError('InternalServerError', 'PDS_DID is not configured', 500);
|
|
33
39
|
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
if (
|
|
37
|
-
|
|
38
|
-
const auth = await verifyResourceRequestHybrid(env, request);
|
|
39
|
-
if (!auth) return dpopResourceUnauthorized(env);
|
|
40
|
-
} catch (error) {
|
|
41
|
-
const handled = await handleResourceAuthError(env, error);
|
|
42
|
-
if (handled) return handled;
|
|
43
|
-
throw error;
|
|
44
|
-
}
|
|
40
|
+
const uploadAuth = await authenticateUpload(env, request, did);
|
|
41
|
+
if (uploadAuth instanceof Response) return uploadAuth;
|
|
42
|
+
if (uploadAuth.tag === 'user' && uploadAuth.auth.did !== did) {
|
|
43
|
+
return jsonError('InvalidRequest', 'authenticated user does not own this repo', 400);
|
|
45
44
|
}
|
|
46
45
|
|
|
47
|
-
// Get DID from environment (single-user PDS)
|
|
48
|
-
const did = (await resolveSecret(env.PDS_DID)) ?? 'did:example:single-user';
|
|
49
|
-
|
|
50
46
|
// Check if account is active
|
|
51
47
|
const active = await isAccountActive(env, did);
|
|
52
48
|
if (!active) {
|
|
@@ -59,72 +55,197 @@ export async function POST({ locals, request }: APIContext) {
|
|
|
59
55
|
);
|
|
60
56
|
}
|
|
61
57
|
|
|
62
|
-
const rateLimitResponse = await checkRate(env, request, 'blob');
|
|
58
|
+
const rateLimitResponse = await checkRate(env, request, 'blob', { key: did });
|
|
63
59
|
if (rateLimitResponse) return rateLimitResponse;
|
|
64
60
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
// Fallback to raw body if decompression not supported
|
|
76
|
-
buf = await request.arrayBuffer();
|
|
61
|
+
const encoding = uploadEncoding(request);
|
|
62
|
+
if (!isSupportedUploadEncoding(encoding)) {
|
|
63
|
+
return jsonError('InvalidRequest', `unsupported content-encoding: ${encoding}`, 400);
|
|
64
|
+
}
|
|
65
|
+
let bytes: Uint8Array;
|
|
66
|
+
try {
|
|
67
|
+
bytes = await readUploadBytes(request, maxBlobBytes(env), encoding);
|
|
68
|
+
} catch (error) {
|
|
69
|
+
if (error instanceof PayloadTooLarge) {
|
|
70
|
+
return jsonError('PayloadTooLarge', 'blob exceeds maximum size', 413);
|
|
77
71
|
}
|
|
78
|
-
|
|
79
|
-
buf = await request.arrayBuffer();
|
|
72
|
+
return jsonError('InvalidRequest', 'failed to read upload body', 400);
|
|
80
73
|
}
|
|
74
|
+
const buf = toArrayBuffer(bytes);
|
|
81
75
|
const headerMime = baseMime(request.headers.get('content-type'));
|
|
82
76
|
const sniffed = sniffMime(buf);
|
|
83
|
-
|
|
84
|
-
|
|
77
|
+
const contentType = resolveUploadMime(headerMime, sniffed);
|
|
78
|
+
if (uploadAuth.tag === 'user' && !canUploadBlob(uploadAuth.auth.access, contentType)) {
|
|
79
|
+
return insufficientScopeResponse();
|
|
80
|
+
}
|
|
85
81
|
|
|
86
82
|
// Skip MIME type validation during migration - accept all types
|
|
87
83
|
// Uncomment to enforce: if (!isAllowedMime(env, contentType)) return new Response(JSON.stringify({ error: 'UnsupportedMediaType' }), { status: 415 });
|
|
88
84
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
return new Response(
|
|
93
|
-
JSON.stringify({
|
|
94
|
-
error: 'BlobQuotaExceeded',
|
|
95
|
-
message: 'Blob storage quota exceeded'
|
|
96
|
-
}),
|
|
97
|
-
{ status: 413 }
|
|
98
|
-
);
|
|
99
|
-
}
|
|
85
|
+
await sweepEligibleUnreferencedBlobKeys(env, { did, limit: 20 }).catch((error) => {
|
|
86
|
+
console.warn('[uploadBlob] Failed to sweep dereferenced blobs:', error);
|
|
87
|
+
});
|
|
100
88
|
|
|
101
|
-
|
|
89
|
+
let registeredCid: string | undefined;
|
|
102
90
|
try {
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
// Compute a CIDv1 (raw) for the blob so clients receive a valid CID link
|
|
106
|
-
const digest = await sha256.digest(new Uint8Array(buf));
|
|
107
|
-
const cid = CID.createV1(0x55, digest); // 0x55 = raw codec
|
|
108
|
-
const cidStr = cid.toString();
|
|
91
|
+
const identity = await blobIdentity(env, buf);
|
|
109
92
|
|
|
110
|
-
|
|
111
|
-
|
|
93
|
+
const registration = await registerBlobRefWithQuota(
|
|
94
|
+
env,
|
|
95
|
+
did,
|
|
96
|
+
identity.cid,
|
|
97
|
+
identity.key,
|
|
98
|
+
contentType,
|
|
99
|
+
identity.size,
|
|
100
|
+
);
|
|
101
|
+
if (registration.tag === 'quotaExceeded') {
|
|
102
|
+
return new Response(
|
|
103
|
+
JSON.stringify({
|
|
104
|
+
error: 'BlobQuotaExceeded',
|
|
105
|
+
message: 'Blob storage quota exceeded',
|
|
106
|
+
}),
|
|
107
|
+
{ status: 413, headers: { 'Content-Type': 'application/json' } },
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
if (registration.tag === 'registered') registeredCid = identity.cid;
|
|
112
111
|
|
|
113
|
-
|
|
114
|
-
await updateBlobQuota(env, did, response.size, 1);
|
|
112
|
+
await ensureBlobObject(env, registration.blob.key, buf, registration.blob.mime, registration.blob.size);
|
|
115
113
|
|
|
116
114
|
// Mirror upstream shape exactly; helpful debugging header
|
|
117
115
|
// Conform to lexicon: blob object must include $type: 'blob'
|
|
118
|
-
const body = {
|
|
116
|
+
const body = {
|
|
117
|
+
blob: {
|
|
118
|
+
$type: 'blob',
|
|
119
|
+
ref: { $link: registration.blob.cid },
|
|
120
|
+
mimeType: registration.blob.mime,
|
|
121
|
+
size: registration.blob.size,
|
|
122
|
+
},
|
|
123
|
+
};
|
|
119
124
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
|
120
125
|
// Debug-only headers (safe for clients to ignore)
|
|
121
126
|
headers['x-sniffed-mime'] = sniffed || '';
|
|
122
127
|
headers['x-header-mime'] = headerMime;
|
|
123
|
-
if (
|
|
128
|
+
if (encoding) headers['x-upload-encoding'] = encoding;
|
|
124
129
|
|
|
125
130
|
return new Response(JSON.stringify(body), { headers });
|
|
126
|
-
} catch (
|
|
127
|
-
if (
|
|
131
|
+
} catch (error) {
|
|
132
|
+
if (registeredCid) await deleteUnreferencedBlobRef(env, did, registeredCid).catch(() => undefined);
|
|
133
|
+
if (error instanceof PayloadTooLarge) {
|
|
134
|
+
return jsonError('PayloadTooLarge', 'blob exceeds maximum size', 413);
|
|
135
|
+
}
|
|
128
136
|
return new Response(JSON.stringify({ error: 'UploadFailed' }), { status: 500 });
|
|
129
137
|
}
|
|
130
138
|
}
|
|
139
|
+
|
|
140
|
+
async function authenticateUpload(env: Env, request: Request, did: string): Promise<UploadAuth | Response> {
|
|
141
|
+
const authHeader = request.headers.get('authorization');
|
|
142
|
+
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7).trim() : null;
|
|
143
|
+
|
|
144
|
+
if (token && isServiceAuthToken(token)) {
|
|
145
|
+
const serviceAuth = await verifyServiceAuth(env, request);
|
|
146
|
+
if (!serviceAuth || serviceAuth.lxm !== UPLOAD_BLOB_LXM || serviceAuth.iss !== did) {
|
|
147
|
+
return jsonError('InvalidToken', 'Invalid service auth token', 401);
|
|
148
|
+
}
|
|
149
|
+
return { tag: 'service' };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
const auth = await verifyResourceRequestHybrid(env, request);
|
|
154
|
+
if (!auth) return dpopResourceUnauthorized(env);
|
|
155
|
+
return { tag: 'user', auth };
|
|
156
|
+
} catch (error) {
|
|
157
|
+
const handled = await handleResourceAuthError(env, error);
|
|
158
|
+
if (handled) return handled;
|
|
159
|
+
throw error;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function readUploadBytes(
|
|
164
|
+
request: Request,
|
|
165
|
+
maxBytes: number,
|
|
166
|
+
encoding: string,
|
|
167
|
+
): Promise<Uint8Array> {
|
|
168
|
+
if (!isCompressedEncoding(encoding)) {
|
|
169
|
+
return readBodyBounded(request, maxBytes);
|
|
170
|
+
}
|
|
171
|
+
const decompressed = request.body?.pipeThrough(createDecompressionStream(encoding));
|
|
172
|
+
return readStreamBounded(decompressed ?? null, maxBytes);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function uploadEncoding(request: Request): string {
|
|
176
|
+
return (request.headers.get('content-encoding') || '').trim().toLowerCase();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function isCompressedEncoding(encoding: string): boolean {
|
|
180
|
+
return encoding === 'gzip' || encoding === 'deflate' || encoding === 'deflate-raw';
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function isSupportedUploadEncoding(encoding: string): boolean {
|
|
184
|
+
return encoding === '' || isCompressedEncoding(encoding);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function resolveUploadMime(headerMime: string, sniffed: string | null): string {
|
|
188
|
+
if (!sniffed) return headerMime;
|
|
189
|
+
if (sniffed === 'video/webm' && (headerMime === 'audio/webm' || headerMime === 'video/webm')) {
|
|
190
|
+
return headerMime;
|
|
191
|
+
}
|
|
192
|
+
if (sniffed === 'video/mp4' && (headerMime === 'audio/mp4' || headerMime === 'video/mp4')) {
|
|
193
|
+
return headerMime;
|
|
194
|
+
}
|
|
195
|
+
return sniffed;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function createDecompressionStream(encoding: string): TransformStream<Uint8Array, Uint8Array> {
|
|
199
|
+
const Decompression = globalThis.DecompressionStream as unknown as {
|
|
200
|
+
new (format: string): TransformStream<Uint8Array, Uint8Array>;
|
|
201
|
+
};
|
|
202
|
+
return new Decompression(encoding);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
|
|
206
|
+
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
type BlobIdentity = {
|
|
210
|
+
cid: string;
|
|
211
|
+
key: string;
|
|
212
|
+
size: number;
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
async function blobIdentity(env: Env, body: ArrayBuffer): Promise<BlobIdentity> {
|
|
216
|
+
const size = body.byteLength;
|
|
217
|
+
const limit = maxBlobBytes(env);
|
|
218
|
+
if (size > limit) throw new PayloadTooLarge('blob exceeds maximum size');
|
|
219
|
+
|
|
220
|
+
const digest = await sha256.digest(new Uint8Array(body));
|
|
221
|
+
const cid = CID.createV1(0x55, digest);
|
|
222
|
+
return {
|
|
223
|
+
cid: cid.toString(),
|
|
224
|
+
key: `blobs/by-cid/${base64Url(digest.digest)}`,
|
|
225
|
+
size,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function ensureBlobObject(
|
|
230
|
+
env: Env,
|
|
231
|
+
key: string,
|
|
232
|
+
body: ArrayBuffer,
|
|
233
|
+
contentType: string,
|
|
234
|
+
expectedSize: number,
|
|
235
|
+
): Promise<void> {
|
|
236
|
+
const existing = await env.ALTERAN_BLOBS.head(key);
|
|
237
|
+
if (existing?.size === expectedSize) return;
|
|
238
|
+
await env.ALTERAN_BLOBS.put(key, body, { httpMetadata: { contentType } });
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function maxBlobBytes(env: Env, defaultMax = 5 * 1024 * 1024): number {
|
|
242
|
+
const raw = env.PDS_MAX_BLOB_SIZE;
|
|
243
|
+
const parsed = raw ? Number(raw) : defaultMax;
|
|
244
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : defaultMax;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function base64Url(bytes: Uint8Array): string {
|
|
248
|
+
let value = '';
|
|
249
|
+
for (const byte of bytes) value += String.fromCharCode(byte);
|
|
250
|
+
return btoa(value).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');
|
|
251
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { APIContext } from 'astro';
|
|
2
2
|
import { errorMessage } from '../../lib/errors';
|
|
3
|
-
import { authErrorResponse,
|
|
3
|
+
import { authErrorResponse, authenticateRequest, unauthorized } from '../../lib/auth';
|
|
4
|
+
import { canAccessFullAccount } from '../../lib/auth-scope';
|
|
4
5
|
import { getAccountState } from '../../db/dal';
|
|
5
6
|
import { toWireStatus } from '../../lib/account-state';
|
|
6
7
|
import { getDb } from '../../db/client';
|
|
@@ -23,7 +24,8 @@ export async function GET({ locals, request }: APIContext) {
|
|
|
23
24
|
const { env } = locals.runtime;
|
|
24
25
|
|
|
25
26
|
try {
|
|
26
|
-
|
|
27
|
+
const auth = await authenticateRequest(request, env);
|
|
28
|
+
if (!auth || !canAccessFullAccount(auth.access)) return unauthorized();
|
|
27
29
|
} catch (error) {
|
|
28
30
|
const handled = await authErrorResponse(env, error);
|
|
29
31
|
if (handled) return handled;
|