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:
parent
f7fa7bc7a1
commit
a065388bbf
|
|
@ -8,6 +8,8 @@ services:
|
|||
- NEXT_PUBLIC_RSPACE_URL=${NEXT_PUBLIC_RSPACE_URL:-https://rspace.online}
|
||||
- RSPACE_INTERNAL_URL=${RSPACE_INTERNAL_URL:-http://rspace-online:3000}
|
||||
- NEXT_PUBLIC_ENCRYPTID_SERVER_URL=${NEXT_PUBLIC_ENCRYPTID_SERVER_URL:-https://encryptid.jeffemmett.com}
|
||||
volumes:
|
||||
- uploads_data:/app/uploads
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.rnotes.rule=Host(`rnotes.online`) || Host(`www.rnotes.online`)"
|
||||
|
|
@ -62,3 +64,4 @@ networks:
|
|||
|
||||
volumes:
|
||||
postgres_data:
|
||||
uploads_data:
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ export async function POST(
|
|||
) {
|
||||
try {
|
||||
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()) {
|
||||
return NextResponse.json({ error: 'Title is required' }, { status: 400 });
|
||||
|
|
@ -60,6 +60,9 @@ export async function POST(
|
|||
type: type || 'NOTE',
|
||||
url: url || null,
|
||||
language: language || null,
|
||||
fileUrl: fileUrl || null,
|
||||
mimeType: mimeType || null,
|
||||
fileSize: fileSize || null,
|
||||
tags: {
|
||||
create: tagRecords.map((tag) => ({
|
||||
tagId: tag.id,
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ export async function GET(request: NextRequest) {
|
|||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
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()) {
|
||||
return NextResponse.json({ error: 'Title is required' }, { status: 400 });
|
||||
|
|
@ -71,6 +71,9 @@ export async function POST(request: NextRequest) {
|
|||
notebookId: notebookId || null,
|
||||
url: url || null,
|
||||
language: language || null,
|
||||
fileUrl: fileUrl || null,
|
||||
mimeType: mimeType || null,
|
||||
fileSize: fileSize || null,
|
||||
tags: {
|
||||
create: tagRecords.map((tag) => ({
|
||||
tagId: tag.id,
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -23,6 +23,9 @@ interface NoteData {
|
|||
type: string;
|
||||
url: string | null;
|
||||
language: string | null;
|
||||
fileUrl: string | null;
|
||||
mimeType: string | null;
|
||||
fileSize: number | null;
|
||||
isPinned: boolean;
|
||||
canvasShapeId: string | null;
|
||||
createdAt: string;
|
||||
|
|
@ -212,6 +215,35 @@ export default function NoteDetailPage() {
|
|||
</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 */}
|
||||
{editing ? (
|
||||
<div className="space-y-4">
|
||||
|
|
|
|||
|
|
@ -4,14 +4,15 @@ import { Suspense, useState, useEffect } from 'react';
|
|||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { NoteEditor } from '@/components/NoteEditor';
|
||||
import { FileUpload } from '@/components/FileUpload';
|
||||
|
||||
const NOTE_TYPES = [
|
||||
{ value: 'NOTE', label: 'Note', desc: 'Rich text note' },
|
||||
{ value: 'CLIP', label: 'Clip', desc: 'Web clipping' },
|
||||
{ value: 'BOOKMARK', label: 'Bookmark', desc: 'Save a URL' },
|
||||
{ value: 'CODE', label: 'Code', desc: 'Code snippet' },
|
||||
{ value: 'IMAGE', label: 'Image', desc: 'Image URL' },
|
||||
{ value: 'FILE', label: 'File', desc: 'File reference' },
|
||||
{ value: 'IMAGE', label: 'Image', desc: 'Upload image' },
|
||||
{ value: 'FILE', label: 'File', desc: 'Upload file' },
|
||||
];
|
||||
|
||||
interface NotebookOption {
|
||||
|
|
@ -45,6 +46,9 @@ function NewNoteForm() {
|
|||
const [url, setUrl] = useState('');
|
||||
const [language, setLanguage] = useState('');
|
||||
const [tags, setTags] = useState('');
|
||||
const [fileUrl, setFileUrl] = useState('');
|
||||
const [mimeType, setMimeType] = useState('');
|
||||
const [fileSize, setFileSize] = useState(0);
|
||||
const [notebookId, setNotebookId] = useState(preselectedNotebook || '');
|
||||
const [notebooks, setNotebooks] = useState<NotebookOption[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
|
@ -71,6 +75,9 @@ function NewNoteForm() {
|
|||
if (notebookId) body.notebookId = notebookId;
|
||||
if (url) body.url = url;
|
||||
if (language) body.language = language;
|
||||
if (fileUrl) body.fileUrl = fileUrl;
|
||||
if (mimeType) body.mimeType = mimeType;
|
||||
if (fileSize) body.fileSize = fileSize;
|
||||
|
||||
const endpoint = notebookId
|
||||
? `/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';
|
||||
|
||||
return (
|
||||
|
|
@ -162,6 +170,53 @@ function NewNoteForm() {
|
|||
</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} · {(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 */}
|
||||
{showLanguage && (
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue