diff --git a/app/api/_utils/pdfExtractor.ts b/app/api/_utils/pdfExtractor.ts new file mode 100644 index 0000000..ea2e169 --- /dev/null +++ b/app/api/_utils/pdfExtractor.ts @@ -0,0 +1,39 @@ +const pdfParse = require('pdf-parse'); + +export interface ExtractPdfResult { + text: string; + numpages: number; + info?: any; +} + +/** + * Extract clean plain text from a PDF Buffer + * @param dataBuffer Buffer containing binary PDF contents + * @param maxChars Maximum characters to return (default 50,000) + */ +export async function extractTextFromPdf(dataBuffer: Buffer, maxChars: number = 50000): Promise { + try { + const data = await pdfParse(dataBuffer); + let rawText = data.text || ''; + + // Clean up excessive whitespace, control characters, and line breaks + rawText = rawText + .replace(/\r\n/g, '\n') + .replace(/[ \t]+/g, ' ') + .replace(/\n{3,}/g, '\n\n') + .trim(); + + if (rawText.length > maxChars) { + rawText = rawText.slice(0, maxChars) + '\n\n[Content truncated due to size limits]'; + } + + return { + text: rawText, + numpages: data.numpages || 1, + info: data.info || null, + }; + } catch (error) { + console.error('Error parsing PDF buffer:', error); + throw new Error('Failed to parse PDF document. The file may be password-protected or corrupted.'); + } +} diff --git a/app/api/resources/pdf/route.ts b/app/api/resources/pdf/route.ts new file mode 100644 index 0000000..f830f4b --- /dev/null +++ b/app/api/resources/pdf/route.ts @@ -0,0 +1,120 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { isAuthError, requireAuth, unauthorizedResponse } from '../../_utils/auth'; +import { getServerFirestore } from '../../_utils/firebaseAdmin'; +import { extractTextFromPdf } from '../../_utils/pdfExtractor'; +import { checkAuthenticatedRateLimit } from '../../_utils/rateLimit'; +import { indexResource } from '../../_utils/resourceIndexer'; + +export async function POST(request: NextRequest) { + try { + const authUser = await requireAuth(request); + + // Rate limit: 60 authenticated requests per minute per user + const authRateLimitResponse = await checkAuthenticatedRateLimit(request, authUser.uid); + if (authRateLimitResponse) return authRateLimitResponse; + + const formData = await request.formData(); + const file = formData.get('file') as File | null; + const customTitle = (formData.get('title') as string) || ''; + const note = (formData.get('note') as string) || ''; + const isPublic = formData.get('is_public') === 'true'; + const collectionIdsRaw = formData.get('collection_ids') as string | null; + + if (!file) { + return NextResponse.json( + { error: 'No PDF file uploaded.' }, + { status: 400 } + ); + } + + // Validate size (10MB max = 10 * 1024 * 1024 bytes) + const MAX_SIZE = 10 * 1024 * 1024; + if (file.size > MAX_SIZE) { + return NextResponse.json( + { error: 'File size exceeds 10MB limit.' }, + { status: 400 } + ); + } + + // Validate mime type / extension + if (!file.name.toLowerCase().endsWith('.pdf') && file.type !== 'application/pdf') { + return NextResponse.json( + { error: 'Uploaded file must be a PDF document.' }, + { status: 400 } + ); + } + + // Read file buffer + const arrayBuffer = await file.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + + // Extract text using pdf-parse + const pdfData = await extractTextFromPdf(buffer); + if (!pdfData.text || pdfData.text.trim().length === 0) { + return NextResponse.json( + { error: 'Could not extract readable text from this PDF (it may be a scanned image-only PDF).' }, + { status: 422 } + ); + } + + const title = customTitle.trim() || file.name.replace(/\.pdf$/i, ''); + let collection_ids: string[] = []; + if (collectionIdsRaw) { + try { + collection_ids = JSON.parse(collectionIdsRaw); + } catch { + collection_ids = []; + } + } + + const db = getServerFirestore(); + const now = new Date(); + const resourceRef = db.collection('resources').doc(); + + const resourceData = { + user_id: authUser.uid, + title, + link: null, + note: note || null, + tag: 'PDF', + is_public: isPublic, + collection_ids, + captured_text: pdfData.text, + pdf_metadata: { + file_name: file.name, + file_size: file.size, + num_pages: pdfData.numpages, + }, + index_status: 'pending', + index_error: null, + created_at: now, + updated_at: now, + }; + + await resourceRef.set(resourceData); + + // Trigger AI indexing in background + indexResource({ resourceId: resourceRef.id, uid: authUser.uid }).catch((err) => { + console.error('Failed to index PDF resource:', err); + }); + + return NextResponse.json({ + success: true, + resource: { + id: resourceRef.id, + ...resourceData, + }, + }); + + } catch (error) { + if (isAuthError(error)) { + return unauthorizedResponse(); + } + + console.error('Error processing PDF upload:', error); + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Internal server error processing PDF' }, + { status: 500 } + ); + } +} diff --git a/app/components/AddResource.tsx b/app/components/AddResource.tsx index 45f2a21..a59582b 100644 --- a/app/components/AddResource.tsx +++ b/app/components/AddResource.tsx @@ -1,6 +1,6 @@ 'use client' -import { CheckCircle2, FolderPlus, Globe, Link2, Loader2, Lock, Plus, Sparkles } from 'lucide-react' +import { CheckCircle2, FileText, FolderPlus, Globe, Link2, Loader2, Lock, Plus, Sparkles, Upload } from 'lucide-react' import { FormEvent, useEffect, useRef, useState } from 'react' import { useAuth } from '../contexts/AuthContext' import { useCollections } from '../contexts/CollectionsContext' @@ -21,6 +21,8 @@ const TAGS = [ 'Book', 'Podcast', 'Newsletter', + 'PDF', + 'Note', 'Other', ] @@ -41,7 +43,8 @@ export function AddResource({ onSuccess }: AddResourceProps) { const [showShareModal, setShowShareModal] = useState(false) const [sharedResourceData, setSharedResourceData] = useState<{ title: string; note?: string; link: string }>({ title: '', note: '', link: '' }) const [username, setUsername] = useState('') - const [resourceType, setResourceType] = useState<'link' | 'note'>('link') + const [resourceType, setResourceType] = useState<'link' | 'note' | 'pdf'>('link') + const [pdfFile, setPdfFile] = useState(null) useEffect(() => { if (user) { @@ -101,6 +104,46 @@ export function AddResource({ onSuccess }: AddResourceProps) { setError('') try { + if (resourceType === 'pdf') { + if (!pdfFile) { + throw new Error('Please select a PDF file to upload.') + } + + const formData = new FormData() + formData.append('file', pdfFile) + if (title) formData.append('title', title) + if (note) formData.append('note', note) + formData.append('is_public', isPublic ? 'true' : 'false') + + const normalizedCollectionIds = selectedCollectionId && selectedCollectionId !== 'none' && selectedCollectionId !== 'new' + ? [selectedCollectionId] + : [] + formData.append('collection_ids', JSON.stringify(normalizedCollectionIds)) + + const idToken = await user.getIdToken() + const response = await fetch('/api/resources/pdf', { + method: 'POST', + headers: { + Authorization: `Bearer ${idToken}`, + }, + body: formData, + }) + + if (!response.ok) { + const errorData = await response.json() + throw new Error(errorData.error || 'Failed to process PDF upload') + } + + setSharedResourceData({ title: title || pdfFile.name, note, link: '' }) + setTitle('') + setNote('') + setPdfFile(null) + setIsPublic(false) + refreshCollections().catch(() => {}) + setShowShareModal(true) + return + } + const payload: any = { title, link: resourceType === 'link' ? link : '', @@ -160,12 +203,12 @@ export function AddResource({ onSuccess }: AddResourceProps) { Capture

Save a source for AI search

- Add a link once. DumpIt stores the resource, queues indexing, and makes the source available to Ask DumpIt according to its visibility. + Add links, notes, or PDFs. DumpIt indexes the content and makes it available to Ask DumpIt according to its visibility.

- Indexed resources can be cited in answers. + Indexed sources are cited in AI search answers.
@@ -199,6 +242,17 @@ export function AddResource({ onSuccess }: AddResourceProps) { > Create Note +
@@ -241,9 +295,55 @@ export function AddResource({ onSuccess }: AddResourceProps) { )} + {resourceType === 'pdf' && ( +
+ +
+ +
+
+ )} +
setTitle(event.target.value)} className="app-input mt-2" - placeholder={resourceType === 'link' ? 'Name this source' : 'Brief summary or topic'} - required + placeholder={resourceType === 'pdf' ? 'Name of this document' : resourceType === 'link' ? 'Name this source' : 'Brief summary or topic'} + required={resourceType !== 'pdf'} />