atproto user agency toolkit for individuals and groups
1import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
2import { join } from "node:path";
3import { create as createCid, CODEC_RAW, toString as cidToString } from "@atcute/cid";
4
5export interface BlobRef {
6 $type: "blob";
7 ref: { $link: string };
8 mimeType: string;
9 size: number;
10}
11
12export interface BlobResult {
13 bytes: Uint8Array;
14 mimeType: string;
15 size: number;
16}
17
18/**
19 * BlobStore manages blob storage on the local filesystem.
20 * Blobs are stored with CID-based filenames under DATA_DIR/blobs/{did}/.
21 */
22export class BlobStore {
23 private blobDir: string;
24
25 constructor(dataDir: string, private did: string) {
26 this.blobDir = join(dataDir, "blobs", did);
27 mkdirSync(this.blobDir, { recursive: true });
28 }
29
30 /**
31 * Upload a blob and return a BlobRef.
32 */
33 async putBlob(bytes: Uint8Array, mimeType: string): Promise<BlobRef> {
34 const cidObj = await createCid(CODEC_RAW, bytes);
35 const cidStr = cidToString(cidObj);
36
37 const blobPath = join(this.blobDir, cidStr);
38 const metaPath = join(this.blobDir, `${cidStr}.meta`);
39
40 writeFileSync(blobPath, bytes);
41 writeFileSync(
42 metaPath,
43 JSON.stringify({ mimeType, size: bytes.length }),
44 );
45
46 return {
47 $type: "blob",
48 ref: { $link: cidStr },
49 mimeType,
50 size: bytes.length,
51 };
52 }
53
54 /**
55 * Retrieve a blob by CID string.
56 */
57 getBlob(cid: string): BlobResult | null {
58 const blobPath = join(this.blobDir, cid);
59 const metaPath = join(this.blobDir, `${cid}.meta`);
60
61 if (!existsSync(blobPath)) return null;
62
63 const bytes = new Uint8Array(readFileSync(blobPath));
64 let mimeType = "application/octet-stream";
65
66 if (existsSync(metaPath)) {
67 try {
68 const meta = JSON.parse(readFileSync(metaPath, "utf-8"));
69 mimeType = meta.mimeType || mimeType;
70 } catch {
71 // Ignore corrupt metadata
72 }
73 }
74
75 return { bytes, mimeType, size: bytes.length };
76 }
77
78 /**
79 * Check if a blob exists.
80 */
81 hasBlob(cid: string): boolean {
82 return existsSync(join(this.blobDir, cid));
83 }
84
85 /**
86 * List all blob CIDs (for listBlobs endpoint).
87 */
88 listBlobs(limit: number = 500, cursor?: string): { cids: string[]; cursor?: string } {
89 const { readdirSync } = require("node:fs") as typeof import("node:fs");
90 const entries = readdirSync(this.blobDir)
91 .filter((name: string) => !name.endsWith(".meta"))
92 .sort();
93
94 let startIdx = 0;
95 if (cursor) {
96 const idx = entries.indexOf(cursor);
97 startIdx = idx >= 0 ? idx + 1 : 0;
98 }
99
100 const slice = entries.slice(startIdx, startIdx + limit + 1);
101 const hasMore = slice.length > limit;
102 const cids = hasMore ? slice.slice(0, limit) : slice;
103 const nextCursor = hasMore ? cids[cids.length - 1] : undefined;
104
105 return { cids, cursor: nextCursor };
106 }
107}