feat: add file/image upload support with drag-and-drop

- Upload API at /api/uploads with 50MB limit, MIME type validation, and
  path traversal protection
- Serve uploaded files at /api/uploads/[filename] with immutable caching
- FileUpload component with drag-and-drop, progress, and preview
- IMAGE notes show uploaded image preview in detail view
- FILE notes show download button in detail view
- Docker volume for persistent upload storage

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-13 13:33:49 -07:00
parent f7fa7bc7a1
commit a065388bbf
8 changed files with 365 additions and 5 deletions

View File

@ -8,6 +8,8 @@ services:
- NEXT_PUBLIC_RSPACE_URL=${NEXT_PUBLIC_RSPACE_URL:-https://rspace.online} - NEXT_PUBLIC_RSPACE_URL=${NEXT_PUBLIC_RSPACE_URL:-https://rspace.online}
- RSPACE_INTERNAL_URL=${RSPACE_INTERNAL_URL:-http://rspace-online:3000} - RSPACE_INTERNAL_URL=${RSPACE_INTERNAL_URL:-http://rspace-online:3000}
- NEXT_PUBLIC_ENCRYPTID_SERVER_URL=${NEXT_PUBLIC_ENCRYPTID_SERVER_URL:-https://encryptid.jeffemmett.com} - NEXT_PUBLIC_ENCRYPTID_SERVER_URL=${NEXT_PUBLIC_ENCRYPTID_SERVER_URL:-https://encryptid.jeffemmett.com}
volumes:
- uploads_data:/app/uploads
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.http.routers.rnotes.rule=Host(`rnotes.online`) || Host(`www.rnotes.online`)" - "traefik.http.routers.rnotes.rule=Host(`rnotes.online`) || Host(`www.rnotes.online`)"
@ -62,3 +64,4 @@ networks:
volumes: volumes:
postgres_data: postgres_data:
uploads_data:

View File

@ -28,7 +28,7 @@ export async function POST(
) { ) {
try { try {
const body = await request.json(); const body = await request.json();
const { title, content, type, url, language, tags } = body; const { title, content, type, url, language, tags, fileUrl, mimeType, fileSize } = body;
if (!title?.trim()) { if (!title?.trim()) {
return NextResponse.json({ error: 'Title is required' }, { status: 400 }); return NextResponse.json({ error: 'Title is required' }, { status: 400 });
@ -60,6 +60,9 @@ export async function POST(
type: type || 'NOTE', type: type || 'NOTE',
url: url || null, url: url || null,
language: language || null, language: language || null,
fileUrl: fileUrl || null,
mimeType: mimeType || null,
fileSize: fileSize || null,
tags: { tags: {
create: tagRecords.map((tag) => ({ create: tagRecords.map((tag) => ({
tagId: tag.id, tagId: tag.id,

View File

@ -39,7 +39,7 @@ export async function GET(request: NextRequest) {
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const body = await request.json(); const body = await request.json();
const { title, content, type, notebookId, url, language, tags } = body; const { title, content, type, notebookId, url, language, tags, fileUrl, mimeType, fileSize } = body;
if (!title?.trim()) { if (!title?.trim()) {
return NextResponse.json({ error: 'Title is required' }, { status: 400 }); return NextResponse.json({ error: 'Title is required' }, { status: 400 });
@ -71,6 +71,9 @@ export async function POST(request: NextRequest) {
notebookId: notebookId || null, notebookId: notebookId || null,
url: url || null, url: url || null,
language: language || null, language: language || null,
fileUrl: fileUrl || null,
mimeType: mimeType || null,
fileSize: fileSize || null,
tags: { tags: {
create: tagRecords.map((tag) => ({ create: tagRecords.map((tag) => ({
tagId: tag.id, tagId: tag.id,

View File

@ -0,0 +1,70 @@
import { NextRequest, NextResponse } from 'next/server';
import { readFile } from 'fs/promises';
import { existsSync } from 'fs';
import path from 'path';
const UPLOAD_DIR = process.env.UPLOAD_DIR || '/app/uploads';
const MIME_TYPES: Record<string, string> = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.svg': 'image/svg+xml',
'.avif': 'image/avif',
'.pdf': 'application/pdf',
'.txt': 'text/plain',
'.md': 'text/markdown',
'.csv': 'text/csv',
'.json': 'application/json',
'.xml': 'application/xml',
'.zip': 'application/zip',
'.gz': 'application/gzip',
'.js': 'text/javascript',
'.ts': 'text/typescript',
'.html': 'text/html',
'.css': 'text/css',
'.py': 'text/x-python',
};
export async function GET(
_request: NextRequest,
{ params }: { params: { filename: string } }
) {
try {
const filename = params.filename;
// Validate filename (no path traversal)
if (filename.includes('/') || filename.includes('\\') || filename.includes('..')) {
return NextResponse.json({ error: 'Invalid filename' }, { status: 400 });
}
const filePath = path.join(UPLOAD_DIR, filename);
// Validate resolved path stays within UPLOAD_DIR
const resolvedPath = path.resolve(filePath);
if (!resolvedPath.startsWith(path.resolve(UPLOAD_DIR))) {
return NextResponse.json({ error: 'Invalid filename' }, { status: 400 });
}
if (!existsSync(filePath)) {
return NextResponse.json({ error: 'File not found' }, { status: 404 });
}
const data = await readFile(filePath);
const ext = path.extname(filename).toLowerCase();
const contentType = MIME_TYPES[ext] || 'application/octet-stream';
return new NextResponse(data, {
headers: {
'Content-Type': contentType,
'Cache-Control': 'public, max-age=31536000, immutable',
'Content-Length': data.length.toString(),
},
});
} catch (error) {
console.error('Serve file error:', error);
return NextResponse.json({ error: 'Failed to serve file' }, { status: 500 });
}
}

View File

@ -0,0 +1,81 @@
import { NextRequest, NextResponse } from 'next/server';
import { writeFile, mkdir } from 'fs/promises';
import { existsSync } from 'fs';
import path from 'path';
import { nanoid } from 'nanoid';
const UPLOAD_DIR = process.env.UPLOAD_DIR || '/app/uploads';
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
const ALLOWED_MIME_TYPES = new Set([
// Images
'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml', 'image/avif',
// Documents
'application/pdf', 'text/plain', 'text/markdown', 'text/csv',
'application/json', 'application/xml',
// Archives
'application/zip', 'application/gzip',
// Code
'text/javascript', 'text/typescript', 'text/html', 'text/css',
'application/x-python-code', 'text/x-python',
]);
function sanitizeFilename(name: string): string {
return name.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 200);
}
export async function POST(request: NextRequest) {
try {
const formData = await request.formData();
const file = formData.get('file') as File | null;
if (!file) {
return NextResponse.json({ error: 'No file provided' }, { status: 400 });
}
if (file.size > MAX_FILE_SIZE) {
return NextResponse.json({ error: 'File too large (max 50MB)' }, { status: 400 });
}
if (!ALLOWED_MIME_TYPES.has(file.type)) {
return NextResponse.json(
{ error: `File type "${file.type}" not allowed` },
{ status: 400 }
);
}
// Ensure upload directory exists
if (!existsSync(UPLOAD_DIR)) {
await mkdir(UPLOAD_DIR, { recursive: true });
}
// Generate unique filename
const ext = path.extname(file.name) || '';
const safeName = sanitizeFilename(path.basename(file.name, ext));
const uniqueName = `${nanoid(12)}_${safeName}${ext}`;
const filePath = path.join(UPLOAD_DIR, uniqueName);
// Validate the resolved path is within UPLOAD_DIR
const resolvedPath = path.resolve(filePath);
if (!resolvedPath.startsWith(path.resolve(UPLOAD_DIR))) {
return NextResponse.json({ error: 'Invalid file path' }, { status: 400 });
}
// Write file
const bytes = await file.arrayBuffer();
await writeFile(filePath, Buffer.from(bytes));
const fileUrl = `/api/uploads/${uniqueName}`;
return NextResponse.json({
url: fileUrl,
filename: uniqueName,
originalName: file.name,
size: file.size,
mimeType: file.type,
}, { status: 201 });
} catch (error) {
console.error('Upload error:', error);
return NextResponse.json({ error: 'Failed to upload file' }, { status: 500 });
}
}

View File

@ -23,6 +23,9 @@ interface NoteData {
type: string; type: string;
url: string | null; url: string | null;
language: string | null; language: string | null;
fileUrl: string | null;
mimeType: string | null;
fileSize: number | null;
isPinned: boolean; isPinned: boolean;
canvasShapeId: string | null; canvasShapeId: string | null;
createdAt: string; createdAt: string;
@ -212,6 +215,35 @@ export default function NoteDetailPage() {
</a> </a>
)} )}
{/* Uploaded file/image */}
{note.fileUrl && note.type === 'IMAGE' && (
<div className="mb-6 rounded-lg overflow-hidden border border-slate-700">
<img
src={note.fileUrl}
alt={note.title}
className="max-w-full max-h-[600px] object-contain mx-auto bg-slate-900"
/>
</div>
)}
{note.fileUrl && note.type === 'FILE' && (
<div className="mb-6 flex items-center gap-3 p-4 bg-slate-800/50 border border-slate-700 rounded-lg">
<svg className="w-8 h-8 text-slate-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
<div className="flex-1 min-w-0">
<p className="text-sm text-white truncate">{note.fileUrl.split('/').pop()}</p>
{note.mimeType && <p className="text-xs text-slate-500">{note.mimeType}{note.fileSize ? ` · ${(note.fileSize / 1024).toFixed(1)} KB` : ''}</p>}
</div>
<a
href={note.fileUrl}
download
className="px-3 py-1.5 text-sm bg-slate-700 hover:bg-slate-600 text-white rounded-lg transition-colors"
>
Download
</a>
</div>
)}
{/* Content */} {/* Content */}
{editing ? ( {editing ? (
<div className="space-y-4"> <div className="space-y-4">

View File

@ -4,14 +4,15 @@ import { Suspense, useState, useEffect } from 'react';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { NoteEditor } from '@/components/NoteEditor'; import { NoteEditor } from '@/components/NoteEditor';
import { FileUpload } from '@/components/FileUpload';
const NOTE_TYPES = [ const NOTE_TYPES = [
{ value: 'NOTE', label: 'Note', desc: 'Rich text note' }, { value: 'NOTE', label: 'Note', desc: 'Rich text note' },
{ value: 'CLIP', label: 'Clip', desc: 'Web clipping' }, { value: 'CLIP', label: 'Clip', desc: 'Web clipping' },
{ value: 'BOOKMARK', label: 'Bookmark', desc: 'Save a URL' }, { value: 'BOOKMARK', label: 'Bookmark', desc: 'Save a URL' },
{ value: 'CODE', label: 'Code', desc: 'Code snippet' }, { value: 'CODE', label: 'Code', desc: 'Code snippet' },
{ value: 'IMAGE', label: 'Image', desc: 'Image URL' }, { value: 'IMAGE', label: 'Image', desc: 'Upload image' },
{ value: 'FILE', label: 'File', desc: 'File reference' }, { value: 'FILE', label: 'File', desc: 'Upload file' },
]; ];
interface NotebookOption { interface NotebookOption {
@ -45,6 +46,9 @@ function NewNoteForm() {
const [url, setUrl] = useState(''); const [url, setUrl] = useState('');
const [language, setLanguage] = useState(''); const [language, setLanguage] = useState('');
const [tags, setTags] = useState(''); const [tags, setTags] = useState('');
const [fileUrl, setFileUrl] = useState('');
const [mimeType, setMimeType] = useState('');
const [fileSize, setFileSize] = useState(0);
const [notebookId, setNotebookId] = useState(preselectedNotebook || ''); const [notebookId, setNotebookId] = useState(preselectedNotebook || '');
const [notebooks, setNotebooks] = useState<NotebookOption[]>([]); const [notebooks, setNotebooks] = useState<NotebookOption[]>([]);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@ -71,6 +75,9 @@ function NewNoteForm() {
if (notebookId) body.notebookId = notebookId; if (notebookId) body.notebookId = notebookId;
if (url) body.url = url; if (url) body.url = url;
if (language) body.language = language; if (language) body.language = language;
if (fileUrl) body.fileUrl = fileUrl;
if (mimeType) body.mimeType = mimeType;
if (fileSize) body.fileSize = fileSize;
const endpoint = notebookId const endpoint = notebookId
? `/api/notebooks/${notebookId}/notes` ? `/api/notebooks/${notebookId}/notes`
@ -93,7 +100,8 @@ function NewNoteForm() {
} }
}; };
const showUrl = ['CLIP', 'BOOKMARK', 'IMAGE', 'FILE'].includes(type); const showUrl = ['CLIP', 'BOOKMARK'].includes(type);
const showUpload = ['IMAGE', 'FILE'].includes(type);
const showLanguage = type === 'CODE'; const showLanguage = type === 'CODE';
return ( return (
@ -162,6 +170,53 @@ function NewNoteForm() {
</div> </div>
)} )}
{/* File upload */}
{showUpload && (
<div>
<label className="block text-sm font-medium text-slate-300 mb-2">
{type === 'IMAGE' ? 'Upload Image' : 'Upload File'}
</label>
{fileUrl ? (
<div className="flex items-center gap-3 p-3 bg-slate-800/50 border border-slate-700 rounded-lg">
{type === 'IMAGE' && (
<img src={fileUrl} alt="Preview" className="w-16 h-16 object-cover rounded" />
)}
<div className="flex-1 min-w-0">
<p className="text-sm text-white truncate">{fileUrl.split('/').pop()}</p>
<p className="text-xs text-slate-500">{mimeType} &middot; {(fileSize / 1024).toFixed(1)} KB</p>
</div>
<button
type="button"
onClick={() => { setFileUrl(''); setMimeType(''); setFileSize(0); }}
className="text-slate-400 hover:text-red-400 text-sm"
>
Remove
</button>
</div>
) : (
<FileUpload
accept={type === 'IMAGE' ? 'image/*' : undefined}
onUpload={(result) => {
setFileUrl(result.url);
setMimeType(result.mimeType);
setFileSize(result.size);
if (!title) setTitle(result.originalName);
}}
/>
)}
<div className="mt-2">
<label className="block text-xs text-slate-500 mb-1">Or paste a URL</label>
<input
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://..."
className="w-full px-3 py-2 bg-slate-800/50 border border-slate-700 rounded-lg text-white text-sm placeholder-slate-500 focus:outline-none focus:border-amber-500/50"
/>
</div>
</div>
)}
{/* Language field */} {/* Language field */}
{showLanguage && ( {showLanguage && (
<div> <div>

View File

@ -0,0 +1,113 @@
'use client';
import { useState, useRef, useCallback } from 'react';
interface UploadResult {
url: string;
filename: string;
originalName: string;
size: number;
mimeType: string;
}
interface FileUploadProps {
onUpload: (result: UploadResult) => void;
accept?: string;
maxSize?: number;
className?: string;
}
export function FileUpload({ onUpload, accept, maxSize = 50 * 1024 * 1024, className }: FileUploadProps) {
const [uploading, setUploading] = useState(false);
const [dragOver, setDragOver] = useState(false);
const [error, setError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const handleUpload = useCallback(async (file: File) => {
if (file.size > maxSize) {
setError(`File too large (max ${Math.round(maxSize / 1024 / 1024)}MB)`);
return;
}
setUploading(true);
setError(null);
try {
const formData = new FormData();
formData.append('file', file);
const res = await fetch('/api/uploads', {
method: 'POST',
body: formData,
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || 'Upload failed');
}
const result: UploadResult = await res.json();
onUpload(result);
} catch (err) {
setError(err instanceof Error ? err.message : 'Upload failed');
} finally {
setUploading(false);
}
}, [maxSize, onUpload]);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
setDragOver(false);
const file = e.dataTransfer.files[0];
if (file) handleUpload(file);
}, [handleUpload]);
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) handleUpload(file);
}, [handleUpload]);
return (
<div className={className}>
<div
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
onDragLeave={() => setDragOver(false)}
onDrop={handleDrop}
onClick={() => inputRef.current?.click()}
className={`border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors ${
dragOver
? 'border-amber-500 bg-amber-500/10'
: 'border-slate-700 hover:border-slate-600 bg-slate-800/30'
}`}
>
<input
ref={inputRef}
type="file"
accept={accept}
onChange={handleChange}
className="hidden"
/>
{uploading ? (
<div className="flex items-center justify-center gap-2 text-slate-400">
<svg className="animate-spin h-5 w-5" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
Uploading...
</div>
) : (
<div className="text-slate-400">
<svg className="w-8 h-8 mx-auto mb-2 text-slate-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
<p className="text-sm">Drop a file here or click to browse</p>
<p className="text-xs text-slate-500 mt-1">Max {Math.round(maxSize / 1024 / 1024)}MB</p>
</div>
)}
</div>
{error && (
<p className="text-red-400 text-sm mt-2">{error}</p>
)}
</div>
);
}