Keep using Photos.app like you always do. Attic quietly backs up your originals and edits to an S3 bucket you control. One-way, append-only.
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

Fix smoke test issues: PhotoKit UUID format, bucket name, Deno permissions

- Append /L0/001 suffix to UUIDs for PhotoKit local identifiers, strip on return
- Change default bucket from photo-cloud-originals to photo-cloud-storage
- Use Deno.readFile instead of ReadableStream for S3 uploads (SDK signing limitation)
- Broaden Deno permission flags for AWS SDK compatibility (--allow-env, --allow-sys)
- Handle -- separator in CLI flag parsing

+43 -42
+3 -3
README.md
··· 74 74 | `--limit N` | Back up at most N assets | 75 75 | `--batch-size N` | Assets per ladder export batch (default: 50) | 76 76 | `--type photo\|video` | Only back up photos or videos | 77 - | `--bucket NAME` | S3 bucket name (default: `photo-cloud-originals`) | 77 + | `--bucket NAME` | S3 bucket name (default: `photo-cloud-storage`) | 78 78 | `--ladder PATH` | Path to the ladder binary | 79 79 | `--db PATH` | Path to Photos.sqlite | 80 80 ··· 90 90 |---|---| 91 91 | `--deep` | Download each object and re-verify SHA-256 checksum (slow) | 92 92 | `--rebuild-manifest` | Reconstruct the local manifest from S3 metadata files | 93 - | `--bucket NAME` | S3 bucket name (default: `photo-cloud-originals`) | 93 + | `--bucket NAME` | S3 bucket name (default: `photo-cloud-storage`) | 94 94 95 95 ## S3 Bucket Structure 96 96 97 97 ``` 98 - photo-cloud-originals/ 98 + photo-cloud-storage/ 99 99 originals/ 100 100 2024/ 101 101 01/
+8 -4
cli/mod.ts
··· 48 48 const credentials = await loadKeychainCredentials(); 49 49 const s3 = createS3Provider( 50 50 credentials, 51 - flags.bucket ?? "photo-cloud-originals", 51 + flags.bucket ?? "photo-cloud-storage", 52 52 ); 53 53 54 54 const ladderPath = flags.ladderPath ?? ··· 72 72 const credentials = await loadKeychainCredentials(); 73 73 const s3 = createS3Provider( 74 74 credentials, 75 - verifyFlags.bucket ?? "photo-cloud-originals", 75 + verifyFlags.bucket ?? "photo-cloud-storage", 76 76 ); 77 77 const manifestStore = createManifestStore(); 78 78 ··· 99 99 console.log(` --batch-size N Assets per ladder batch (default: 50)`); 100 100 console.log(` --type photo|video Only back up photos or videos`); 101 101 console.log( 102 - ` --bucket NAME S3 bucket (default: photo-cloud-originals)`, 102 + ` --bucket NAME S3 bucket (default: photo-cloud-storage)`, 103 103 ); 104 104 console.log(` --ladder PATH Path to ladder binary`); 105 105 console.log(` --db PATH Path to Photos.sqlite`); ··· 107 107 console.log(` --deep Download and re-checksum each object`); 108 108 console.log(` --rebuild-manifest Reconstruct manifest from S3 metadata`); 109 109 console.log( 110 - ` --bucket NAME S3 bucket (default: photo-cloud-originals)`, 110 + ` --bucket NAME S3 bucket (default: photo-cloud-storage)`, 111 111 ); 112 112 console.log(`\nUsage: deno task <command>`); 113 113 if (command) { ··· 191 191 case "--db": 192 192 flags.dbPath = requireArg(args, ++i, "--db"); 193 193 break; 194 + case "--": 195 + break; 194 196 default: 195 197 console.error(`Unknown flag: ${arg}`); 196 198 Deno.exit(1); ··· 224 226 break; 225 227 case "--bucket": 226 228 flags.bucket = requireArg(args, ++i, "--bucket"); 229 + break; 230 + case "--": 227 231 break; 228 232 default: 229 233 console.error(`Unknown flag: ${arg}`);
+3 -8
cli/src/commands/backup.ts
··· 178 178 const s3Key = originalKey(asset.uuid, asset.dateCreated, ext); 179 179 180 180 try { 181 - // Stream original file to S3 (avoids loading entire file into memory) 182 - const file = await Deno.open(exported.path, { read: true }); 183 - try { 184 - await s3.putObject(s3Key, file.readable, contentTypeFor(ext)); 185 - } catch (uploadErr) { 186 - // file.readable auto-closes on error, but rethrow 187 - throw uploadErr; 188 - } 181 + // Read file and upload to S3 182 + const fileData = await Deno.readFile(exported.path); 183 + await s3.putObject(s3Key, fileData, contentTypeFor(ext)); 189 184 190 185 // Upload metadata JSON 191 186 const meta = buildMetadataJson(asset, s3Key, exported.sha256);
+19 -1
cli/src/export/exporter.ts
··· 100 100 } 101 101 } 102 102 103 + /** Strip the "/L0/001" suffix from a PhotoKit local identifier, returning the bare UUID. */ 104 + function stripLocalIdSuffix(id: string): string { 105 + const slashIndex = id.indexOf("/"); 106 + return slashIndex === -1 ? id : id.substring(0, slashIndex); 107 + } 108 + 103 109 /** Create an exporter that shells out to the ladder binary. */ 104 110 export function createLadderExporter( 105 111 ladderPath: string, ··· 111 117 async exportBatch(uuids: string[]): Promise<ExportBatchResult> { 112 118 await Deno.mkdir(stagingDir, { recursive: true }); 113 119 120 + // PhotoKit expects local identifiers in "UUID/L0/001" format 121 + const photoKitIds = uuids.map((uuid) => `${uuid}/L0/001`); 122 + 114 123 const request = JSON.stringify({ 115 - uuids, 124 + uuids: photoKitIds, 116 125 stagingDir, 117 126 }); 118 127 ··· 138 147 const output = new TextDecoder().decode(stdout); 139 148 const parsed: unknown = JSON.parse(output); 140 149 assertExportBatchResult(parsed); 150 + 151 + // Map PhotoKit identifiers ("UUID/L0/001") back to bare UUIDs 152 + for (const r of parsed.results) { 153 + r.uuid = stripLocalIdSuffix(r.uuid); 154 + } 155 + for (const e of parsed.errors) { 156 + e.uuid = stripLocalIdSuffix(e.uuid); 157 + } 158 + 141 159 return parsed; 142 160 }, 143 161 };
+4 -20
cli/src/storage/s3-client.mock.ts
··· 12 12 return { 13 13 objects, 14 14 15 - async putObject( 15 + putObject( 16 16 key: string, 17 - body: Uint8Array | ReadableStream<Uint8Array>, 17 + body: Uint8Array, 18 18 contentType?: string, 19 19 ): Promise<void> { 20 - let bytes: Uint8Array; 21 - if (body instanceof ReadableStream) { 22 - const chunks: Uint8Array[] = []; 23 - for await (const chunk of body) { 24 - chunks.push(chunk); 25 - } 26 - let totalLength = 0; 27 - for (const c of chunks) totalLength += c.length; 28 - bytes = new Uint8Array(totalLength); 29 - let offset = 0; 30 - for (const c of chunks) { 31 - bytes.set(c, offset); 32 - offset += c.length; 33 - } 34 - } else { 35 - bytes = body; 36 - } 37 - objects.set(key, { body: bytes, contentType }); 20 + objects.set(key, { body, contentType }); 21 + return Promise.resolve(); 38 22 }, 39 23 40 24 getObject(key: string): Promise<Uint8Array> {
+2 -2
cli/src/storage/s3-client.ts
··· 21 21 export interface S3Provider { 22 22 putObject( 23 23 key: string, 24 - body: Uint8Array | ReadableStream<Uint8Array>, 24 + body: Uint8Array, 25 25 contentType?: string, 26 26 ): Promise<void>; 27 27 getObject(key: string): Promise<Uint8Array>; ··· 83 83 return { 84 84 async putObject( 85 85 key: string, 86 - body: Uint8Array | ReadableStream<Uint8Array>, 86 + body: Uint8Array, 87 87 contentType?: string, 88 88 ): Promise<void> { 89 89 await client.send(
+4 -4
deno.json
··· 6 6 "lint": "deno lint", 7 7 "fmt": "deno fmt", 8 8 "fmt:check": "deno fmt --check", 9 - "scan": "deno run --allow-read --allow-env=HOME --allow-ffi cli/mod.ts scan", 10 - "status": "deno run --allow-read --allow-write --allow-env=HOME --allow-ffi cli/mod.ts status", 11 - "backup": "deno run --allow-read --allow-write --allow-env=HOME --allow-ffi --allow-net=s3.fr-par.scw.cloud --allow-run=security,ladder cli/mod.ts backup", 12 - "verify": "deno run --allow-read --allow-write --allow-env=HOME --allow-net=s3.fr-par.scw.cloud --allow-run=security cli/mod.ts verify" 9 + "scan": "deno run --allow-read --allow-env --allow-ffi cli/mod.ts scan", 10 + "status": "deno run --allow-read --allow-write --allow-env --allow-ffi cli/mod.ts status", 11 + "backup": "deno run --allow-read --allow-write --allow-env --allow-sys --allow-ffi --allow-net=s3.fr-par.scw.cloud --allow-run cli/mod.ts backup", 12 + "verify": "deno run --allow-read --allow-write --allow-env --allow-sys --allow-ffi --allow-net=s3.fr-par.scw.cloud --allow-run=security cli/mod.ts verify" 13 13 } 14 14 }