feat: integrate encrypted IPFS file storage

- Add src/lib/ipfs.ts: AES-256-GCM encryption + kubo upload/download
- Add /api/ipfs/[cid] proxy route: decrypt + serve with LRU cache
- Upload route: encrypt + pin to IPFS alongside local disk (fallback)
- Prisma: add ipfsCid/ipfsEncKey fields to File model
- FileUpload: prefer IPFS proxy URL when available
- Feature-flagged via IPFS_ENABLED env var

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-31 17:17:46 -07:00
parent 77302e1a6e
commit f475ed0462
7 changed files with 255 additions and 0 deletions

View File

@ -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:

View File

@ -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;

View File

@ -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[]
}

View File

@ -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<string, { data: Buffer; mimeType: string; ts: number }>();
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<string, unknown>,
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 });
}
}

View File

@ -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);

View File

@ -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');

138
src/lib/ipfs.ts Normal file
View File

@ -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<Uint8Array> {
const key = new Uint8Array(32)
crypto.getRandomValues(key)
return key
}
async function encryptFile(key: Uint8Array, data: Uint8Array): Promise<Uint8Array> {
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<Uint8Array> {
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<FileMetadata> {
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<Uint8Array> {
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}`
}