@alteran/astro 0.1.7 → 0.1.9
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 +38 -3
- package/migrations/0005_odd_bishop.sql +18 -0
- package/migrations/meta/0005_snapshot.json +429 -0
- package/migrations/meta/_journal.json +7 -0
- package/package.json +3 -2
- package/src/db/dal.ts +33 -0
- package/src/db/schema.ts +7 -0
- package/src/lib/blob-refs.ts +99 -0
- package/src/lib/jwt.ts +18 -5
- package/src/lib/secrets.ts +95 -0
- package/src/pages/xrpc/com.atproto.identity.getRecommendedDidCredentials.ts +99 -0
- package/src/pages/xrpc/com.atproto.repo.applyWrites.ts +20 -0
- package/src/pages/xrpc/com.atproto.repo.importRepo.ts +142 -0
- package/src/pages/xrpc/com.atproto.repo.listMissingBlobs.ts +88 -0
- package/src/pages/xrpc/com.atproto.repo.uploadBlob.ts +16 -4
- package/src/pages/xrpc/com.atproto.server.activateAccount.ts +53 -0
- package/src/pages/xrpc/com.atproto.server.checkAccountStatus.ts +92 -0
- package/src/pages/xrpc/com.atproto.server.createAccount.ts +79 -0
- package/src/pages/xrpc/com.atproto.server.deactivateAccount.ts +53 -0
- package/src/pages/xrpc/com.atproto.sync.getBlob.ts +84 -0
- package/src/worker/runtime.ts +11 -6
- package/types/env.d.ts +19 -8
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Blob Reference Extraction Utilities
|
|
3
|
+
*
|
|
4
|
+
* Provides functions to extract blob CIDs from AT Protocol records.
|
|
5
|
+
* Used during migration and for blob usage tracking.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Extract all blob CIDs from a record object
|
|
10
|
+
*
|
|
11
|
+
* @param obj - The record object to scan
|
|
12
|
+
* @returns Set of blob CIDs found in the record
|
|
13
|
+
*/
|
|
14
|
+
export function extractBlobRefs(obj: any): Set<string> {
|
|
15
|
+
const refs = new Set<string>();
|
|
16
|
+
extractBlobRefsRecursive(obj, refs);
|
|
17
|
+
return refs;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Recursively extract blob CIDs from an object
|
|
22
|
+
*/
|
|
23
|
+
function extractBlobRefsRecursive(obj: any, refs: Set<string>): void {
|
|
24
|
+
if (!obj || typeof obj !== 'object') return;
|
|
25
|
+
|
|
26
|
+
// Check for blob reference pattern ($type: 'blob')
|
|
27
|
+
if (obj.$type === 'blob' && obj.ref) {
|
|
28
|
+
if (typeof obj.ref === 'object' && obj.ref.$link) {
|
|
29
|
+
refs.add(obj.ref.$link);
|
|
30
|
+
} else if (typeof obj.ref === 'string') {
|
|
31
|
+
refs.add(obj.ref);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Check for direct CID link pattern
|
|
36
|
+
if (obj.$link && typeof obj.$link === 'string') {
|
|
37
|
+
// Only add if it looks like a blob CID (not a record CID)
|
|
38
|
+
// Blob CIDs typically start with specific multihash prefixes
|
|
39
|
+
refs.add(obj.$link);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Check for legacy blob patterns
|
|
43
|
+
if (obj.cid && typeof obj.cid === 'string') {
|
|
44
|
+
refs.add(obj.cid);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Recurse into nested objects and arrays
|
|
48
|
+
if (Array.isArray(obj)) {
|
|
49
|
+
for (const item of obj) {
|
|
50
|
+
extractBlobRefsRecursive(item, refs);
|
|
51
|
+
}
|
|
52
|
+
} else {
|
|
53
|
+
for (const value of Object.values(obj)) {
|
|
54
|
+
extractBlobRefsRecursive(value, refs);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Extract blob references from known Bluesky record types
|
|
61
|
+
* This is more specific than the generic extractor and handles
|
|
62
|
+
* common patterns in app.bsky.* records.
|
|
63
|
+
*/
|
|
64
|
+
export function extractBskyBlobRefs(record: any): Set<string> {
|
|
65
|
+
const refs = new Set<string>();
|
|
66
|
+
|
|
67
|
+
// Handle app.bsky.feed.post embeds
|
|
68
|
+
if (record.embed) {
|
|
69
|
+
extractBlobRefsRecursive(record.embed, refs);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Handle app.bsky.actor.profile avatar and banner
|
|
73
|
+
if (record.avatar) {
|
|
74
|
+
extractBlobRefsRecursive(record.avatar, refs);
|
|
75
|
+
}
|
|
76
|
+
if (record.banner) {
|
|
77
|
+
extractBlobRefsRecursive(record.banner, refs);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Handle app.bsky.feed.generator avatar
|
|
81
|
+
if (record.$type === 'app.bsky.feed.generator' && record.avatar) {
|
|
82
|
+
extractBlobRefsRecursive(record.avatar, refs);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Fallback to generic extraction for any other patterns
|
|
86
|
+
extractBlobRefsRecursive(record, refs);
|
|
87
|
+
|
|
88
|
+
return refs;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Convert blob references to R2 keys
|
|
93
|
+
*
|
|
94
|
+
* @param cids - Set of blob CIDs
|
|
95
|
+
* @returns Array of R2 keys
|
|
96
|
+
*/
|
|
97
|
+
export function blobCidsToKeys(cids: Set<string>): string[] {
|
|
98
|
+
return Array.from(cids).map(cid => `blobs/by-cid/${cid}`);
|
|
99
|
+
}
|
package/src/lib/jwt.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Env } from '../env';
|
|
2
|
+
import { getRuntimeString } from './secrets';
|
|
2
3
|
|
|
3
4
|
export interface JwtClaims {
|
|
4
5
|
sub: string; // DID
|
|
@@ -31,7 +32,14 @@ export async function signJwt(env: Env, claims: JwtClaims, kind: 'access' | 'ref
|
|
|
31
32
|
if (claims.scope) payload.scope = claims.scope;
|
|
32
33
|
if (claims.jti) payload.jti = claims.jti;
|
|
33
34
|
|
|
34
|
-
const secret =
|
|
35
|
+
const secret = await getRuntimeString(
|
|
36
|
+
env,
|
|
37
|
+
kind === 'access' ? 'ACCESS_TOKEN_SECRET' : 'REFRESH_TOKEN_SECRET',
|
|
38
|
+
kind === 'access' ? 'dev-access' : 'dev-refresh'
|
|
39
|
+
);
|
|
40
|
+
if (!secret) {
|
|
41
|
+
throw new Error(`Missing ${kind === 'access' ? 'ACCESS_TOKEN_SECRET' : 'REFRESH_TOKEN_SECRET'}`);
|
|
42
|
+
}
|
|
35
43
|
const algorithm = (env.JWT_ALGORITHM as string | undefined) ?? 'HS256';
|
|
36
44
|
|
|
37
45
|
if (algorithm === 'EdDSA') {
|
|
@@ -50,7 +58,12 @@ export async function verifyJwt(env: Env, token: string): Promise<{ valid: boole
|
|
|
50
58
|
|
|
51
59
|
let ok = false;
|
|
52
60
|
if (header.alg === 'HS256' && header.typ === 'JWT') {
|
|
53
|
-
const secret =
|
|
61
|
+
const secret = await getRuntimeString(
|
|
62
|
+
env,
|
|
63
|
+
payload.t === 'refresh' ? 'REFRESH_TOKEN_SECRET' : 'ACCESS_TOKEN_SECRET',
|
|
64
|
+
payload.t === 'refresh' ? 'dev-refresh' : 'dev-access'
|
|
65
|
+
);
|
|
66
|
+
if (!secret) return null;
|
|
54
67
|
ok = await hmacJwtVerify(parts[0] + '.' + parts[1], parts[2], secret);
|
|
55
68
|
} else if (header.alg === 'EdDSA' && header.typ === 'JWT') {
|
|
56
69
|
ok = await eddsaJwtVerify(parts[0] + '.' + parts[1], parts[2], env);
|
|
@@ -90,9 +103,9 @@ async function eddsaJwtSign(payload: any, env: Env): Promise<string> {
|
|
|
90
103
|
const data = `${h}.${p}`;
|
|
91
104
|
|
|
92
105
|
// Import Ed25519 private key from env
|
|
93
|
-
const keyData = env
|
|
106
|
+
const keyData = await getRuntimeString(env, 'REPO_SIGNING_KEY');
|
|
94
107
|
if (!keyData) {
|
|
95
|
-
throw new Error('
|
|
108
|
+
throw new Error('REPO_SIGNING_KEY not configured for EdDSA JWTs');
|
|
96
109
|
}
|
|
97
110
|
|
|
98
111
|
// Decode base64 private key
|
|
@@ -114,7 +127,7 @@ async function eddsaJwtVerify(data: string, sigB64: string, env: Env): Promise<b
|
|
|
114
127
|
const enc = new TextEncoder();
|
|
115
128
|
|
|
116
129
|
// Import Ed25519 public key from env
|
|
117
|
-
const keyData = env
|
|
130
|
+
const keyData = await getRuntimeString(env, 'REPO_SIGNING_PUBLIC_KEY');
|
|
118
131
|
if (!keyData) {
|
|
119
132
|
return false;
|
|
120
133
|
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { setGetEnv } from 'astro/env/setup';
|
|
2
|
+
import type { Env } from '../env';
|
|
3
|
+
import type { SecretsStoreSecret } from '../../types/env';
|
|
4
|
+
|
|
5
|
+
const SECRET_KEYS = [
|
|
6
|
+
'PDS_DID',
|
|
7
|
+
'PDS_HANDLE',
|
|
8
|
+
'USER_PASSWORD',
|
|
9
|
+
'ACCESS_TOKEN_SECRET',
|
|
10
|
+
'REFRESH_TOKEN_SECRET',
|
|
11
|
+
'REPO_SIGNING_KEY',
|
|
12
|
+
'REPO_SIGNING_PUBLIC_KEY',
|
|
13
|
+
] as const satisfies readonly (keyof Env)[];
|
|
14
|
+
|
|
15
|
+
function isSecretStoreBinding(value: unknown): value is SecretsStoreSecret {
|
|
16
|
+
return !!value && typeof value === 'object' && typeof (value as any).get === 'function';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function resolveSecret(
|
|
20
|
+
value: string | SecretsStoreSecret | undefined
|
|
21
|
+
): Promise<string | undefined> {
|
|
22
|
+
if (value === undefined) return undefined;
|
|
23
|
+
if (typeof value === 'string') return value;
|
|
24
|
+
if (isSecretStoreBinding(value)) return value.get();
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Return a shallow-cloned Env where all known secret fields are materialized to strings.
|
|
30
|
+
* Non-secret bindings (DB, BLOBS, SEQUENCER, vars) are preserved as-is.
|
|
31
|
+
*/
|
|
32
|
+
export async function resolveEnvSecrets<E extends Env>(env: E): Promise<E> {
|
|
33
|
+
const resolved: Record<string, unknown> = { ...env };
|
|
34
|
+
|
|
35
|
+
await Promise.all(
|
|
36
|
+
SECRET_KEYS.map(async (key) => {
|
|
37
|
+
const val = await resolveSecret((env as any)[key]);
|
|
38
|
+
if (val !== undefined) {
|
|
39
|
+
resolved[key as string] = val;
|
|
40
|
+
}
|
|
41
|
+
})
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
setGetEnv((key) => {
|
|
45
|
+
const local = resolved[key];
|
|
46
|
+
if (typeof local === 'string') return local;
|
|
47
|
+
if (typeof local === 'number' || typeof local === 'boolean') return String(local);
|
|
48
|
+
const fallback = process.env[key];
|
|
49
|
+
return typeof fallback === 'string' ? fallback : undefined;
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
return resolved as E;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
type AstroGetSecret = (key: string) => string | undefined;
|
|
56
|
+
|
|
57
|
+
let astroGetSecret: AstroGetSecret | null | undefined;
|
|
58
|
+
|
|
59
|
+
async function loadAstroGetSecret(): Promise<AstroGetSecret | null> {
|
|
60
|
+
if (astroGetSecret !== undefined) return astroGetSecret;
|
|
61
|
+
try {
|
|
62
|
+
const mod = await import('astro:env/server');
|
|
63
|
+
astroGetSecret = mod.getSecret as AstroGetSecret;
|
|
64
|
+
} catch {
|
|
65
|
+
astroGetSecret = null;
|
|
66
|
+
}
|
|
67
|
+
return astroGetSecret;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function getRuntimeString<K extends keyof Env>(
|
|
71
|
+
env: Env,
|
|
72
|
+
key: K,
|
|
73
|
+
fallback?: string
|
|
74
|
+
): Promise<string | undefined> {
|
|
75
|
+
const current = env[key];
|
|
76
|
+
if (typeof current === 'string' && current !== '') {
|
|
77
|
+
return current;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const secretFn = await loadAstroGetSecret();
|
|
81
|
+
if (secretFn) {
|
|
82
|
+
try {
|
|
83
|
+
const value = secretFn(String(key));
|
|
84
|
+
if (typeof value === 'string' && value !== '') {
|
|
85
|
+
return value;
|
|
86
|
+
}
|
|
87
|
+
} catch (error) {
|
|
88
|
+
if (fallback === undefined) {
|
|
89
|
+
throw error;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return fallback;
|
|
95
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { APIContext } from 'astro';
|
|
2
|
+
import { isAuthorized, unauthorized } from '../../lib/auth';
|
|
3
|
+
|
|
4
|
+
export const prerender = false;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* com.atproto.identity.getRecommendedDidCredentials
|
|
8
|
+
*
|
|
9
|
+
* Returns recommended DID credentials for this PDS.
|
|
10
|
+
* Used during migration to update identity documents.
|
|
11
|
+
*/
|
|
12
|
+
export async function GET({ locals, request }: APIContext) {
|
|
13
|
+
const { env } = locals.runtime;
|
|
14
|
+
|
|
15
|
+
if (!(await isAuthorized(request, env))) return unauthorized();
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
const did = env.PDS_DID ?? 'did:example:single-user';
|
|
19
|
+
const handle = env.PDS_HANDLE ?? 'example.com';
|
|
20
|
+
const hostname = env.PDS_HOSTNAME ?? handle;
|
|
21
|
+
|
|
22
|
+
// Get signing key if available
|
|
23
|
+
let signingKey: string | undefined;
|
|
24
|
+
if (env.REPO_SIGNING_PUBLIC_KEY) {
|
|
25
|
+
// Convert raw public key to multibase format
|
|
26
|
+
const pubKeyStr = String(env.REPO_SIGNING_PUBLIC_KEY);
|
|
27
|
+
const pubKeyBytes = Uint8Array.from(atob(pubKeyStr), c => c.charCodeAt(0));
|
|
28
|
+
|
|
29
|
+
// Ed25519 multicodec prefix (0xed01) + public key
|
|
30
|
+
const multicodecBytes = new Uint8Array(2 + pubKeyBytes.length);
|
|
31
|
+
multicodecBytes[0] = 0xed;
|
|
32
|
+
multicodecBytes[1] = 0x01;
|
|
33
|
+
multicodecBytes.set(pubKeyBytes, 2);
|
|
34
|
+
|
|
35
|
+
// Base58 encode with 'z' prefix for multibase
|
|
36
|
+
signingKey = 'z' + base58Encode(multicodecBytes);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return new Response(
|
|
40
|
+
JSON.stringify({
|
|
41
|
+
did,
|
|
42
|
+
handle,
|
|
43
|
+
pds: `https://${hostname}`,
|
|
44
|
+
signingKey,
|
|
45
|
+
alsoKnownAs: [`at://${handle}`],
|
|
46
|
+
verificationMethods: signingKey ? {
|
|
47
|
+
atproto: signingKey
|
|
48
|
+
} : undefined,
|
|
49
|
+
services: {
|
|
50
|
+
atproto_pds: {
|
|
51
|
+
type: 'AtprotoPersonalDataServer',
|
|
52
|
+
endpoint: `https://${hostname}`
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}),
|
|
56
|
+
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
|
57
|
+
);
|
|
58
|
+
} catch (error: any) {
|
|
59
|
+
return new Response(
|
|
60
|
+
JSON.stringify({
|
|
61
|
+
error: 'InternalServerError',
|
|
62
|
+
message: error.message || 'Failed to get DID credentials'
|
|
63
|
+
}),
|
|
64
|
+
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Base58 encode (Bitcoin alphabet)
|
|
71
|
+
*/
|
|
72
|
+
function base58Encode(bytes: Uint8Array): string {
|
|
73
|
+
const ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
|
|
74
|
+
|
|
75
|
+
// Convert bytes to bigint
|
|
76
|
+
let num = 0n;
|
|
77
|
+
for (const byte of bytes) {
|
|
78
|
+
num = num * 256n + BigInt(byte);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Convert to base58
|
|
82
|
+
let result = '';
|
|
83
|
+
while (num > 0n) {
|
|
84
|
+
const remainder = Number(num % 58n);
|
|
85
|
+
result = ALPHABET[remainder] + result;
|
|
86
|
+
num = num / 58n;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Add leading '1's for leading zero bytes
|
|
90
|
+
for (const byte of bytes) {
|
|
91
|
+
if (byte === 0) {
|
|
92
|
+
result = '1' + result;
|
|
93
|
+
} else {
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return result;
|
|
99
|
+
}
|
|
@@ -2,6 +2,9 @@ import type { APIContext } from 'astro';
|
|
|
2
2
|
import { RepoManager } from '../../services/repo-manager';
|
|
3
3
|
import { readJson } from '../../lib/util';
|
|
4
4
|
import { bumpRoot } from '../../db/repo';
|
|
5
|
+
import { isAuthorized, unauthorized } from '../../lib/auth';
|
|
6
|
+
import { isAccountActive } from '../../db/dal';
|
|
7
|
+
import { checkRate } from '../../lib/ratelimit';
|
|
5
8
|
|
|
6
9
|
export const prerender = false;
|
|
7
10
|
|
|
@@ -11,6 +14,23 @@ export const prerender = false;
|
|
|
11
14
|
*/
|
|
12
15
|
export async function POST({ locals, request }: APIContext) {
|
|
13
16
|
const { env } = locals.runtime;
|
|
17
|
+
if (!(await isAuthorized(request, env))) return unauthorized();
|
|
18
|
+
|
|
19
|
+
// Check if account is active
|
|
20
|
+
const did = env.PDS_DID ?? 'did:example:single-user';
|
|
21
|
+
const active = await isAccountActive(env, did);
|
|
22
|
+
if (!active) {
|
|
23
|
+
return new Response(
|
|
24
|
+
JSON.stringify({
|
|
25
|
+
error: 'AccountDeactivated',
|
|
26
|
+
message: 'Account is deactivated. Activate it before making changes.'
|
|
27
|
+
}),
|
|
28
|
+
{ status: 403, headers: { 'Content-Type': 'application/json' } }
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const rateLimitResponse = await checkRate(env, request, 'writes');
|
|
33
|
+
if (rateLimitResponse) return rateLimitResponse;
|
|
14
34
|
|
|
15
35
|
try {
|
|
16
36
|
const body = await readJson(request);
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import type { APIContext } from 'astro';
|
|
2
|
+
import { isAuthorized, unauthorized } from '../../lib/auth';
|
|
3
|
+
import { parseCarFile } from '../../lib/car-reader';
|
|
4
|
+
import { D1Blockstore } from '../../lib/mst';
|
|
5
|
+
import { getDb } from '../../db/client';
|
|
6
|
+
import { repo_root, commit_log } from '../../db/schema';
|
|
7
|
+
import { putRecord } from '../../db/dal';
|
|
8
|
+
import * as dagCbor from '@ipld/dag-cbor';
|
|
9
|
+
import { CID } from 'multiformats/cid';
|
|
10
|
+
|
|
11
|
+
export const prerender = false;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* com.atproto.repo.importRepo
|
|
15
|
+
*
|
|
16
|
+
* Imports a repository from a CAR (Content Addressable aRchive) file.
|
|
17
|
+
* This is used during account migration to transfer the complete repo history.
|
|
18
|
+
*/
|
|
19
|
+
export async function POST({ locals, request }: APIContext) {
|
|
20
|
+
const { env } = locals.runtime;
|
|
21
|
+
|
|
22
|
+
if (!(await isAuthorized(request, env))) return unauthorized();
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
const contentType = request.headers.get('content-type');
|
|
26
|
+
if (contentType !== 'application/vnd.ipld.car') {
|
|
27
|
+
return new Response(
|
|
28
|
+
JSON.stringify({
|
|
29
|
+
error: 'InvalidRequest',
|
|
30
|
+
message: 'Content-Type must be application/vnd.ipld.car'
|
|
31
|
+
}),
|
|
32
|
+
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const did = env.PDS_DID ?? 'did:example:single-user';
|
|
37
|
+
const carBytes = new Uint8Array(await request.arrayBuffer());
|
|
38
|
+
|
|
39
|
+
// Parse CAR file
|
|
40
|
+
const { header, blocks } = parseCarFile(carBytes);
|
|
41
|
+
|
|
42
|
+
if (blocks.length === 0) {
|
|
43
|
+
return new Response(
|
|
44
|
+
JSON.stringify({
|
|
45
|
+
error: 'InvalidRequest',
|
|
46
|
+
message: 'CAR file contains no blocks'
|
|
47
|
+
}),
|
|
48
|
+
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Store all blocks in blockstore
|
|
53
|
+
const blockstore = new D1Blockstore(env);
|
|
54
|
+
for (const block of blocks) {
|
|
55
|
+
await blockstore.put(block.cid, block.bytes);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Find the commit block (root of the CAR)
|
|
59
|
+
const rootCid = header.roots[0];
|
|
60
|
+
if (!rootCid) {
|
|
61
|
+
return new Response(
|
|
62
|
+
JSON.stringify({
|
|
63
|
+
error: 'InvalidRequest',
|
|
64
|
+
message: 'CAR file has no root CID'
|
|
65
|
+
}),
|
|
66
|
+
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Decode the commit to get repo details
|
|
71
|
+
const commitBlock = blocks.find(b => b.cid.equals(rootCid));
|
|
72
|
+
if (!commitBlock) {
|
|
73
|
+
return new Response(
|
|
74
|
+
JSON.stringify({
|
|
75
|
+
error: 'InvalidRequest',
|
|
76
|
+
message: 'Root commit block not found in CAR'
|
|
77
|
+
}),
|
|
78
|
+
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const commit = dagCbor.decode(commitBlock.bytes) as any;
|
|
83
|
+
const rev = commit.rev || commit.version || 1;
|
|
84
|
+
|
|
85
|
+
// Update repo root
|
|
86
|
+
const db = getDb(env);
|
|
87
|
+
await db
|
|
88
|
+
.insert(repo_root)
|
|
89
|
+
.values({
|
|
90
|
+
did,
|
|
91
|
+
commitCid: rootCid.toString(),
|
|
92
|
+
rev: typeof rev === 'string' ? parseInt(rev) : rev,
|
|
93
|
+
})
|
|
94
|
+
.onConflictDoUpdate({
|
|
95
|
+
target: repo_root.did,
|
|
96
|
+
set: {
|
|
97
|
+
commitCid: rootCid.toString(),
|
|
98
|
+
rev: typeof rev === 'string' ? parseInt(rev) : rev,
|
|
99
|
+
},
|
|
100
|
+
})
|
|
101
|
+
.run();
|
|
102
|
+
|
|
103
|
+
// Index records from MST
|
|
104
|
+
// Note: This is a simplified implementation
|
|
105
|
+
// A full implementation would walk the MST tree and index all records
|
|
106
|
+
let recordCount = 0;
|
|
107
|
+
for (const block of blocks) {
|
|
108
|
+
try {
|
|
109
|
+
const obj = dagCbor.decode(block.bytes) as any;
|
|
110
|
+
|
|
111
|
+
// Check if this looks like a record (has $type)
|
|
112
|
+
if (obj && typeof obj === 'object' && obj.$type) {
|
|
113
|
+
// This is a record, we should index it
|
|
114
|
+
// For now, we'll skip detailed indexing and let it be done lazily
|
|
115
|
+
recordCount++;
|
|
116
|
+
}
|
|
117
|
+
} catch {
|
|
118
|
+
// Not a valid CBOR object or not a record, skip
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return new Response(
|
|
123
|
+
JSON.stringify({
|
|
124
|
+
did,
|
|
125
|
+
commitCid: rootCid.toString(),
|
|
126
|
+
rev,
|
|
127
|
+
blocksImported: blocks.length,
|
|
128
|
+
recordsFound: recordCount,
|
|
129
|
+
message: 'Repository imported successfully'
|
|
130
|
+
}),
|
|
131
|
+
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
|
132
|
+
);
|
|
133
|
+
} catch (error: any) {
|
|
134
|
+
return new Response(
|
|
135
|
+
JSON.stringify({
|
|
136
|
+
error: 'InternalServerError',
|
|
137
|
+
message: error.message || 'Failed to import repository'
|
|
138
|
+
}),
|
|
139
|
+
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { APIContext } from 'astro';
|
|
2
|
+
import { isAuthorized, unauthorized } from '../../lib/auth';
|
|
3
|
+
import { getDb } from '../../db/client';
|
|
4
|
+
import { record, blob_ref } from '../../db/schema';
|
|
5
|
+
import { eq } from 'drizzle-orm';
|
|
6
|
+
import { extractBlobRefs } from '../../lib/blob-refs';
|
|
7
|
+
|
|
8
|
+
export const prerender = false;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* com.atproto.repo.listMissingBlobs
|
|
12
|
+
*
|
|
13
|
+
* Lists blob CIDs that are referenced in records but not present in blob storage.
|
|
14
|
+
* Used during migration to identify which blobs need to be transferred.
|
|
15
|
+
*/
|
|
16
|
+
export async function GET({ locals, request, url }: APIContext) {
|
|
17
|
+
const { env } = locals.runtime;
|
|
18
|
+
|
|
19
|
+
if (!(await isAuthorized(request, env))) return unauthorized();
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
const did = env.PDS_DID ?? 'did:example:single-user';
|
|
23
|
+
const limit = parseInt(url.searchParams.get('limit') || '500');
|
|
24
|
+
const cursor = url.searchParams.get('cursor') || '';
|
|
25
|
+
|
|
26
|
+
const db = getDb(env);
|
|
27
|
+
|
|
28
|
+
// Get all records for this DID
|
|
29
|
+
const records = await db
|
|
30
|
+
.select()
|
|
31
|
+
.from(record)
|
|
32
|
+
.where(eq(record.did, did))
|
|
33
|
+
.all();
|
|
34
|
+
|
|
35
|
+
// Get all blob refs for this DID
|
|
36
|
+
const blobs = await db
|
|
37
|
+
.select()
|
|
38
|
+
.from(blob_ref)
|
|
39
|
+
.where(eq(blob_ref.did, did))
|
|
40
|
+
.all();
|
|
41
|
+
|
|
42
|
+
// Create a set of existing blob CIDs
|
|
43
|
+
const existingBlobCids = new Set(blobs.map(b => b.cid));
|
|
44
|
+
|
|
45
|
+
// Extract blob references from records
|
|
46
|
+
const referencedBlobs = new Set<string>();
|
|
47
|
+
for (const rec of records) {
|
|
48
|
+
try {
|
|
49
|
+
const data = JSON.parse(rec.json);
|
|
50
|
+
const refs = extractBlobRefs(data);
|
|
51
|
+
refs.forEach(ref => referencedBlobs.add(ref));
|
|
52
|
+
} catch {
|
|
53
|
+
// Skip invalid JSON
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Find missing blobs
|
|
58
|
+
const missingBlobs: string[] = [];
|
|
59
|
+
for (const cid of referencedBlobs) {
|
|
60
|
+
if (!existingBlobCids.has(cid)) {
|
|
61
|
+
if (!cursor || cid > cursor) {
|
|
62
|
+
missingBlobs.push(cid);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Sort and limit
|
|
68
|
+
missingBlobs.sort();
|
|
69
|
+
const page = missingBlobs.slice(0, limit);
|
|
70
|
+
const nextCursor = page.length === limit ? page[page.length - 1] : undefined;
|
|
71
|
+
|
|
72
|
+
return new Response(
|
|
73
|
+
JSON.stringify({
|
|
74
|
+
blobs: page.map(cid => ({ cid })),
|
|
75
|
+
cursor: nextCursor,
|
|
76
|
+
}),
|
|
77
|
+
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
|
78
|
+
);
|
|
79
|
+
} catch (error: any) {
|
|
80
|
+
return new Response(
|
|
81
|
+
JSON.stringify({
|
|
82
|
+
error: 'InternalServerError',
|
|
83
|
+
message: error.message || 'Failed to list missing blobs'
|
|
84
|
+
}),
|
|
85
|
+
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -3,7 +3,7 @@ import { isAuthorized, unauthorized } from '../../lib/auth';
|
|
|
3
3
|
import { checkRate } from '../../lib/ratelimit';
|
|
4
4
|
import { isAllowedMime } from '../../lib/util';
|
|
5
5
|
import { R2BlobStore } from '../../services/r2-blob-store';
|
|
6
|
-
import { putBlobRef, checkBlobQuota, updateBlobQuota } from '../../db/dal';
|
|
6
|
+
import { putBlobRef, checkBlobQuota, updateBlobQuota, isAccountActive } from '../../db/dal';
|
|
7
7
|
|
|
8
8
|
export const prerender = false;
|
|
9
9
|
|
|
@@ -11,6 +11,21 @@ export async function POST({ locals, request }: APIContext) {
|
|
|
11
11
|
const { env } = locals.runtime;
|
|
12
12
|
if (!(await isAuthorized(request, env))) return unauthorized();
|
|
13
13
|
|
|
14
|
+
// Get DID from environment (single-user PDS)
|
|
15
|
+
const did = env.PDS_DID ?? 'did:example:single-user';
|
|
16
|
+
|
|
17
|
+
// Check if account is active
|
|
18
|
+
const active = await isAccountActive(env, did);
|
|
19
|
+
if (!active) {
|
|
20
|
+
return new Response(
|
|
21
|
+
JSON.stringify({
|
|
22
|
+
error: 'AccountDeactivated',
|
|
23
|
+
message: 'Account is deactivated. Activate it before uploading blobs.'
|
|
24
|
+
}),
|
|
25
|
+
{ status: 403, headers: { 'Content-Type': 'application/json' } }
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
14
29
|
const rateLimitResponse = await checkRate(env, request, 'blob');
|
|
15
30
|
if (rateLimitResponse) return rateLimitResponse;
|
|
16
31
|
|
|
@@ -18,9 +33,6 @@ export async function POST({ locals, request }: APIContext) {
|
|
|
18
33
|
const contentType = request.headers.get('content-type') ?? 'application/octet-stream';
|
|
19
34
|
if (!isAllowedMime(env, contentType)) return new Response(JSON.stringify({ error: 'UnsupportedMediaType' }), { status: 415 });
|
|
20
35
|
|
|
21
|
-
// Get DID from environment (single-user PDS)
|
|
22
|
-
const did = env.PDS_DID ?? 'did:example:single-user';
|
|
23
|
-
|
|
24
36
|
// Check quota before upload
|
|
25
37
|
const canUpload = await checkBlobQuota(env, did, buf.byteLength);
|
|
26
38
|
if (!canUpload) {
|