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:
parent
77302e1a6e
commit
f475ed0462
|
|
@ -12,6 +12,10 @@ services:
|
||||||
- INFISICAL_ENV=prod
|
- INFISICAL_ENV=prod
|
||||||
- INFISICAL_URL=http://infisical:8080
|
- INFISICAL_URL=http://infisical:8080
|
||||||
- DATABASE_URL=postgresql://rnotes:${DB_PASSWORD}@rnotes-postgres:5432/rnotes
|
- 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:
|
volumes:
|
||||||
- uploads_data:/app/uploads
|
- uploads_data:/app/uploads
|
||||||
labels:
|
labels:
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -140,6 +140,10 @@ model File {
|
||||||
author User? @relation(fields: [authorId], references: [id], onDelete: SetNull)
|
author User? @relation(fields: [authorId], references: [id], onDelete: SetNull)
|
||||||
createdAt DateTime @default(now())
|
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[]
|
attachments CardAttachment[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ import path from 'path';
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
import { requireAuth, isAuthed } from '@/lib/auth';
|
import { requireAuth, isAuthed } from '@/lib/auth';
|
||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { isIPFSEnabled, uploadToIPFS, ipfsProxyUrl } from '@/lib/ipfs';
|
||||||
|
|
||||||
const UPLOAD_DIR = process.env.UPLOAD_DIR || '/app/uploads';
|
const UPLOAD_DIR = process.env.UPLOAD_DIR || '/app/uploads';
|
||||||
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
|
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
|
||||||
|
|
@ -75,6 +76,22 @@ export async function POST(request: NextRequest) {
|
||||||
|
|
||||||
const fileUrl = `/api/uploads/${uniqueName}`;
|
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
|
// Create File record in database
|
||||||
const fileRecord = await prisma.file.create({
|
const fileRecord = await prisma.file.create({
|
||||||
data: {
|
data: {
|
||||||
|
|
@ -83,6 +100,10 @@ export async function POST(request: NextRequest) {
|
||||||
mimeType: file.type,
|
mimeType: file.type,
|
||||||
sizeBytes: file.size,
|
sizeBytes: file.size,
|
||||||
authorId: user.id,
|
authorId: user.id,
|
||||||
|
...(ipfsData && {
|
||||||
|
ipfsCid: ipfsData.cid,
|
||||||
|
ipfsEncKey: ipfsData.encKey,
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -93,6 +114,7 @@ export async function POST(request: NextRequest) {
|
||||||
size: file.size,
|
size: file.size,
|
||||||
mimeType: file.type,
|
mimeType: file.type,
|
||||||
fileId: fileRecord.id,
|
fileId: fileRecord.id,
|
||||||
|
...(ipfsData && { ipfs: ipfsData }),
|
||||||
}, { status: 201 });
|
}, { status: 201 });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Upload error:', error);
|
console.error('Upload error:', error);
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,11 @@ interface UploadResult {
|
||||||
originalName: string;
|
originalName: string;
|
||||||
size: number;
|
size: number;
|
||||||
mimeType: string;
|
mimeType: string;
|
||||||
|
ipfs?: {
|
||||||
|
cid: string;
|
||||||
|
encKey: string;
|
||||||
|
gateway: string; // proxy URL: /api/ipfs/{cid}?key={encKey}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FileUploadProps {
|
interface FileUploadProps {
|
||||||
|
|
@ -48,6 +53,10 @@ export function FileUpload({ onUpload, accept, maxSize = 50 * 1024 * 1024, class
|
||||||
}
|
}
|
||||||
|
|
||||||
const result: UploadResult = await res.json();
|
const result: UploadResult = await res.json();
|
||||||
|
// Prefer IPFS proxy URL when available
|
||||||
|
if (result.ipfs) {
|
||||||
|
result.url = result.ipfs.gateway;
|
||||||
|
}
|
||||||
onUpload(result);
|
onUpload(result);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Upload failed');
|
setError(err instanceof Error ? err.message : 'Upload failed');
|
||||||
|
|
|
||||||
|
|
@ -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}`
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue