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_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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
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[]
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 { 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);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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