Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

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

feat: self-hosted IPFS node on lith — replace Pinata for keeps minting

- Install Kubo IPFS daemon on lith (systemd service, localhost:5001/8090)
- Add ipfs.aesthetic.computer Caddy handler → local IPFS gateway
- Replace Pinata API calls with local IPFS /api/v0/add in keep pipeline
- Thumbnail: use oven /grab (raw image) + local IPFS pin instead of
oven /grab-ipfs (which required Pinata credentials)
- DNS: ipfs.aesthetic.computer A record → lith (was Pinata CNAME)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+28 -32
+7
lith/Caddyfile
··· 110 110 reverse_proxy localhost:8888 111 111 } 112 112 113 + # --- ipfs.aesthetic.computer (self-hosted IPFS gateway) --- 114 + @ipfs host ipfs.aesthetic.computer 115 + handle @ipfs { 116 + header Access-Control-Allow-Origin * 117 + reverse_proxy localhost:8090 118 + } 119 + 113 120 # --- justanothersystem.org --- 114 121 @wwwjas2 host www.justanothersystem.org 115 122 handle @wwwjas2 {
+21 -32
system/netlify/functions/keep-prepare-background.mjs
··· 367 367 && piece?.ipfsMedia?.sourceHash === hashSource(piece.source); 368 368 } 369 369 370 - // ─── IPFS Upload ───────────────────────────────────────────────────────────── 370 + // ─── IPFS Upload (self-hosted Kubo node on lith) ───────────────────────────── 371 + const IPFS_API = process.env.IPFS_API_URL || "http://localhost:5001"; 372 + 371 373 async function uploadToIPFS(content, filename, mimeType, timeoutMs = 90000) { 372 - const pinata = await getPinataCredentials(); 373 374 const formData = new FormData(); 374 375 formData.append("file", new Blob([content], { type: mimeType }), filename); 375 - formData.append("pinataMetadata", JSON.stringify({ name: filename })); 376 376 const controller = new AbortController(); 377 377 const timeout = setTimeout(() => controller.abort(), Math.max(3000, timeoutMs)); 378 378 try { 379 - const res = await fetch("https://api.pinata.cloud/pinning/pinFileToIPFS", { 379 + const res = await fetch(`${IPFS_API}/api/v0/add?pin=true`, { 380 380 method: "POST", 381 - headers: { pinata_api_key: pinata.apiKey, pinata_secret_api_key: pinata.apiSecret }, 382 381 body: formData, 383 382 signal: controller.signal, 384 383 }); 385 384 clearTimeout(timeout); 386 385 if (!res.ok) throw new Error(`IPFS upload failed: ${res.status}`); 387 386 const result = await res.json(); 388 - return formatIpfsUri(result.IpfsHash); 387 + return formatIpfsUri(result.Hash); 389 388 } catch (err) { 390 389 clearTimeout(timeout); 391 390 if (err.name === "AbortError") throw new Error(`IPFS upload timed out after ${Math.round(timeoutMs / 1000)}s`); ··· 394 393 } 395 394 396 395 async function uploadJsonToIPFS(json, name, timeoutMs = 30000) { 397 - const pinata = await getPinataCredentials(); 396 + const content = JSON.stringify(json); 397 + const formData = new FormData(); 398 + formData.append("file", new Blob([content], { type: "application/json" }), name); 398 399 const controller = new AbortController(); 399 400 const timeout = setTimeout(() => controller.abort(), Math.max(3000, timeoutMs)); 400 401 try { 401 - const res = await fetch("https://api.pinata.cloud/pinning/pinJSONToIPFS", { 402 + const res = await fetch(`${IPFS_API}/api/v0/add?pin=true`, { 402 403 method: "POST", 403 - headers: { 404 - "Content-Type": "application/json", 405 - pinata_api_key: pinata.apiKey, 406 - pinata_secret_api_key: pinata.apiSecret, 407 - }, 408 - body: JSON.stringify({ pinataContent: json, pinataMetadata: { name } }), 404 + body: formData, 409 405 signal: controller.signal, 410 406 }); 411 407 clearTimeout(timeout); 412 408 if (!res.ok) throw new Error(`Metadata upload failed: ${res.status}`); 413 409 const result = await res.json(); 414 - return formatIpfsUri(result.IpfsHash); 410 + return formatIpfsUri(result.Hash); 415 411 } catch (err) { 416 412 clearTimeout(timeout); 417 413 if (err.name === "AbortError") throw new Error(`Metadata upload timed out after ${Math.round(timeoutMs / 1000)}s`); ··· 495 491 log("cache", "Reusing cached"); 496 492 } 497 493 498 - const pinataCredentials = await getPinataCredentials(); 499 - 500 494 // ── Thumbnail (async) ────────────────────────────────────────────── 501 495 let thumbnailPromise; 502 496 if (!useCachedMedia) { ··· 509 503 const controller = new AbortController(); 510 504 const tid = setTimeout(() => controller.abort(), timeoutMs); 511 505 try { 512 - const res = await fetch(`${ovenUrl}/grab-ipfs`, { 506 + // Use /grab to get raw image, then pin to local IPFS 507 + const res = await fetch(`${ovenUrl}/grab`, { 513 508 method: "POST", 514 509 headers: { "Content-Type": "application/json" }, 515 510 body: JSON.stringify({ 516 511 piece: `$${pieceName}`, format: "webp", 517 512 width: 256, height: 256, density: 2, 518 - duration: forceFresh ? 4000 : 5000, fps: 8, playbackFps: 16, quality: 70, 519 - source: "keep", 520 - author: userHandle ? `@${userHandle}` : null, 513 + duration: forceFresh ? 4000 : 5000, fps: 8, 514 + quality: 70, 521 515 cacheKey: forceFresh ? `rebake-${pieceSourceHash}-${Date.now()}` : `src-${pieceSourceHash}`, 522 516 skipCache: forceFresh, 523 - pinataKey: pinataCredentials.apiKey, 524 - pinataSecret: pinataCredentials.apiSecret, 525 517 }), 526 518 signal: controller.signal, 527 519 }); 528 520 clearTimeout(tid); 529 521 if (!res.ok) throw new Error(`Oven ${res.status}`); 530 - return res.json(); 522 + const buffer = Buffer.from(await res.arrayBuffer()); 523 + const thumbFilename = `${pieceName}-thumbnail.webp-${pieceSourceHash.slice(0, 16)}`; 524 + const ipfsUri = await uploadToIPFS(buffer, thumbFilename, "image/webp"); 525 + return { ipfsUri, size: buffer.length }; 531 526 } catch (err) { 532 527 clearTimeout(tid); 533 528 if (err.name === "AbortError") throw new Error(`Oven timed out after ${timeoutMs / 1000}s`); ··· 536 531 }; 537 532 538 533 try { 539 - const result = await tryOven(OVEN_URL, KEEP_MINT_THUMBNAIL_TIMEOUT_MS); 540 - if (result?.ipfsUri) return result; 541 - if (result?.error) throw new Error(result.error); 542 - return result; 534 + return await tryOven(OVEN_URL, KEEP_MINT_THUMBNAIL_TIMEOUT_MS); 543 535 } catch (err) { 544 536 log("thumbnail", `Primary oven failed: ${err.message}`); 545 537 if (OVEN_URL !== OVEN_FALLBACK_URL) { 546 538 try { 547 - const fb = await tryOven(OVEN_FALLBACK_URL, KEEP_MINT_THUMBNAIL_TIMEOUT_MS); 548 - if (fb?.ipfsUri) return fb; 549 - if (fb?.error) throw new Error(fb.error); 550 - return fb; 539 + return await tryOven(OVEN_FALLBACK_URL, KEEP_MINT_THUMBNAIL_TIMEOUT_MS); 551 540 } catch (fbErr) { 552 541 return { error: fbErr.message }; 553 542 }