376 lines
12 KiB
TypeScript
376 lines
12 KiB
TypeScript
import { NextRequest, NextResponse } from 'next/server';
|
|
import { marked } from 'marked';
|
|
import { prisma, ensureDefaultNotebook, getCurrentUserId } from '@/lib/prisma';
|
|
import {
|
|
clipUrlToNote,
|
|
getOrCreateTagIds,
|
|
localizeClippedImages,
|
|
} from '@/lib/importers';
|
|
import {
|
|
saveAttachmentBuffer,
|
|
stripHtmlToText,
|
|
makeExcerpt,
|
|
} from '@/lib/storage';
|
|
import { applyAutoSortToNote } from '@/lib/autoSort';
|
|
|
|
export const dynamic = 'force-dynamic';
|
|
|
|
function extractUrl(text: string): { extractedUrl: string | null; remainingText: string } {
|
|
if (!text) return { extractedUrl: null, remainingText: '' };
|
|
const match = /(https?:\/\/[^\s]+)/i.exec(text);
|
|
if (!match) return { extractedUrl: null, remainingText: text };
|
|
const extractedUrl = match[1];
|
|
const remainingText = text.replace(extractedUrl, '').trim();
|
|
return { extractedUrl, remainingText };
|
|
}
|
|
|
|
function getRedirectUrl(req: NextRequest, noteId?: string, isError?: boolean): URL {
|
|
const host =
|
|
req.headers.get('x-forwarded-host') ||
|
|
req.headers.get('host') ||
|
|
'everclone.vloser.us';
|
|
const proto =
|
|
req.headers.get('x-forwarded-proto') ||
|
|
'https';
|
|
if (isError) {
|
|
return new URL(`${proto}://${host}/?shared_error=1`);
|
|
}
|
|
return new URL(`${proto}://${host}/?noteId=${noteId}&shared=1`);
|
|
}
|
|
|
|
export async function GET(req: NextRequest) {
|
|
try {
|
|
const userId = await getCurrentUserId();
|
|
const defaultNb = await ensureDefaultNotebook(userId);
|
|
const sp = req.nextUrl.searchParams;
|
|
|
|
let title = (sp.get('title') || '').trim();
|
|
let text = (sp.get('text') || sp.get('content') || '').trim();
|
|
let url = (sp.get('url') || '').trim();
|
|
|
|
// Check if URL was shared inside text or title
|
|
if (!url && text) {
|
|
const extracted = extractUrl(text);
|
|
if (extracted.extractedUrl) {
|
|
url = extracted.extractedUrl;
|
|
text = extracted.remainingText;
|
|
}
|
|
}
|
|
if (!url && title) {
|
|
const extracted = extractUrl(title);
|
|
if (extracted.extractedUrl) {
|
|
url = extracted.extractedUrl;
|
|
title = extracted.remainingText;
|
|
}
|
|
}
|
|
if (!url && title && /^https?:\/\/\S+$/i.test(title)) {
|
|
url = title;
|
|
title = '';
|
|
}
|
|
if (url) {
|
|
url = url.replace(/[.,;:)\]]+$/, '');
|
|
}
|
|
if (url && text) {
|
|
const remaining = text.replace(url, '').trim();
|
|
if (
|
|
!remaining ||
|
|
remaining === title ||
|
|
title.includes(remaining) ||
|
|
remaining.includes(title) ||
|
|
(!remaining.includes('\n') && remaining.length < 300)
|
|
) {
|
|
if (!title && remaining) {
|
|
title = remaining;
|
|
}
|
|
text = '';
|
|
}
|
|
}
|
|
|
|
if (url) {
|
|
const recentDup = await prisma.note.findFirst({
|
|
where: {
|
|
userId,
|
|
sourceUrl: url,
|
|
createdAt: {
|
|
gte: new Date(Date.now() - 5 * 60 * 1000),
|
|
},
|
|
},
|
|
orderBy: { createdAt: 'desc' },
|
|
});
|
|
if (recentDup) {
|
|
return NextResponse.redirect(getRedirectUrl(req, recentDup.id), 303);
|
|
}
|
|
}
|
|
|
|
let noteId: string;
|
|
if (url && !text) {
|
|
// Direct web clip
|
|
noteId = await clipUrlToNote(url, {
|
|
title: title || undefined,
|
|
notebookId: defaultNb.id,
|
|
tags: ['inbox', 'web-clip'],
|
|
});
|
|
} else {
|
|
const contentHtml = text
|
|
? `<p>${text.replace(/\n/g, '<br/>')}</p>${
|
|
url ? `<p>Source: <a href="${url}" target="_blank">${url}</a></p>` : ''
|
|
}`
|
|
: `<p><a href="${url}" target="_blank">${url}</a></p>`;
|
|
|
|
const tagIds = await getOrCreateTagIds(['inbox', 'share'], userId);
|
|
const created = await prisma.note.create({
|
|
data: {
|
|
title: title || url || 'Shared Note',
|
|
contentHtml,
|
|
contentText: stripHtmlToText(contentHtml),
|
|
excerpt: makeExcerpt(stripHtmlToText(contentHtml)),
|
|
notebookId: defaultNb.id,
|
|
sourceUrl: url || null,
|
|
tags: {
|
|
create: tagIds.map((tagId) => ({ tagId })),
|
|
},
|
|
},
|
|
});
|
|
noteId = created.id;
|
|
}
|
|
|
|
await applyAutoSortToNote(noteId, userId).catch(() => {});
|
|
|
|
return NextResponse.redirect(getRedirectUrl(req, noteId), 303);
|
|
} catch (err) {
|
|
console.error('GET /share error:', err);
|
|
return NextResponse.redirect(getRedirectUrl(req, undefined, true), 303);
|
|
}
|
|
}
|
|
|
|
export async function POST(req: NextRequest) {
|
|
try {
|
|
const userId = await getCurrentUserId();
|
|
const defaultNb = await ensureDefaultNotebook(userId);
|
|
|
|
const contentType = req.headers.get('content-type') || '';
|
|
let title = '';
|
|
let text = '';
|
|
let url = '';
|
|
const uploadedFiles: File[] = [];
|
|
|
|
if (contentType.includes('application/json')) {
|
|
const body = await req.json();
|
|
title = (body.title || '').trim();
|
|
text = (body.text || body.content || '').trim();
|
|
url = (body.url || '').trim();
|
|
} else {
|
|
const formData = await req.formData();
|
|
title = ((formData.get('title') as string) || '').trim();
|
|
text = (
|
|
(formData.get('text') as string) ||
|
|
(formData.get('content') as string) ||
|
|
''
|
|
).trim();
|
|
url = ((formData.get('url') as string) || '').trim();
|
|
|
|
for (const [key, value] of formData.entries()) {
|
|
if (value && typeof value === 'object' && 'arrayBuffer' in value) {
|
|
const file = value as File;
|
|
if (file.size > 0) {
|
|
uploadedFiles.push(file);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check if Android shared a URL inside `text` or `title`
|
|
if (!url && text) {
|
|
const extracted = extractUrl(text);
|
|
if (extracted.extractedUrl) {
|
|
url = extracted.extractedUrl;
|
|
text = extracted.remainingText;
|
|
}
|
|
}
|
|
if (!url && title) {
|
|
const extracted = extractUrl(title);
|
|
if (extracted.extractedUrl) {
|
|
url = extracted.extractedUrl;
|
|
title = extracted.remainingText;
|
|
}
|
|
}
|
|
if (!url && title && /^https?:\/\/\S+$/i.test(title)) {
|
|
url = title;
|
|
title = '';
|
|
}
|
|
if (url) {
|
|
url = url.replace(/[.,;:)\]]+$/, '');
|
|
}
|
|
if (url && text) {
|
|
const remaining = text.replace(url, '').trim();
|
|
if (
|
|
!remaining ||
|
|
remaining === title ||
|
|
title.includes(remaining) ||
|
|
remaining.includes(title) ||
|
|
(!remaining.includes('\n') && remaining.length < 300)
|
|
) {
|
|
if (!title && remaining) {
|
|
title = remaining;
|
|
}
|
|
text = '';
|
|
}
|
|
}
|
|
|
|
if (url) {
|
|
const recentDup = await prisma.note.findFirst({
|
|
where: {
|
|
userId,
|
|
sourceUrl: url,
|
|
createdAt: {
|
|
gte: new Date(Date.now() - 5 * 60 * 1000),
|
|
},
|
|
},
|
|
orderBy: { createdAt: 'desc' },
|
|
});
|
|
if (recentDup) {
|
|
return NextResponse.redirect(getRedirectUrl(req, recentDup.id), 303);
|
|
}
|
|
}
|
|
|
|
const wantsJson =
|
|
contentType.includes('application/json') ||
|
|
Boolean(req.headers.get('accept')?.includes('application/json'));
|
|
|
|
// 1. If sharing a URL with no uploaded files and no extra body text, clip the webpage
|
|
if (url && !text && uploadedFiles.length === 0) {
|
|
try {
|
|
const clippedId = await clipUrlToNote(url, {
|
|
title: title || undefined,
|
|
notebookId: defaultNb.id,
|
|
tags: ['inbox', 'web-clip'],
|
|
});
|
|
await applyAutoSortToNote(clippedId, userId).catch(() => {});
|
|
if (wantsJson) {
|
|
return NextResponse.json({ ok: true, noteId: clippedId, mode: 'web-clip' }, { status: 201 });
|
|
}
|
|
return NextResponse.redirect(getRedirectUrl(req, clippedId), 303);
|
|
} catch (e) {
|
|
console.warn('URL clipping failed in /share, falling back to basic link note:', e);
|
|
}
|
|
}
|
|
|
|
// 2. Generate base HTML content
|
|
let finalHtml = '';
|
|
if (text) {
|
|
finalHtml = await marked.parse(text, { gfm: true, breaks: true });
|
|
}
|
|
if (url && !finalHtml.includes(url)) {
|
|
finalHtml = `${finalHtml ? `${finalHtml}\n` : ''}<p>Source: <a href="${url}" target="_blank" rel="noopener noreferrer">${url}</a></p>`;
|
|
}
|
|
if (!finalHtml && uploadedFiles.length === 0) {
|
|
finalHtml = '<p></p>';
|
|
}
|
|
|
|
const finalTitle =
|
|
title ||
|
|
stripHtmlToText(finalHtml).slice(0, 60).trim() ||
|
|
(uploadedFiles[0]?.name ? `Attachment: ${uploadedFiles[0].name}` : 'Shared Note');
|
|
|
|
const tagNames = ['inbox', 'share'];
|
|
if (url && !text && uploadedFiles.length === 0) {
|
|
tagNames.push('needs-reclip');
|
|
}
|
|
const tagIds = await getOrCreateTagIds(tagNames, userId);
|
|
|
|
const note = await prisma.note.create({
|
|
data: {
|
|
title: finalTitle,
|
|
contentHtml: finalHtml,
|
|
contentText: stripHtmlToText(finalHtml),
|
|
excerpt: makeExcerpt(stripHtmlToText(finalHtml)),
|
|
notebookId: defaultNb.id,
|
|
sourceUrl: url || null,
|
|
tags: {
|
|
create: tagIds.map((tagId) => ({ tagId })),
|
|
},
|
|
},
|
|
});
|
|
|
|
// 3. Save any uploaded attachments (images, PDFs, audio, documents)
|
|
let appendedMediaHtml = '';
|
|
for (const f of uploadedFiles) {
|
|
const buf = Buffer.from(await f.arrayBuffer());
|
|
const mimeType = f.type || 'application/octet-stream';
|
|
const filename = f.name || 'shared-file';
|
|
const savedBlob = await saveAttachmentBuffer(buf, filename, mimeType);
|
|
const att = await prisma.attachment.create({
|
|
data: {
|
|
noteId: note.id,
|
|
filename,
|
|
mimeType,
|
|
sizeBytes: savedBlob.sizeBytes,
|
|
md5Hash: savedBlob.md5Hash,
|
|
storagePath: savedBlob.storagePath,
|
|
extractedText: savedBlob.extractedText,
|
|
},
|
|
});
|
|
|
|
if (mimeType.startsWith('image/')) {
|
|
appendedMediaHtml += `<p><img src="/api/attachments/${att.id}" alt="${filename}" /></p>\n`;
|
|
} else if (mimeType.startsWith('audio/')) {
|
|
appendedMediaHtml += `<p><audio controls src="/api/attachments/${att.id}"></audio></p>\n`;
|
|
} else if (mimeType.startsWith('video/')) {
|
|
appendedMediaHtml += `<p><video controls src="/api/attachments/${att.id}" style="max-width: 100%; border-radius: 8px;"></video></p>\n`;
|
|
} else if (mimeType === 'application/pdf' || filename.toLowerCase().endsWith('.pdf')) {
|
|
appendedMediaHtml += `<p><a href="/api/attachments/${att.id}" target="_blank" rel="noopener noreferrer">📄 View PDF: ${filename}</a></p>\n`;
|
|
} else {
|
|
appendedMediaHtml += `<p><a href="/api/attachments/${att.id}" target="_blank" rel="noopener noreferrer">📎 ${filename}</a></p>\n`;
|
|
}
|
|
}
|
|
|
|
const firstImgAtt = await prisma.attachment.findFirst({
|
|
where: { noteId: note.id, mimeType: { startsWith: 'image/' } },
|
|
select: { id: true },
|
|
});
|
|
|
|
if (appendedMediaHtml) {
|
|
const combinedHtml = `${finalHtml ? `${finalHtml}\n` : ''}${appendedMediaHtml}`;
|
|
await prisma.note.update({
|
|
where: { id: note.id },
|
|
data: {
|
|
contentHtml: combinedHtml,
|
|
contentText: stripHtmlToText(combinedHtml),
|
|
excerpt: makeExcerpt(stripHtmlToText(combinedHtml)),
|
|
...(firstImgAtt ? { thumbnailUrl: `/api/attachments/${firstImgAtt.id}` } : {}),
|
|
},
|
|
});
|
|
}
|
|
|
|
if (finalHtml.includes('<img')) {
|
|
const localized = await localizeClippedImages(
|
|
note.id,
|
|
finalHtml,
|
|
url || undefined
|
|
);
|
|
if (localized !== finalHtml) {
|
|
await prisma.note.update({
|
|
where: { id: note.id },
|
|
data: { contentHtml: localized },
|
|
});
|
|
}
|
|
}
|
|
|
|
// 4. Run Smart Auto-Sorting & Auto-Tagging
|
|
await applyAutoSortToNote(note.id, userId).catch(() => {});
|
|
|
|
if (wantsJson) {
|
|
return NextResponse.json(
|
|
{ ok: true, noteId: note.id, title: note.title },
|
|
{ status: 201 }
|
|
);
|
|
}
|
|
|
|
// 5. Redirect browser / Android WebAPK straight into Everclone displaying the new note
|
|
return NextResponse.redirect(getRedirectUrl(req, note.id), 303);
|
|
} catch (err) {
|
|
console.error('POST /share error:', err);
|
|
return NextResponse.redirect(getRedirectUrl(req, undefined, true), 303);
|
|
}
|
|
}
|