diff --git a/docker-compose.yml b/docker-compose.yml index a75e3a8..1101156 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,6 +12,10 @@ services: - INFISICAL_ENV=prod - INFISICAL_URL=http://infisical:8080 - DATABASE_URL=postgresql://rnotes:${DB_PASSWORD}@rnotes-postgres:5432/rnotes + # IPFS integration (encrypted file storage) + - IPFS_ENABLED=true + - IPFS_API_URL=http://ipfs:5001 + - IPFS_GATEWAY_URL=https://ipfs.jeffemmett.com volumes: - uploads_data:/app/uploads labels: diff --git a/prisma/migrations/20260331_add_ipfs_fields/migration.sql b/prisma/migrations/20260331_add_ipfs_fields/migration.sql new file mode 100644 index 0000000..f917576 --- /dev/null +++ b/prisma/migrations/20260331_add_ipfs_fields/migration.sql @@ -0,0 +1,3 @@ +-- Add IPFS storage fields to File model +ALTER TABLE "File" ADD COLUMN "ipfsCid" TEXT; +ALTER TABLE "File" ADD COLUMN "ipfsEncKey" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a10bcff..9a3262d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -140,6 +140,10 @@ model File { author User? @relation(fields: [authorId], references: [id], onDelete: SetNull) createdAt DateTime @default(now()) + // IPFS storage (optional — populated when IPFS_ENABLED=true) + ipfsCid String? // IPFS content identifier + ipfsEncKey String? // base64 AES-256-GCM key for this file + attachments CardAttachment[] } diff --git a/src/app/api/ipfs/[cid]/route.ts b/src/app/api/ipfs/[cid]/route.ts new file mode 100644 index 0000000..cb2c747 --- /dev/null +++ b/src/app/api/ipfs/[cid]/route.ts @@ -0,0 +1,75 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { downloadFromIPFS, ipfsGatewayUrl } from '@/lib/ipfs'; +import { prisma } from '@/lib/prisma'; + +// Simple LRU cache for decrypted content +const cache = new Map(); +const MAX_CACHE = 100; +const CACHE_TTL = 10 * 60 * 1000; // 10 minutes + +function evictStale() { + if (cache.size <= MAX_CACHE) return; + const now = Date.now(); + const keys = Array.from(cache.keys()); + for (const k of keys) { + const entry = cache.get(k); + if (!entry || now - entry.ts > CACHE_TTL || cache.size > MAX_CACHE) { + cache.delete(k); + } + } +} + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ cid: string }> } +) { + const { cid } = await params; + const key = request.nextUrl.searchParams.get('key'); + + if (!cid || !/^[a-zA-Z0-9]+$/.test(cid)) { + return NextResponse.json({ error: 'Invalid CID' }, { status: 400 }); + } + + // No key = redirect to public gateway (serves encrypted blob) + if (!key) { + return NextResponse.redirect(ipfsGatewayUrl(cid)); + } + + // Check cache + const cached = cache.get(cid); + if (cached && Date.now() - cached.ts < CACHE_TTL) { + return new NextResponse(cached.data as unknown as BodyInit, { + headers: { + 'Content-Type': cached.mimeType, + 'Cache-Control': 'private, max-age=3600', + }, + }); + } + + try { + // Look up mime type from DB + const file = await prisma.file.findFirst({ + where: { ipfsCid: cid } as Record, + select: { mimeType: true }, + }); + const mimeType = file?.mimeType || 'application/octet-stream'; + + // Download and decrypt + const decrypted = await downloadFromIPFS(cid, key); + const buf = Buffer.from(decrypted.buffer as ArrayBuffer); + + // Cache result + evictStale(); + cache.set(cid, { data: buf, mimeType, ts: Date.now() }); + + return new NextResponse(buf as unknown as BodyInit, { + headers: { + 'Content-Type': mimeType, + 'Cache-Control': 'private, max-age=3600', + }, + }); + } catch (err) { + console.error('IPFS proxy error:', err); + return NextResponse.json({ error: 'Failed to fetch from IPFS' }, { status: 502 }); + } +} diff --git a/src/app/api/uploads/route.ts b/src/app/api/uploads/route.ts index 8415022..231821e 100644 --- a/src/app/api/uploads/route.ts +++ b/src/app/api/uploads/route.ts @@ -5,6 +5,7 @@ import path from 'path'; import { nanoid } from 'nanoid'; import { requireAuth, isAuthed } from '@/lib/auth'; import { prisma } from '@/lib/prisma'; +import { isIPFSEnabled, uploadToIPFS, ipfsProxyUrl } from '@/lib/ipfs'; const UPLOAD_DIR = process.env.UPLOAD_DIR || '/app/uploads'; const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB @@ -75,6 +76,22 @@ export async function POST(request: NextRequest) { const fileUrl = `/api/uploads/${uniqueName}`; + // IPFS upload (if enabled) + let ipfsData: { cid: string; encKey: string; gateway: string } | undefined; + if (isIPFSEnabled()) { + try { + const fileBytes = new Uint8Array(bytes); + const metadata = await uploadToIPFS(fileBytes, file.name, file.type); + ipfsData = { + cid: metadata.cid, + encKey: metadata.encryptionKey, + gateway: ipfsProxyUrl(metadata.cid, metadata.encryptionKey), + }; + } catch (err) { + console.error('IPFS upload failed (falling back to local):', err); + } + } + // Create File record in database const fileRecord = await prisma.file.create({ data: { @@ -83,6 +100,10 @@ export async function POST(request: NextRequest) { mimeType: file.type, sizeBytes: file.size, authorId: user.id, + ...(ipfsData && { + ipfsCid: ipfsData.cid, + ipfsEncKey: ipfsData.encKey, + }), }, }); @@ -93,6 +114,7 @@ export async function POST(request: NextRequest) { size: file.size, mimeType: file.type, fileId: fileRecord.id, + ...(ipfsData && { ipfs: ipfsData }), }, { status: 201 }); } catch (error) { console.error('Upload error:', error); diff --git a/src/components/FileUpload.tsx b/src/components/FileUpload.tsx index f38d160..04e2e3f 100644 --- a/src/components/FileUpload.tsx +++ b/src/components/FileUpload.tsx @@ -9,6 +9,11 @@ interface UploadResult { originalName: string; size: number; mimeType: string; + ipfs?: { + cid: string; + encKey: string; + gateway: string; // proxy URL: /api/ipfs/{cid}?key={encKey} + }; } interface FileUploadProps { @@ -48,6 +53,10 @@ export function FileUpload({ onUpload, accept, maxSize = 50 * 1024 * 1024, class } const result: UploadResult = await res.json(); + // Prefer IPFS proxy URL when available + if (result.ipfs) { + result.url = result.ipfs.gateway; + } onUpload(result); } catch (err) { setError(err instanceof Error ? err.message : 'Upload failed'); diff --git a/src/lib/ipfs.ts b/src/lib/ipfs.ts new file mode 100644 index 0000000..e6d214f --- /dev/null +++ b/src/lib/ipfs.ts @@ -0,0 +1,138 @@ +/** + * Encrypted IPFS file storage for rNotes + * Ported from fileverse/poc/ipfs-storage + * + * Flow: encrypt locally (AES-256-GCM) → upload to kubo → store CID + key in DB + */ + +// ─── Encryption ─── + +async function generateFileKey(): Promise { + const key = new Uint8Array(32) + crypto.getRandomValues(key) + return key +} + +async function encryptFile(key: Uint8Array, data: Uint8Array): Promise { + const iv = new Uint8Array(12) + crypto.getRandomValues(iv) + const keyBuf = key.buffer as ArrayBuffer + const cryptoKey = await crypto.subtle.importKey('raw', keyBuf, 'AES-GCM', false, ['encrypt']) + const dataBuf = data.buffer as ArrayBuffer + const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, cryptoKey, dataBuf) + const result = new Uint8Array(12 + ciphertext.byteLength) + result.set(iv) + result.set(new Uint8Array(ciphertext), 12) + return result +} + +async function decryptFile(key: Uint8Array, encrypted: Uint8Array): Promise { + const iv = encrypted.slice(0, 12) + const ciphertext = encrypted.slice(12) + const keyBuf = key.buffer as ArrayBuffer + const cryptoKey = await crypto.subtle.importKey('raw', keyBuf, 'AES-GCM', false, ['decrypt']) + const ciphertextBuf = ciphertext.buffer as ArrayBuffer + const plaintext = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, cryptoKey, ciphertextBuf) + return new Uint8Array(plaintext) +} + +// ─── Base64 Utilities ─── + +function uint8ArrayToBase64(bytes: Uint8Array): string { + let binary = '' + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]) + } + return btoa(binary) +} + +function base64ToUint8Array(base64: string): Uint8Array { + const binary = atob(base64) + const bytes = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i) + } + return bytes +} + +// ─── Types ─── + +export interface FileMetadata { + cid: string + encryptionKey: string // base64-encoded 32-byte AES key + filename: string + mimeType: string + size: number + encryptedSize: number +} + +// ─── IPFS Client ─── + +const IPFS_API_URL = process.env.IPFS_API_URL || 'http://ipfs:5001' +const IPFS_GATEWAY_URL = process.env.IPFS_GATEWAY_URL || 'https://ipfs.jeffemmett.com' + +export function isIPFSEnabled(): boolean { + return process.env.IPFS_ENABLED === 'true' +} + +/** + * Encrypt and upload a file to IPFS via kubo API + */ +export async function uploadToIPFS( + data: Uint8Array, + filename: string, + mimeType: string +): Promise { + const fileKey = await generateFileKey() + const encrypted = await encryptFile(fileKey, data) + + const formData = new FormData() + formData.append('file', new Blob([encrypted.buffer as ArrayBuffer]), `${filename}.enc`) + + const response = await fetch(`${IPFS_API_URL}/api/v0/add?pin=true`, { + method: 'POST', + body: formData, + }) + + if (!response.ok) { + throw new Error(`IPFS upload failed: ${response.status}`) + } + + const result = await response.json() as { Hash: string } + + return { + cid: result.Hash, + encryptionKey: uint8ArrayToBase64(fileKey), + filename, + mimeType, + size: data.byteLength, + encryptedSize: encrypted.byteLength, + } +} + +/** + * Download and decrypt a file from IPFS + */ +export async function downloadFromIPFS(cid: string, encKey: string): Promise { + const response = await fetch(`${IPFS_GATEWAY_URL}/ipfs/${cid}`) + if (!response.ok) { + throw new Error(`IPFS download failed: ${response.status}`) + } + const encrypted = new Uint8Array(await response.arrayBuffer()) + const fileKey = base64ToUint8Array(encKey) + return decryptFile(fileKey, encrypted) +} + +/** + * Build the proxy URL for an IPFS-backed file + */ +export function ipfsProxyUrl(cid: string, encKey: string): string { + return `/api/ipfs/${cid}?key=${encodeURIComponent(encKey)}` +} + +/** + * Get the public gateway URL (serves encrypted content) + */ +export function ipfsGatewayUrl(cid: string): string { + return `${IPFS_GATEWAY_URL}/ipfs/${cid}` +}