diff --git a/docker-compose.yml b/docker-compose.yml index 15e629a..f7e6873 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/src/app/api/notebooks/[id]/notes/route.ts b/src/app/api/notebooks/[id]/notes/route.ts index 146d2b9..0867a5c 100644 --- a/src/app/api/notebooks/[id]/notes/route.ts +++ b/src/app/api/notebooks/[id]/notes/route.ts @@ -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, diff --git a/src/app/api/notes/route.ts b/src/app/api/notes/route.ts index 8e4c877..af2b5ee 100644 --- a/src/app/api/notes/route.ts +++ b/src/app/api/notes/route.ts @@ -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, diff --git a/src/app/api/uploads/[filename]/route.ts b/src/app/api/uploads/[filename]/route.ts new file mode 100644 index 0000000..b3f3bb2 --- /dev/null +++ b/src/app/api/uploads/[filename]/route.ts @@ -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 = { + '.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 }); + } +} diff --git a/src/app/api/uploads/route.ts b/src/app/api/uploads/route.ts new file mode 100644 index 0000000..79f4059 --- /dev/null +++ b/src/app/api/uploads/route.ts @@ -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 }); + } +} diff --git a/src/app/notes/[id]/page.tsx b/src/app/notes/[id]/page.tsx index 87270f6..0ab4ee0 100644 --- a/src/app/notes/[id]/page.tsx +++ b/src/app/notes/[id]/page.tsx @@ -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() { )} + {/* Uploaded file/image */} + {note.fileUrl && note.type === 'IMAGE' && ( +
+ {note.title} +
+ )} + {note.fileUrl && note.type === 'FILE' && ( +
+ + + +
+

{note.fileUrl.split('/').pop()}

+ {note.mimeType &&

{note.mimeType}{note.fileSize ? ` ยท ${(note.fileSize / 1024).toFixed(1)} KB` : ''}

} +
+ + Download + +
+ )} + {/* Content */} {editing ? (
diff --git a/src/app/notes/new/page.tsx b/src/app/notes/new/page.tsx index a39a897..960729b 100644 --- a/src/app/notes/new/page.tsx +++ b/src/app/notes/new/page.tsx @@ -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([]); 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() {
)} + {/* File upload */} + {showUpload && ( +
+ + {fileUrl ? ( +
+ {type === 'IMAGE' && ( + Preview + )} +
+

{fileUrl.split('/').pop()}

+

{mimeType} · {(fileSize / 1024).toFixed(1)} KB

+
+ +
+ ) : ( + { + setFileUrl(result.url); + setMimeType(result.mimeType); + setFileSize(result.size); + if (!title) setTitle(result.originalName); + }} + /> + )} +
+ + 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" + /> +
+
+ )} + {/* Language field */} {showLanguage && (
diff --git a/src/components/FileUpload.tsx b/src/components/FileUpload.tsx new file mode 100644 index 0000000..0c7be86 --- /dev/null +++ b/src/components/FileUpload.tsx @@ -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(null); + const inputRef = useRef(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) => { + const file = e.target.files?.[0]; + if (file) handleUpload(file); + }, [handleUpload]); + + return ( +
+
{ 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' + }`} + > + + {uploading ? ( +
+ + + + + Uploading... +
+ ) : ( +
+ + + +

Drop a file here or click to browse

+

Max {Math.round(maxSize / 1024 / 1024)}MB

+
+ )} +
+ {error && ( +

{error}

+ )} +
+ ); +}