Merge remote-tracking branch 'upstream/dev' into tags-in-public-collection

This commit is contained in:
Oliver Schwamb
2024-10-30 12:10:30 +01:00
266 changed files with 18575 additions and 6564 deletions
+69 -287
View File
@@ -1,15 +1,16 @@
import { LaunchOptions, chromium, devices } from "playwright";
import { prisma } from "./db";
import createFile from "./storage/createFile";
import sendToWayback from "./sendToWayback";
import { Readability } from "@mozilla/readability";
import { JSDOM } from "jsdom";
import DOMPurify from "dompurify";
import sendToWayback from "./preservationScheme/sendToWayback";
import { Collection, Link, User } from "@prisma/client";
import validateUrlSize from "./validateUrlSize";
import removeFile from "./storage/removeFile";
import Jimp from "jimp";
import fetchHeaders from "./fetchHeaders";
import createFolder from "./storage/createFolder";
import { removeFiles } from "./manageLinkFiles";
import handleMonolith from "./preservationScheme/handleMonolith";
import handleReadablility from "./preservationScheme/handleReadablility";
import handleArchivePreview from "./preservationScheme/handleArchivePreview";
import handleScreenshotAndPdf from "./preservationScheme/handleScreenshotAndPdf";
import imageHandler from "./preservationScheme/imageHandler";
import pdfHandler from "./preservationScheme/pdfHandler";
type LinksAndCollectionAndOwner = Link & {
collection: Collection & {
@@ -20,6 +21,18 @@ type LinksAndCollectionAndOwner = Link & {
const BROWSER_TIMEOUT = Number(process.env.BROWSER_TIMEOUT) || 5;
export default async function archiveHandler(link: LinksAndCollectionAndOwner) {
const timeoutPromise = new Promise((_, reject) => {
setTimeout(
() =>
reject(
new Error(
`Browser has been open for more than ${BROWSER_TIMEOUT} minutes.`
)
),
BROWSER_TIMEOUT * 60000
);
});
// allow user to configure a proxy
let browserOptions: LaunchOptions = {};
if (process.env.PROXY) {
@@ -39,32 +52,22 @@ export default async function archiveHandler(link: LinksAndCollectionAndOwner) {
const page = await context.newPage();
const timeoutPromise = new Promise((_, reject) => {
setTimeout(
() =>
reject(
new Error(
`Browser has been open for more than ${BROWSER_TIMEOUT} minutes.`
)
),
BROWSER_TIMEOUT * 60000
);
createFolder({
filePath: `archives/preview/${link.collectionId}`,
});
createFolder({
filePath: `archives/${link.collectionId}`,
});
try {
await Promise.race([
(async () => {
const validatedUrl = link.url
? await validateUrlSize(link.url)
: undefined;
const user = link.collection?.owner;
if (
validatedUrl === null &&
process.env.IGNORE_URL_SIZE_LIMIT !== "true"
)
throw "Something went wrong while retrieving the file size.";
const header = link.url ? await fetchHeaders(link.url) : undefined;
const contentType = validatedUrl?.get("content-type");
const contentType = header?.get("content-type");
let linkType = "url";
let imageExtension = "png";
@@ -76,23 +79,22 @@ export default async function archiveHandler(link: LinksAndCollectionAndOwner) {
else if (contentType.includes("image/png")) imageExtension = "png";
}
const user = link.collection?.owner;
// send to archive.org
if (user.archiveAsWaybackMachine && link.url) sendToWayback(link.url);
const targetLink = await prisma.link.update({
await prisma.link.update({
where: { id: link.id },
data: {
type: linkType,
image:
user.archiveAsScreenshot && !link.image?.startsWith("archive")
? "pending"
: "unavailable",
: undefined,
pdf:
user.archiveAsPDF && !link.pdf?.startsWith("archive")
? "pending"
: "unavailable",
: undefined,
monolith:
user.archiveAsMonolith && !link.monolith?.startsWith("archive")
? "pending"
: undefined,
readable: !link.readable?.startsWith("archive")
? "pending"
: undefined,
@@ -103,6 +105,9 @@ export default async function archiveHandler(link: LinksAndCollectionAndOwner) {
},
});
// send to archive.org
if (user.archiveAsWaybackMachine && link.url) sendToWayback(link.url);
if (linkType === "image" && !link.image?.startsWith("archive")) {
await imageHandler(link, imageExtension); // archive image (jpeg/png)
return;
@@ -116,183 +121,37 @@ export default async function archiveHandler(link: LinksAndCollectionAndOwner) {
const content = await page.content();
// TODO single file
// const session = await page.context().newCDPSession(page);
// const doc = await session.send("Page.captureSnapshot", {
// format: "mhtml",
// });
// const saveDocLocally = (doc: any) => {
// console.log(doc);
// return createFile({
// data: doc,
// filePath: `archives/${targetLink.collectionId}/${link.id}.mhtml`,
// });
// };
// saveDocLocally(doc.data);
// Preview
if (
!link.preview?.startsWith("archives") &&
!link.preview?.startsWith("unavailable")
)
await handleArchivePreview(link, page);
// Readability
const window = new JSDOM("").window;
const purify = DOMPurify(window);
const cleanedUpContent = purify.sanitize(content);
const dom = new JSDOM(cleanedUpContent, { url: link.url || "" });
const article = new Readability(dom.window.document).parse();
const articleText = article?.textContent
.replace(/ +(?= )/g, "") // strip out multiple spaces
.replace(/(\r\n|\n|\r)/gm, " "); // strip out line breaks
if (
articleText &&
articleText !== "" &&
!link.readable?.startsWith("archive")
) {
await createFile({
data: JSON.stringify(article),
filePath: `archives/${targetLink.collectionId}/${link.id}_readability.json`,
});
await prisma.link.update({
where: { id: link.id },
data: {
readable: `archives/${targetLink.collectionId}/${link.id}_readability.json`,
textContent: articleText,
},
});
}
// Preview
const ogImageUrl = await page.evaluate(() => {
const metaTag = document.querySelector('meta[property="og:image"]');
return metaTag ? (metaTag as any).content : null;
});
createFolder({
filePath: `archives/preview/${link.collectionId}`,
});
if (ogImageUrl) {
console.log("Found og:image URL:", ogImageUrl);
// Download the image
const imageResponse = await page.goto(ogImageUrl);
// Check if imageResponse is not null
if (imageResponse && !link.preview?.startsWith("archive")) {
const buffer = await imageResponse.body();
// Check if buffer is not null
if (buffer) {
// Load the image using Jimp
Jimp.read(buffer, async (err, image) => {
if (image && !err) {
image?.resize(1280, Jimp.AUTO).quality(20);
const processedBuffer = await image?.getBufferAsync(
Jimp.MIME_JPEG
);
createFile({
data: processedBuffer,
filePath: `archives/preview/${link.collectionId}/${link.id}.jpeg`,
}).then(() => {
return prisma.link.update({
where: { id: link.id },
data: {
preview: `archives/preview/${link.collectionId}/${link.id}.jpeg`,
},
});
});
}
}).catch((err) => {
console.error("Error processing the image:", err);
});
} else {
console.log("No image data found.");
}
}
await page.goBack();
} else if (!link.preview?.startsWith("archive")) {
console.log("No og:image found");
await page
.screenshot({ type: "jpeg", quality: 20 })
.then((screenshot) => {
return createFile({
data: screenshot,
filePath: `archives/preview/${link.collectionId}/${link.id}.jpeg`,
});
})
.then(() => {
return prisma.link.update({
where: { id: link.id },
data: {
preview: `archives/preview/${link.collectionId}/${link.id}.jpeg`,
},
});
});
}
!link.readable?.startsWith("archives") &&
!link.readable?.startsWith("unavailable")
)
await handleReadablility(content, link);
// Screenshot/PDF
await page.evaluate(
autoScroll,
Number(process.env.AUTOSCROLL_TIMEOUT) || 30
);
if (
(!link.image?.startsWith("archives") &&
!link.image?.startsWith("unavailable")) ||
(!link.pdf?.startsWith("archives") &&
!link.pdf?.startsWith("unavailable"))
)
await handleScreenshotAndPdf(link, page, user);
// Check if the user hasn't deleted the link by the time we're done scrolling
const linkExists = await prisma.link.findUnique({
where: { id: link.id },
});
if (linkExists) {
const processingPromises = [];
if (
user.archiveAsScreenshot &&
!link.image?.startsWith("archive")
) {
processingPromises.push(
page.screenshot({ fullPage: true }).then((screenshot) => {
return createFile({
data: screenshot,
filePath: `archives/${linkExists.collectionId}/${link.id}.png`,
});
})
);
}
// apply administrator's defined pdf margins or default to 15px
const margins = {
top: process.env.PDF_MARGIN_TOP || "15px",
bottom: process.env.PDF_MARGIN_BOTTOM || "15px",
};
if (user.archiveAsPDF && !link.pdf?.startsWith("archive")) {
processingPromises.push(
page
.pdf({
width: "1366px",
height: "1931px",
printBackground: true,
margin: margins,
})
.then((pdf) => {
return createFile({
data: pdf,
filePath: `archives/${linkExists.collectionId}/${link.id}.pdf`,
});
})
);
}
await Promise.allSettled(processingPromises);
await prisma.link.update({
where: { id: link.id },
data: {
image: user.archiveAsScreenshot
? `archives/${linkExists.collectionId}/${link.id}.png`
: undefined,
pdf: user.archiveAsPDF
? `archives/${linkExists.collectionId}/${link.id}.pdf`
: undefined,
},
});
}
// Monolith
if (
!link.monolith?.startsWith("archive") &&
!link.monolith?.startsWith("unavailable") &&
user.archiveAsMonolith &&
link.url
)
await handleMonolith(link, content);
}
})(),
timeoutPromise,
@@ -317,6 +176,9 @@ export default async function archiveHandler(link: LinksAndCollectionAndOwner) {
image: !finalLink.image?.startsWith("archives")
? "unavailable"
: undefined,
monolith: !finalLink.monolith?.startsWith("archives")
? "unavailable"
: undefined,
pdf: !finalLink.pdf?.startsWith("archives")
? "unavailable"
: undefined,
@@ -326,89 +188,9 @@ export default async function archiveHandler(link: LinksAndCollectionAndOwner) {
},
});
else {
removeFile({ filePath: `archives/${link.collectionId}/${link.id}.png` });
removeFile({ filePath: `archives/${link.collectionId}/${link.id}.pdf` });
removeFile({
filePath: `archives/${link.collectionId}/${link.id}_readability.json`,
});
removeFile({
filePath: `archives/preview/${link.collectionId}/${link.id}.jpeg`,
});
await removeFiles(link.id, link.collectionId);
}
await browser.close();
}
}
const autoScroll = async (AUTOSCROLL_TIMEOUT: number) => {
const timeoutPromise = new Promise<void>((_, reject) => {
setTimeout(() => {
reject(new Error(`Webpage was too long to be archived.`));
}, AUTOSCROLL_TIMEOUT * 1000);
});
const scrollingPromise = new Promise<void>((resolve) => {
let totalHeight = 0;
let distance = 100;
let scrollDown = setInterval(() => {
let scrollHeight = document.body.scrollHeight;
window.scrollBy(0, distance);
totalHeight += distance;
if (totalHeight >= scrollHeight) {
clearInterval(scrollDown);
window.scroll(0, 0);
resolve();
}
}, 100);
});
await Promise.race([scrollingPromise, timeoutPromise]);
};
const imageHandler = async ({ url, id }: Link, extension: string) => {
const image = await fetch(url as string).then((res) => res.blob());
const buffer = Buffer.from(await image.arrayBuffer());
const linkExists = await prisma.link.findUnique({
where: { id },
});
if (linkExists) {
await createFile({
data: buffer,
filePath: `archives/${linkExists.collectionId}/${id}.${extension}`,
});
await prisma.link.update({
where: { id },
data: {
image: `archives/${linkExists.collectionId}/${id}.${extension}`,
},
});
}
};
const pdfHandler = async ({ url, id }: Link) => {
const pdf = await fetch(url as string).then((res) => res.blob());
const buffer = Buffer.from(await pdf.arrayBuffer());
const linkExists = await prisma.link.findUnique({
where: { id },
});
if (linkExists) {
await createFile({
data: buffer,
filePath: `archives/${linkExists.collectionId}/${id}.pdf`,
});
await prisma.link.update({
where: { id },
data: {
pdf: `archives/${linkExists.collectionId}/${id}.pdf`,
},
});
}
};
@@ -1,6 +1,6 @@
import { prisma } from "@/lib/api/db";
import getPermission from "@/lib/api/getPermission";
import { Collection, UsersAndCollections } from "@prisma/client";
import { UsersAndCollections } from "@prisma/client";
import removeFolder from "@/lib/api/storage/removeFolder";
export default async function deleteCollection(
@@ -57,7 +57,8 @@ export default async function deleteCollection(
},
});
await removeFolder({ filePath: `archives/${collectionId}` });
removeFolder({ filePath: `archives/${collectionId}` });
removeFolder({ filePath: `archives/preview/${collectionId}` });
await removeFromOrders(userId, collectionId);
@@ -99,7 +100,8 @@ async function deleteSubCollections(collectionId: number) {
where: { id: subCollection.id },
});
await removeFolder({ filePath: `archives/${subCollection.id}` });
removeFolder({ filePath: `archives/${subCollection.id}` });
removeFolder({ filePath: `archives/preview/${subCollection.id}` });
}
}
@@ -1,15 +1,31 @@
import { prisma } from "@/lib/api/db";
import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
import getPermission from "@/lib/api/getPermission";
import {
UpdateCollectionSchema,
UpdateCollectionSchemaType,
} from "@/lib/shared/schemaValidation";
export default async function updateCollection(
userId: number,
collectionId: number,
data: CollectionIncludingMembersAndLinkCount
body: UpdateCollectionSchemaType
) {
if (!collectionId)
return { response: "Please choose a valid collection.", status: 401 };
const dataValidation = UpdateCollectionSchema.safeParse(body);
if (!dataValidation.success) {
return {
response: `Error: ${
dataValidation.error.issues[0].message
} [${dataValidation.error.issues[0].path.join(", ")}]`,
status: 400,
};
}
const data = dataValidation.data;
const collectionIsAccessible = await getPermission({
userId,
collectionId,
@@ -18,8 +34,6 @@ export default async function updateCollection(
if (!(collectionIsAccessible?.ownerId === userId))
return { response: "Collection is not accessible.", status: 401 };
console.log(data);
if (data.parentId) {
if (data.parentId !== ("root" as any)) {
const findParentCollection = await prisma.collection.findUnique({
@@ -61,6 +75,8 @@ export default async function updateCollection(
name: data.name.trim(),
description: data.description,
color: data.color,
icon: data.icon,
iconWeight: data.iconWeight,
isPublic: data.isPublic,
tagsArePublic: data.tagsArePublic,
parent:
@@ -77,7 +93,7 @@ export default async function updateCollection(
: undefined,
members: {
create: data.members.map((e) => ({
user: { connect: { id: e.user.id || e.userId } },
user: { connect: { id: e.userId } },
canCreate: e.canCreate,
canUpdate: e.canUpdate,
canDelete: e.canDelete,
@@ -1,16 +1,26 @@
import { prisma } from "@/lib/api/db";
import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
import createFolder from "@/lib/api/storage/createFolder";
import {
PostCollectionSchema,
PostCollectionSchemaType,
} from "@/lib/shared/schemaValidation";
export default async function postCollection(
collection: CollectionIncludingMembersAndLinkCount,
body: PostCollectionSchemaType,
userId: number
) {
if (!collection || collection.name.trim() === "")
const dataValidation = PostCollectionSchema.safeParse(body);
if (!dataValidation.success) {
return {
response: "Please enter a valid collection.",
response: `Error: ${
dataValidation.error.issues[0].message
} [${dataValidation.error.issues[0].path.join(", ")}]`,
status: 400,
};
}
const collection = dataValidation.data;
if (collection.parentId) {
const findParentCollection = await prisma.collection.findUnique({
@@ -42,6 +52,8 @@ export default async function postCollection(
name: collection.name.trim(),
description: collection.description,
color: collection.color,
icon: collection.icon,
iconWeight: collection.iconWeight,
parent: collection.parentId
? {
connect: {
@@ -1,11 +1,11 @@
import { prisma } from "@/lib/api/db";
import { LinkRequestQuery, Sort } from "@/types/global";
import { LinkRequestQuery, Order, Sort } from "@/types/global";
export default async function getDashboardData(
userId: number,
query: LinkRequestQuery
) {
let order: any;
let order: Order = { id: "desc" };
if (query.sort === Sort.DateNewestFirst) order = { id: "desc" };
else if (query.sort === Sort.DateOldestFirst) order = { id: "asc" };
else if (query.sort === Sort.NameAZ) order = { name: "asc" };
@@ -14,7 +14,7 @@ export default async function getDashboardData(
else if (query.sort === Sort.DescriptionZA) order = { description: "desc" };
const pinnedLinks = await prisma.link.findMany({
take: 8,
take: 10,
where: {
AND: [
{
@@ -42,11 +42,11 @@ export default async function getDashboardData(
select: { id: true },
},
},
orderBy: order || { id: "desc" },
orderBy: order,
});
const recentlyAddedLinks = await prisma.link.findMany({
take: 8,
take: 10,
where: {
collection: {
OR: [
@@ -67,10 +67,18 @@ export default async function getDashboardData(
select: { id: true },
},
},
orderBy: order || { id: "desc" },
orderBy: order,
});
const links = [...recentlyAddedLinks, ...pinnedLinks].sort(
const combinedLinks = [...recentlyAddedLinks, ...pinnedLinks];
const uniqueLinks = Array.from(
combinedLinks
.reduce((map, item) => map.set(item.id, item), new Map())
.values()
);
const links = uniqueLinks.sort(
(a, b) => (new Date(b.id) as any) - (new Date(a.id) as any)
);
@@ -0,0 +1,124 @@
import { prisma } from "@/lib/api/db";
import { LinkRequestQuery, Order, Sort } from "@/types/global";
type Response<D> =
| {
data: D;
message: string;
status: number;
}
| {
data: D;
message: string;
status: number;
};
export default async function getDashboardData(
userId: number,
query: LinkRequestQuery
): Promise<Response<any>> {
let order: Order = { id: "desc" };
if (query.sort === Sort.DateNewestFirst) order = { id: "desc" };
else if (query.sort === Sort.DateOldestFirst) order = { id: "asc" };
else if (query.sort === Sort.NameAZ) order = { name: "asc" };
else if (query.sort === Sort.NameZA) order = { name: "desc" };
else if (query.sort === Sort.DescriptionAZ) order = { description: "asc" };
else if (query.sort === Sort.DescriptionZA) order = { description: "desc" };
const numberOfPinnedLinks = await prisma.link.count({
where: {
AND: [
{
collection: {
OR: [
{ ownerId: userId },
{
members: {
some: { userId },
},
},
],
},
},
{
pinnedBy: { some: { id: userId } },
},
],
},
});
const pinnedLinks = await prisma.link.findMany({
take: 16,
where: {
AND: [
{
collection: {
OR: [
{ ownerId: userId },
{
members: {
some: { userId },
},
},
],
},
},
{
pinnedBy: { some: { id: userId } },
},
],
},
include: {
tags: true,
collection: true,
pinnedBy: {
where: { id: userId },
select: { id: true },
},
},
orderBy: order || { id: "desc" },
});
const recentlyAddedLinks = await prisma.link.findMany({
take: 16,
where: {
collection: {
OR: [
{ ownerId: userId },
{
members: {
some: { userId },
},
},
],
},
},
include: {
tags: true,
collection: true,
pinnedBy: {
where: { id: userId },
select: { id: true },
},
},
orderBy: order || { id: "desc" },
});
const links = [...recentlyAddedLinks, ...pinnedLinks].sort(
(a, b) => new Date(b.id).getTime() - new Date(a.id).getTime()
);
// Make sure links are unique
const uniqueLinks = links.filter(
(link, index, self) => index === self.findIndex((t) => t.id === link.id)
);
return {
data: {
links: uniqueLinks,
numberOfPinnedLinks,
},
message: "Dashboard data fetched successfully.",
status: 200,
};
}
@@ -1,7 +1,7 @@
import { prisma } from "@/lib/api/db";
import { UsersAndCollections } from "@prisma/client";
import getPermission from "@/lib/api/getPermission";
import removeFile from "@/lib/api/storage/removeFile";
import { removeFiles } from "@/lib/api/manageLinkFiles";
export default async function deleteLinksById(
userId: number,
@@ -43,15 +43,7 @@ export default async function deleteLinksById(
const linkId = linkIds[i];
const collectionIsAccessible = collectionIsAccessibleArray[i];
removeFile({
filePath: `archives/${collectionIsAccessible?.id}/${linkId}.pdf`,
});
removeFile({
filePath: `archives/${collectionIsAccessible?.id}/${linkId}.png`,
});
removeFile({
filePath: `archives/${collectionIsAccessible?.id}/${linkId}_readability.json`,
});
if (collectionIsAccessible) removeFiles(linkId, collectionIsAccessible.id);
}
return { response: deletedLinks, status: 200 };
@@ -1,9 +1,10 @@
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import updateLinkById from "../linkId/updateLinkById";
import { UpdateLinkSchemaType } from "@/lib/shared/schemaValidation";
export default async function updateLinks(
userId: number,
links: LinkIncludingShortenedCollectionAndTags[],
links: UpdateLinkSchemaType[],
removePreviousTags: boolean,
newData: Pick<
LinkIncludingShortenedCollectionAndTags,
@@ -22,7 +23,7 @@ export default async function updateLinks(
updatedTags = [...(newData.tags ?? [])];
}
const updatedData: LinkIncludingShortenedCollectionAndTags = {
const updatedData: UpdateLinkSchemaType = {
...link,
tags: updatedTags,
collection: {
+6 -5
View File
@@ -1,10 +1,11 @@
import { prisma } from "@/lib/api/db";
import { LinkRequestQuery, Sort } from "@/types/global";
import { LinkRequestQuery, Order, Sort } from "@/types/global";
export default async function getLink(userId: number, query: LinkRequestQuery) {
const POSTGRES_IS_ENABLED = process.env.DATABASE_URL.startsWith("postgresql");
const POSTGRES_IS_ENABLED =
process.env.DATABASE_URL?.startsWith("postgresql");
let order: any;
let order: Order = { id: "desc" };
if (query.sort === Sort.DateNewestFirst) order = { id: "desc" };
else if (query.sort === Sort.DateOldestFirst) order = { id: "asc" };
else if (query.sort === Sort.NameAZ) order = { name: "asc" };
@@ -102,7 +103,7 @@ export default async function getLink(userId: number, query: LinkRequestQuery) {
}
const links = await prisma.link.findMany({
take: Number(process.env.PAGINATION_TAKE_COUNT) || 20,
take: Number(process.env.PAGINATION_TAKE_COUNT) || 50,
skip: query.cursor ? 1 : undefined,
cursor: query.cursor ? { id: query.cursor } : undefined,
where: {
@@ -145,7 +146,7 @@ export default async function getLink(userId: number, query: LinkRequestQuery) {
select: { id: true },
},
},
orderBy: order || { id: "desc" },
orderBy: order,
});
return { response: links, status: 200 };
@@ -1,7 +1,7 @@
import { prisma } from "@/lib/api/db";
import { Link, UsersAndCollections } from "@prisma/client";
import getPermission from "@/lib/api/getPermission";
import removeFile from "@/lib/api/storage/removeFile";
import { removeFiles } from "@/lib/api/manageLinkFiles";
export default async function deleteLink(userId: number, linkId: number) {
if (!linkId) return { response: "Please choose a valid link.", status: 401 };
@@ -12,7 +12,10 @@ export default async function deleteLink(userId: number, linkId: number) {
(e: UsersAndCollections) => e.userId === userId && e.canDelete
);
if (!(collectionIsAccessible?.ownerId === userId || memberHasAccess))
if (
!collectionIsAccessible ||
!(collectionIsAccessible?.ownerId === userId || memberHasAccess)
)
return { response: "Collection is not accessible.", status: 401 };
const deleteLink: Link = await prisma.link.delete({
@@ -21,15 +24,7 @@ export default async function deleteLink(userId: number, linkId: number) {
},
});
removeFile({
filePath: `archives/${collectionIsAccessible?.id}/${linkId}.pdf`,
});
removeFile({
filePath: `archives/${collectionIsAccessible?.id}/${linkId}.png`,
});
removeFile({
filePath: `archives/${collectionIsAccessible?.id}/${linkId}_readability.json`,
});
removeFiles(linkId, collectionIsAccessible.id);
return { response: deleteLink, status: 200 };
}
@@ -1,19 +1,30 @@
import { prisma } from "@/lib/api/db";
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import { UsersAndCollections } from "@prisma/client";
import getPermission from "@/lib/api/getPermission";
import moveFile from "@/lib/api/storage/moveFile";
import { moveFiles, removeFiles } from "@/lib/api/manageLinkFiles";
import isValidUrl from "@/lib/shared/isValidUrl";
import {
UpdateLinkSchema,
UpdateLinkSchemaType,
} from "@/lib/shared/schemaValidation";
export default async function updateLinkById(
userId: number,
linkId: number,
data: LinkIncludingShortenedCollectionAndTags
body: UpdateLinkSchemaType
) {
if (!data || !data.collection.id)
const dataValidation = UpdateLinkSchema.safeParse(body);
if (!dataValidation.success) {
return {
response: "Please choose a valid link and collection.",
status: 401,
response: `Error: ${
dataValidation.error.issues[0].message
} [${dataValidation.error.issues[0].path.join(", ")}]`,
status: 400,
};
}
const data = dataValidation.data;
const collectionIsAccessible = await getPermission({ userId, linkId });
@@ -25,17 +36,18 @@ export default async function updateLinkById(
(e: UsersAndCollections) => e.userId === userId
);
// If the user is able to create a link, they can pin it to their dashboard only.
if (canPinPermission) {
// If the user is part of a collection, they can pin it to their dashboard
if (canPinPermission && data.pinnedBy && data.pinnedBy[0]) {
const updatedLink = await prisma.link.update({
where: {
id: linkId,
},
data: {
pinnedBy:
data?.pinnedBy && data.pinnedBy[0]
pinnedBy: data?.pinnedBy
? data.pinnedBy[0]?.id === userId
? { connect: { id: userId } }
: { disconnect: { id: userId } },
: { disconnect: { id: userId } }
: undefined,
},
include: {
collection: true,
@@ -60,23 +72,13 @@ export default async function updateLinkById(
(e: UsersAndCollections) => e.userId === userId && e.canUpdate
);
const targetCollectionsAccessible =
targetCollectionIsAccessible?.ownerId === userId;
const targetCollectionMatchesData = data.collection.id
? data.collection.id === targetCollectionIsAccessible?.id
: true && data.collection.name
? data.collection.name === targetCollectionIsAccessible?.name
: true && data.collection.ownerId
? data.collection.ownerId === targetCollectionIsAccessible?.ownerId
: true;
: true && data.collection.ownerId
? data.collection.ownerId === targetCollectionIsAccessible?.ownerId
: true;
if (!targetCollectionsAccessible)
return {
response: "Target collection is not accessible.",
status: 401,
};
else if (!targetCollectionMatchesData)
if (!targetCollectionMatchesData)
return {
response: "Target collection does not match the data.",
status: 401,
@@ -97,13 +99,41 @@ export default async function updateLinkById(
status: 401,
};
else {
const oldLink = await prisma.link.findUnique({
where: {
id: linkId,
},
});
if (
data.url &&
oldLink &&
oldLink?.url !== data.url &&
isValidUrl(data.url)
) {
await removeFiles(oldLink.id, oldLink.collectionId);
} else if (oldLink?.url !== data.url)
return {
response: "Invalid URL.",
status: 401,
};
const updatedLink = await prisma.link.update({
where: {
id: linkId,
},
data: {
name: data.name,
url: data.url,
description: data.description,
icon: data.icon,
iconWeight: data.iconWeight,
color: data.color,
image: oldLink?.url !== data.url ? null : undefined,
pdf: oldLink?.url !== data.url ? null : undefined,
readable: oldLink?.url !== data.url ? null : undefined,
monolith: oldLink?.url !== data.url ? null : undefined,
preview: oldLink?.url !== data.url ? null : undefined,
collection: {
connect: {
id: data.collection.id,
@@ -128,10 +158,11 @@ export default async function updateLinkById(
},
})),
},
pinnedBy:
data?.pinnedBy && data.pinnedBy[0]
pinnedBy: data?.pinnedBy
? data.pinnedBy[0]?.id === userId
? { connect: { id: userId } }
: { disconnect: { id: userId } },
: { disconnect: { id: userId } }
: undefined,
},
include: {
tags: true,
@@ -146,20 +177,7 @@ export default async function updateLinkById(
});
if (collectionIsAccessible?.id !== data.collection.id) {
await moveFile(
`archives/${collectionIsAccessible?.id}/${linkId}.pdf`,
`archives/${data.collection.id}/${linkId}.pdf`
);
await moveFile(
`archives/${collectionIsAccessible?.id}/${linkId}.png`,
`archives/${data.collection.id}/${linkId}.png`
);
await moveFile(
`archives/${collectionIsAccessible?.id}/${linkId}_readability.json`,
`archives/${data.collection.id}/${linkId}_readability.json`
);
await moveFiles(linkId, collectionIsAccessible?.id, data.collection.id);
}
return { response: updatedLink, status: 200 };
+39 -117
View File
@@ -1,114 +1,35 @@
import { prisma } from "@/lib/api/db";
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import getTitle from "@/lib/shared/getTitle";
import { UsersAndCollections } from "@prisma/client";
import getPermission from "@/lib/api/getPermission";
import fetchTitleAndHeaders from "@/lib/shared/fetchTitleAndHeaders";
import createFolder from "@/lib/api/storage/createFolder";
import validateUrlSize from "../../validateUrlSize";
import setLinkCollection from "../../setLinkCollection";
import {
PostLinkSchema,
PostLinkSchemaType,
} from "@/lib/shared/schemaValidation";
const MAX_LINKS_PER_USER = Number(process.env.MAX_LINKS_PER_USER) || 30000;
export default async function postLink(
link: LinkIncludingShortenedCollectionAndTags,
body: PostLinkSchemaType,
userId: number
) {
try {
new URL(link.url || "");
} catch (error) {
const dataValidation = PostLinkSchema.safeParse(body);
if (!dataValidation.success) {
return {
response:
"Please enter a valid Address for the Link. (It should start with http/https)",
response: `Error: ${
dataValidation.error.issues[0].message
} [${dataValidation.error.issues[0].path.join(", ")}]`,
status: 400,
};
}
if (!link.collection.id && link.collection.name) {
link.collection.name = link.collection.name.trim();
const link = dataValidation.data;
// find the collection with the name and the user's id
const findCollection = await prisma.collection.findFirst({
where: {
name: link.collection.name,
ownerId: userId,
parentId: link.collection.parentId,
},
});
const linkCollection = await setLinkCollection(link, userId);
if (findCollection) {
const collectionIsAccessible = await getPermission({
userId,
collectionId: findCollection.id,
});
const memberHasAccess = collectionIsAccessible?.members.some(
(e: UsersAndCollections) => e.userId === userId && e.canCreate
);
if (!(collectionIsAccessible?.ownerId === userId || memberHasAccess))
return { response: "Collection is not accessible.", status: 401 };
link.collection.id = findCollection.id;
link.collection.ownerId = findCollection.ownerId;
} else {
const collection = await prisma.collection.create({
data: {
name: link.collection.name,
ownerId: userId,
},
});
link.collection.id = collection.id;
await prisma.user.update({
where: {
id: userId,
},
data: {
collectionOrder: {
push: link.collection.id,
},
},
});
}
} else if (link.collection.id) {
const collectionIsAccessible = await getPermission({
userId,
collectionId: link.collection.id,
});
const memberHasAccess = collectionIsAccessible?.members.some(
(e: UsersAndCollections) => e.userId === userId && e.canCreate
);
if (!(collectionIsAccessible?.ownerId === userId || memberHasAccess))
return { response: "Collection is not accessible.", status: 401 };
} else if (!link.collection.id) {
link.collection.name = "Unorganized";
link.collection.parentId = null;
// find the collection with the name "Unorganized" and the user's id
const unorganizedCollection = await prisma.collection.findFirst({
where: {
name: "Unorganized",
ownerId: userId,
},
});
link.collection.id = unorganizedCollection?.id;
await prisma.user.update({
where: {
id: userId,
},
data: {
collectionOrder: {
push: link.collection.id,
},
},
});
} else {
return { response: "Uncaught error.", status: 500 };
}
if (!linkCollection)
return { response: "Collection is not accessible.", status: 400 };
const user = await prisma.user.findUnique({
where: {
@@ -117,9 +38,14 @@ export default async function postLink(
});
if (user?.preventDuplicateLinks) {
const url = link.url?.trim().replace(/\/+$/, ""); // trim and remove trailing slashes from the URL
const hasWwwPrefix = url?.includes(`://www.`);
const urlWithoutWww = hasWwwPrefix ? url?.replace(`://www.`, "://") : url;
const urlWithWww = hasWwwPrefix ? url : url?.replace("://", `://www.`);
const existingLink = await prisma.link.findFirst({
where: {
url: link.url?.trim(),
OR: [{ url: urlWithWww }, { url: urlWithoutWww }],
collection: {
ownerId: userId,
},
@@ -136,29 +62,23 @@ export default async function postLink(
const numberOfLinksTheUserHas = await prisma.link.count({
where: {
collection: {
ownerId: userId,
ownerId: linkCollection.ownerId,
},
},
});
if (numberOfLinksTheUserHas + 1 > MAX_LINKS_PER_USER)
if (numberOfLinksTheUserHas > MAX_LINKS_PER_USER)
return {
response: `Error: Each user can only have a maximum of ${MAX_LINKS_PER_USER} Links.`,
response: `Each collection owner can only have a maximum of ${MAX_LINKS_PER_USER} Links.`,
status: 400,
};
link.collection.name = link.collection.name.trim();
const { title, headers } = await fetchTitleAndHeaders(link.url || "");
const description =
link.description && link.description !== ""
? link.description
: link.url
? await getTitle(link.url)
: undefined;
const name =
link.name && link.name !== "" ? link.name : link.url ? title : "";
const validatedUrl = link.url ? await validateUrlSize(link.url) : undefined;
const contentType = validatedUrl?.get("content-type");
const contentType = headers?.get("content-type");
let linkType = "url";
let imageExtension = "png";
@@ -170,30 +90,32 @@ export default async function postLink(
else if (contentType === "image/png") imageExtension = "png";
}
if (!link.tags) link.tags = [];
const newLink = await prisma.link.create({
data: {
url: link.url?.trim(),
name: link.name,
description,
url: link.url?.trim() || null,
name,
description: link.description,
type: linkType,
collection: {
connect: {
id: link.collection.id,
id: linkCollection.id,
},
},
tags: {
connectOrCreate: link.tags.map((tag) => ({
connectOrCreate: link.tags?.map((tag) => ({
where: {
name_ownerId: {
name: tag.name.trim(),
ownerId: link.collection.ownerId,
ownerId: linkCollection.ownerId,
},
},
create: {
name: tag.name.trim(),
owner: {
connect: {
id: link.collection.ownerId,
id: linkCollection.ownerId,
},
},
},
@@ -22,18 +22,5 @@ export default async function exportData(userId: number) {
const { password, id, ...userData } = user;
function redactIds(obj: any) {
if (Array.isArray(obj)) {
obj.forEach((o) => redactIds(o));
} else if (obj !== null && typeof obj === "object") {
delete obj.id;
for (let key in obj) {
redactIds(obj[key]);
}
}
}
redactIds(userData);
return { response: userData, status: 200 };
}
@@ -2,7 +2,6 @@ import { prisma } from "@/lib/api/db";
import createFolder from "@/lib/api/storage/createFolder";
import { JSDOM } from "jsdom";
import { parse, Node, Element, TextNode } from "himalaya";
import { writeFileSync } from "fs";
const MAX_LINKS_PER_USER = Number(process.env.MAX_LINKS_PER_USER) || 30000;
@@ -31,7 +30,7 @@ export default async function importFromHTMLFile(
if (totalImports + numberOfLinksTheUserHas > MAX_LINKS_PER_USER)
return {
response: `Error: Each user can only have a maximum of ${MAX_LINKS_PER_USER} Links.`,
response: `Each collection owner can only have a maximum of ${MAX_LINKS_PER_USER} Links.`,
status: 400,
};
@@ -63,11 +62,22 @@ async function processBookmarks(
) as Element;
if (collectionName) {
collectionId = await createCollection(
userId,
(collectionName.children[0] as TextNode).content,
parentCollectionId
);
const collectionNameContent = (collectionName.children[0] as TextNode)
?.content;
if (collectionNameContent) {
collectionId = await createCollection(
userId,
collectionNameContent,
parentCollectionId
);
} else {
// Handle the case when the collection name is empty
collectionId = await createCollection(
userId,
"Untitled Collection",
parentCollectionId
);
}
}
await processBookmarks(
userId,
@@ -144,6 +154,8 @@ const createCollection = async (
collectionName: string,
parentId?: number
) => {
collectionName = collectionName.trim().slice(0, 254);
const findCollection = await prisma.collection.findFirst({
where: {
parentId,
@@ -188,6 +200,22 @@ const createLink = async (
tags?: string[],
importDate?: Date
) => {
url = url.trim().slice(0, 254);
try {
new URL(url);
} catch (e) {
return;
}
tags = tags?.map((tag) => tag.trim().slice(0, 49));
name = name?.trim().slice(0, 254);
description = description?.trim().slice(0, 254);
if (importDate) {
const dateString = importDate.toISOString();
if (dateString.length > 50) {
importDate = undefined;
}
}
await prisma.link.create({
data: {
name: name || "",
@@ -26,7 +26,7 @@ export default async function importFromLinkwarden(
if (totalImports + numberOfLinksTheUserHas > MAX_LINKS_PER_USER)
return {
response: `Error: Each user can only have a maximum of ${MAX_LINKS_PER_USER} Links.`,
response: `Each collection owner can only have a maximum of ${MAX_LINKS_PER_USER} Links.`,
status: 400,
};
@@ -44,9 +44,9 @@ export default async function importFromLinkwarden(
id: userId,
},
},
name: e.name,
description: e.description,
color: e.color,
name: e.name?.trim().slice(0, 254),
description: e.description?.trim().slice(0, 254),
color: e.color?.trim().slice(0, 50),
},
});
@@ -54,11 +54,19 @@ export default async function importFromLinkwarden(
// Import Links
for (const link of e.links) {
const newLink = await prisma.link.create({
if (link.url) {
try {
new URL(link.url.trim());
} catch (err) {
continue;
}
}
await prisma.link.create({
data: {
url: link.url,
name: link.name,
description: link.description,
url: link.url?.trim().slice(0, 254),
name: link.name?.trim().slice(0, 254),
description: link.description?.trim().slice(0, 254),
collection: {
connect: {
id: newCollection.id,
@@ -69,12 +77,12 @@ export default async function importFromLinkwarden(
connectOrCreate: link.tags.map((tag) => ({
where: {
name_ownerId: {
name: tag.name.trim(),
name: tag.name?.slice(0, 49),
ownerId: userId,
},
},
create: {
name: tag.name.trim(),
name: tag.name?.trim().slice(0, 49),
owner: {
connect: {
id: userId,
@@ -0,0 +1,122 @@
import { prisma } from "@/lib/api/db";
import createFolder from "@/lib/api/storage/createFolder";
const MAX_LINKS_PER_USER = Number(process.env.MAX_LINKS_PER_USER) || 30000;
type WallabagBackup = {
is_archived: number;
is_starred: number;
tags: String[];
is_public: boolean;
id: number;
title: string;
url: string;
content: string;
created_at: Date;
updated_at: Date;
published_by: string[];
starred_at: Date;
annotations: any[];
mimetype: string;
language: string;
reading_time: number;
domain_name: string;
preview_picture: string;
http_status: string;
headers: Record<string, string>;
}[];
export default async function importFromWallabag(
userId: number,
rawData: string
) {
const data: WallabagBackup = JSON.parse(rawData);
const backup = data.filter((e) => e.url);
let totalImports = backup.length;
const numberOfLinksTheUserHas = await prisma.link.count({
where: {
collection: {
ownerId: userId,
},
},
});
if (totalImports + numberOfLinksTheUserHas > MAX_LINKS_PER_USER)
return {
response: `Each collection owner can only have a maximum of ${MAX_LINKS_PER_USER} Links.`,
status: 400,
};
await prisma
.$transaction(
async () => {
const newCollection = await prisma.collection.create({
data: {
owner: {
connect: {
id: userId,
},
},
name: "Imports",
},
});
createFolder({ filePath: `archives/${newCollection.id}` });
for (const link of backup) {
if (link.url) {
try {
new URL(link.url.trim());
} catch (err) {
continue;
}
}
await prisma.link.create({
data: {
pinnedBy: link.is_starred
? { connect: { id: userId } }
: undefined,
url: link.url?.trim().slice(0, 254),
name: link.title?.trim().slice(0, 254) || "",
textContent: link.content?.trim() || "",
importDate: link.created_at || null,
collection: {
connect: {
id: newCollection.id,
},
},
tags:
link.tags && link.tags[0]
? {
connectOrCreate: link.tags.map((tag) => ({
where: {
name_ownerId: {
name: tag?.trim().slice(0, 49),
ownerId: userId,
},
},
create: {
name: tag?.trim().slice(0, 49),
owner: {
connect: {
id: userId,
},
},
},
})),
}
: undefined,
},
});
}
},
{ timeout: 30000 }
)
.catch((err) => console.log(err));
return { response: "Success.", status: 200 };
}
@@ -1,12 +1,13 @@
import { prisma } from "@/lib/api/db";
import { LinkRequestQuery, Sort } from "@/types/global";
import { LinkRequestQuery, Order, Sort } from "@/types/global";
export default async function getLink(
query: Omit<LinkRequestQuery, "tagId" | "pinnedOnly">, takeAll = false
) {
const POSTGRES_IS_ENABLED = process.env.DATABASE_URL.startsWith("postgresql");
const POSTGRES_IS_ENABLED =
process.env.DATABASE_URL?.startsWith("postgresql");
let order: any;
let order: Order = { id: "desc" };
if (query.sort === Sort.DateNewestFirst) order = { id: "desc" };
else if (query.sort === Sort.DateOldestFirst) order = { id: "asc" };
else if (query.sort === Sort.NameAZ) order = { name: "asc" };
@@ -68,7 +69,7 @@ export default async function getLink(
}
const links = await prisma.link.findMany({
take: !takeAll ? Number(process.env.PAGINATION_TAKE_COUNT) || 20 : undefined,
take: Number(process.env.PAGINATION_TAKE_COUNT) || 50,
skip: query.cursor ? 1 : undefined,
cursor: query.cursor ? { id: query.cursor } : undefined,
where: {
@@ -75,6 +75,7 @@ export default async function getPublicUser(
username: lessSensitiveInfo.username,
image: lessSensitiveInfo.image,
archiveAsScreenshot: lessSensitiveInfo.archiveAsScreenshot,
archiveAsMonolith: lessSensitiveInfo.archiveAsMonolith,
archiveAsPDF: lessSensitiveInfo.archiveAsPDF,
};
@@ -0,0 +1,48 @@
import { prisma } from "@/lib/api/db";
import crypto from "crypto";
import { decode, encode } from "next-auth/jwt";
export default async function createSession(
userId: number,
sessionName?: string
) {
const now = Date.now();
const expiryDate = new Date();
const oneDayInSeconds = 86400;
expiryDate.setDate(expiryDate.getDate() + 73000); // 200 years (not really never)
const expiryDateSecond = 73050 * oneDayInSeconds;
const token = await encode({
token: {
id: userId,
iat: now / 1000,
exp: (expiryDate as any) / 1000,
jti: crypto.randomUUID(),
},
maxAge: expiryDateSecond || 604800,
secret: process.env.NEXTAUTH_SECRET as string,
});
const tokenBody = await decode({
token,
secret: process.env.NEXTAUTH_SECRET as string,
});
await prisma.accessToken.create({
data: {
name: sessionName || "Unknown Device",
userId,
token: tokenBody?.jti as string,
isSession: true,
expires: expiryDate,
},
});
return {
response: {
token,
},
status: 200,
};
}
@@ -1,18 +1,31 @@
import { prisma } from "@/lib/api/db";
import { Tag } from "@prisma/client";
import {
UpdateTagSchema,
UpdateTagSchemaType,
} from "@/lib/shared/schemaValidation";
export default async function updeteTagById(
userId: number,
tagId: number,
data: Tag
body: UpdateTagSchemaType
) {
if (!tagId || !data.name)
return { response: "Please choose a valid name for the tag.", status: 401 };
const dataValidation = UpdateTagSchema.safeParse(body);
if (!dataValidation.success) {
return {
response: `Error: ${
dataValidation.error.issues[0].message
} [${dataValidation.error.issues[0].path.join(", ")}]`,
status: 400,
};
}
const { name } = dataValidation.data;
const tagNameIsTaken = await prisma.tag.findFirst({
where: {
ownerId: userId,
name: data.name,
name: name,
},
});
@@ -39,7 +52,7 @@ export default async function updeteTagById(
id: tagId,
},
data: {
name: data.name,
name: name,
},
});
+1
View File
@@ -9,6 +9,7 @@ export default async function getToken(userId: number) {
select: {
id: true,
name: true,
isSession: true,
expires: true,
createdAt: true,
},
+21 -17
View File
@@ -1,28 +1,32 @@
import { prisma } from "@/lib/api/db";
import {
PostTokenSchemaType,
PostTokenSchema,
} from "@/lib/shared/schemaValidation";
import { TokenExpiry } from "@/types/global";
import crypto from "crypto";
import { decode, encode } from "next-auth/jwt";
export default async function postToken(
body: {
name: string;
expires: TokenExpiry;
},
body: PostTokenSchemaType,
userId: number
) {
console.log(body);
const dataValidation = PostTokenSchema.safeParse(body);
const checkHasEmptyFields = !body.name || body.expires === undefined;
if (checkHasEmptyFields)
if (!dataValidation.success) {
return {
response: "Please fill out all the fields.",
response: `Error: ${
dataValidation.error.issues[0].message
} [${dataValidation.error.issues[0].path.join(", ")}]`,
status: 400,
};
}
const { name, expires } = dataValidation.data;
const checkIfTokenExists = await prisma.accessToken.findFirst({
where: {
name: body.name,
name: name,
revoked: false,
userId,
},
@@ -40,16 +44,16 @@ export default async function postToken(
const oneDayInSeconds = 86400;
let expiryDateSecond = 7 * oneDayInSeconds;
if (body.expires === TokenExpiry.oneMonth) {
if (expires === TokenExpiry.oneMonth) {
expiryDate.setDate(expiryDate.getDate() + 30);
expiryDateSecond = 30 * oneDayInSeconds;
} else if (body.expires === TokenExpiry.twoMonths) {
} else if (expires === TokenExpiry.twoMonths) {
expiryDate.setDate(expiryDate.getDate() + 60);
expiryDateSecond = 60 * oneDayInSeconds;
} else if (body.expires === TokenExpiry.threeMonths) {
} else if (expires === TokenExpiry.threeMonths) {
expiryDate.setDate(expiryDate.getDate() + 90);
expiryDateSecond = 90 * oneDayInSeconds;
} else if (body.expires === TokenExpiry.never) {
} else if (expires === TokenExpiry.never) {
expiryDate.setDate(expiryDate.getDate() + 73000); // 200 years (not really never)
expiryDateSecond = 73050 * oneDayInSeconds;
} else {
@@ -65,17 +69,17 @@ export default async function postToken(
jti: crypto.randomUUID(),
},
maxAge: expiryDateSecond || 604800,
secret: process.env.NEXTAUTH_SECRET,
secret: process.env.NEXTAUTH_SECRET as string,
});
const tokenBody = await decode({
token,
secret: process.env.NEXTAUTH_SECRET,
secret: process.env.NEXTAUTH_SECRET as string,
});
const createToken = await prisma.accessToken.create({
data: {
name: body.name,
name: name,
userId,
token: tokenBody?.jti as string,
expires: expiryDate,
+21
View File
@@ -0,0 +1,21 @@
import { prisma } from "@/lib/api/db";
export default async function getUsers() {
// Get all users
const users = await prisma.user.findMany({
select: {
id: true,
username: true,
email: true,
emailVerified: true,
subscriptions: {
select: {
active: true,
},
},
createdAt: true,
},
});
return { response: users, status: 200 };
}
+93 -60
View File
@@ -1,91 +1,124 @@
import { prisma } from "@/lib/api/db";
import type { NextApiRequest, NextApiResponse } from "next";
import bcrypt from "bcrypt";
import isServerAdmin from "../../isServerAdmin";
import { PostUserSchema } from "@/lib/shared/schemaValidation";
const emailEnabled =
process.env.EMAIL_FROM && process.env.EMAIL_SERVER ? true : false;
const stripeEnabled = process.env.STRIPE_SECRET_KEY ? true : false;
interface Data {
response: string | object;
}
interface User {
name: string;
username?: string;
email?: string;
password: string;
status: number;
}
export default async function postUser(
req: NextApiRequest,
res: NextApiResponse<Data>
) {
if (process.env.NEXT_PUBLIC_DISABLE_REGISTRATION === "true") {
return res.status(400).json({ response: "Registration is disabled." });
res: NextApiResponse
): Promise<Data> {
let isAdmin = await isServerAdmin({ req });
if (process.env.NEXT_PUBLIC_DISABLE_REGISTRATION === "true" && !isAdmin) {
return { response: "Registration is disabled.", status: 400 };
}
const body: User = req.body;
const dataValidation = PostUserSchema().safeParse(req.body);
const checkHasEmptyFields = emailEnabled
? !body.password || !body.name || !body.email
: !body.username || !body.password || !body.name;
if (!dataValidation.success) {
return {
response: `Error: ${
dataValidation.error.issues[0].message
} [${dataValidation.error.issues[0].path.join(", ")}]`,
status: 400,
};
}
if (!body.password || body.password.length < 8)
return res
.status(400)
.json({ response: "Password must be at least 8 characters." });
const { name, email, password } = dataValidation.data;
let { username } = dataValidation.data;
if (checkHasEmptyFields)
return res
.status(400)
.json({ response: "Please fill out all the fields." });
const autoGeneratedUsername = "user" + Math.round(Math.random() * 1000000000);
// Check email (if enabled)
const checkEmail =
/^(([^<>()[\]\.,;:\s@\"]+(\.[^<>()[\]\.,;:\s@\"]+)*)|(\".+\"))@(([^<>()[\]\.,;:\s@\"]+\.)+[^<>()[\]\.,;:\s@\"]{2,})$/i;
if (emailEnabled && !checkEmail.test(body.email?.toLowerCase() || ""))
return res.status(400).json({
response: "Please enter a valid email.",
});
// Check username (if email was disabled)
const checkUsername = RegExp("^[a-z0-9_-]{3,31}$");
if (!emailEnabled && !checkUsername.test(body.username?.toLowerCase() || ""))
return res.status(400).json({
response:
"Username has to be between 3-30 characters, no spaces and special characters are allowed.",
});
if (!username) {
username = autoGeneratedUsername;
}
const checkIfUserExists = await prisma.user.findFirst({
where: emailEnabled
? {
email: body.email?.toLowerCase().trim(),
}
: {
username: (body.username as string).toLowerCase().trim(),
where: {
OR: [
{
email: email ? email.toLowerCase().trim() : undefined,
},
{
username: username ? username.toLowerCase().trim() : undefined,
},
],
},
});
if (!checkIfUserExists) {
const autoGeneratedUsername =
"user" + Math.round(Math.random() * 1000000000);
const saltRounds = 10;
const hashedPassword = bcrypt.hashSync(body.password, saltRounds);
const hashedPassword = bcrypt.hashSync(password, saltRounds);
await prisma.user.create({
data: {
name: body.name,
username: emailEnabled
? undefined
: (body.username as string).toLowerCase().trim(),
email: emailEnabled ? body.email?.toLowerCase().trim() : undefined,
password: hashedPassword,
},
});
// Subscription dates
const currentPeriodStart = new Date();
const currentPeriodEnd = new Date();
currentPeriodEnd.setFullYear(currentPeriodEnd.getFullYear() + 1000); // end date is in 1000 years...
return res.status(201).json({ response: "User successfully created." });
} else if (checkIfUserExists) {
return res.status(400).json({
response: `${emailEnabled ? "Email" : "Username"} already exists.`,
});
if (isAdmin) {
const user = await prisma.user.create({
data: {
name: name,
username: emailEnabled
? (username as string) || autoGeneratedUsername
: (username as string),
email: emailEnabled ? email : undefined,
password: hashedPassword,
emailVerified: new Date(),
subscriptions: stripeEnabled
? {
create: {
stripeSubscriptionId:
"fake_sub_" + Math.round(Math.random() * 10000000000000),
active: true,
currentPeriodStart,
currentPeriodEnd,
},
}
: undefined,
},
select: {
id: true,
username: true,
email: true,
emailVerified: true,
subscriptions: {
select: {
active: true,
},
},
createdAt: true,
},
});
return { response: user, status: 201 };
} else {
await prisma.user.create({
data: {
name: name,
username: emailEnabled ? autoGeneratedUsername : (username as string),
email: emailEnabled ? email : undefined,
password: hashedPassword,
},
});
return { response: "User successfully created.", status: 201 };
}
} else {
return { response: "Email or Username already exists.", status: 400 };
}
}
@@ -5,12 +5,10 @@ import Stripe from "stripe";
import { DeleteUserBody } from "@/types/global";
import removeFile from "@/lib/api/storage/removeFile";
const keycloakEnabled = process.env.KEYCLOAK_CLIENT_SECRET;
const authentikEnabled = process.env.AUTHENTIK_CLIENT_SECRET;
export default async function deleteUserById(
userId: number,
body: DeleteUserBody
body: DeleteUserBody,
isServerAdmin?: boolean
) {
// First, we retrieve the user from the database
const user = await prisma.user.findUnique({
@@ -24,16 +22,20 @@ export default async function deleteUserById(
};
}
// Then, we check if the provided password matches the one stored in the database (disabled in Keycloak integration)
if (!keycloakEnabled && !authentikEnabled) {
const isPasswordValid = bcrypt.compareSync(
body.password,
user.password as string
);
if (!isServerAdmin) {
if (user.password) {
const isPasswordValid = bcrypt.compareSync(body.password, user.password);
if (!isPasswordValid) {
if (!isPasswordValid && !isServerAdmin) {
return {
response: "Invalid credentials.",
status: 401, // Unauthorized
};
}
} else {
return {
response: "Invalid credentials.",
response:
"User has no password. Please reset your password from the forgot password page.",
status: 401, // Unauthorized
};
}
@@ -43,6 +45,11 @@ export default async function deleteUserById(
await prisma
.$transaction(
async (prisma) => {
// Delete Access Tokens
await prisma.accessToken.deleteMany({
where: { userId },
});
// Delete whitelisted users
await prisma.whitelistedUser.deleteMany({
where: { userId },
@@ -70,7 +77,11 @@ export default async function deleteUserById(
});
// Delete archive folders
removeFolder({ filePath: `archives/${collection.id}` });
await removeFolder({ filePath: `archives/${collection.id}` });
await removeFolder({
filePath: `archives/preview/${collection.id}`,
});
}
// Delete collections after cleaning up related data
@@ -80,9 +91,11 @@ export default async function deleteUserById(
// Delete subscription
if (process.env.STRIPE_SECRET_KEY)
await prisma.subscription.delete({
where: { userId },
});
await prisma.subscription
.delete({
where: { userId },
})
.catch((err) => console.log(err));
await prisma.usersAndCollections.deleteMany({
where: {
+134 -137
View File
@@ -3,172 +3,172 @@ import { AccountSettings } from "@/types/global";
import bcrypt from "bcrypt";
import removeFile from "@/lib/api/storage/removeFile";
import createFile from "@/lib/api/storage/createFile";
import updateCustomerEmail from "@/lib/api/updateCustomerEmail";
import createFolder from "@/lib/api/storage/createFolder";
import sendChangeEmailVerificationRequest from "@/lib/api/sendChangeEmailVerificationRequest";
import { i18n } from "next-i18next.config";
import { UpdateUserSchema } from "@/lib/shared/schemaValidation";
const emailEnabled =
process.env.EMAIL_FROM && process.env.EMAIL_SERVER ? true : false;
export default async function updateUserById(
userId: number,
data: AccountSettings
body: AccountSettings
) {
const ssoUser = await prisma.account.findFirst({
const dataValidation = UpdateUserSchema().safeParse(body);
if (!dataValidation.success) {
return {
response: `Error: ${
dataValidation.error.issues[0].message
} [${dataValidation.error.issues[0].path.join(", ")}]`,
status: 400,
};
}
const data = dataValidation.data;
const userIsTaken = await prisma.user.findFirst({
where: {
userId: userId,
},
});
const user = await prisma.user.findUnique({
where: {
id: userId,
id: { not: userId },
OR: emailEnabled
? [
{
username: data.username.toLowerCase(),
},
{
email: data.email?.toLowerCase(),
},
]
: [
{
username: data.username.toLowerCase(),
},
],
},
});
if (ssoUser) {
// deny changes to SSO-defined properties
if (data.email !== user?.email) {
if (userIsTaken) {
if (data.email?.toLowerCase().trim() === userIsTaken.email?.trim())
return {
response: "SSO users cannot change their email.",
response: "Email is taken.",
status: 400,
};
else if (
data.username?.toLowerCase().trim() === userIsTaken.username?.trim()
)
return {
response: "Username is taken.",
status: 400,
};
return {
response: "Username/Email is taken.",
status: 400,
};
}
// Avatar Settings
if (
data.image?.startsWith("data:image/jpeg;base64") &&
data.image.length < 1572864
) {
try {
const base64Data = data.image.replace(/^data:image\/jpeg;base64,/, "");
createFolder({ filePath: `uploads/avatar` });
await createFile({
filePath: `uploads/avatar/${userId}.jpg`,
data: base64Data,
isBase64: true,
});
} catch (err) {
console.log("Error saving image:", err);
}
} else if (data.image?.length && data.image?.length >= 1572864) {
console.log("A file larger than 1.5MB was uploaded.");
return {
response: "A file larger than 1.5MB was uploaded.",
status: 400,
};
} else if (data.image == "") {
removeFile({ filePath: `uploads/avatar/${userId}.jpg` });
}
// Email Settings
const user = await prisma.user.findUnique({
where: { id: userId },
select: { email: true, password: true, name: true },
});
if (user && user.email && data.email && data.email !== user.email) {
if (!data.password) {
return {
response: "Invalid password.",
status: 400,
};
}
if (data.newPassword) {
// Verify password
if (!user.password) {
return {
response: "SSO Users cannot change their password.",
response:
"User has no password. Please reset your password from the forgot password page.",
status: 400,
};
}
if (data.name !== user?.name) {
const passwordMatch = bcrypt.compareSync(data.password, user.password);
if (!passwordMatch) {
return {
response: "SSO Users cannot change their name.",
response: "Password is incorrect.",
status: 400,
};
}
if (data.username !== user?.username) {
sendChangeEmailVerificationRequest(
user.email,
data.email,
data.name?.trim() || user.name
);
}
// Password Settings
if (data.newPassword || data.oldPassword) {
if (!data.oldPassword || !data.newPassword)
return {
response: "SSO Users cannot change their username.",
response: "Please fill out all the fields.",
status: 400,
};
}
if (data.image?.startsWith("data:image/jpeg;base64")) {
else if (!user?.password)
return {
response: "SSO Users cannot change their avatar.",
response:
"User has no password. Please reset your password from the forgot password page.",
status: 400,
};
}
} else {
// verify only for non-SSO users
// SSO users cannot change their email, password, name, username, or avatar
if (emailEnabled && !data.email)
else if (!bcrypt.compareSync(data.oldPassword, user.password))
return {
response: "Email invalid.",
response: "Old password is incorrect.",
status: 400,
};
else if (!data.username)
return {
response: "Username invalid.",
status: 400,
};
if (data.newPassword && data.newPassword?.length < 8)
else if (data.newPassword?.length < 8)
return {
response: "Password must be at least 8 characters.",
status: 400,
};
// Check email (if enabled)
const checkEmail =
/^(([^<>()[\]\.,;:\s@\"]+(\.[^<>()[\]\.,;:\s@\"]+)*)|(\".+\"))@(([^<>()[\]\.,;:\s@\"]+\.)+[^<>()[\]\.,;:\s@\"]{2,})$/i;
if (emailEnabled && !checkEmail.test(data.email?.toLowerCase() || ""))
else if (data.newPassword === data.oldPassword)
return {
response: "Please enter a valid email.",
response: "New password must be different from the old password.",
status: 400,
};
const checkUsername = RegExp("^[a-z0-9_-]{3,31}$");
if (!checkUsername.test(data.username.toLowerCase()))
return {
response:
"Username has to be between 3-30 characters, no spaces and special characters are allowed.",
status: 400,
};
const userIsTaken = await prisma.user.findFirst({
where: {
id: { not: userId },
OR: emailEnabled
? [
{
username: data.username.toLowerCase(),
},
{
email: data.email?.toLowerCase(),
},
]
: [
{
username: data.username.toLowerCase(),
},
],
},
});
if (userIsTaken) {
if (data.email?.toLowerCase().trim() === userIsTaken.email?.trim())
return {
response: "Email is taken.",
status: 400,
};
else if (
data.username?.toLowerCase().trim() === userIsTaken.username?.trim()
)
return {
response: "Username is taken.",
status: 400,
};
return {
response: "Username/Email is taken.",
status: 400,
};
}
// Avatar Settings
if (data.image?.startsWith("data:image/jpeg;base64")) {
if (data.image.length < 1572864) {
try {
const base64Data = data.image.replace(
/^data:image\/jpeg;base64,/,
""
);
createFolder({ filePath: `uploads/avatar` });
await createFile({
filePath: `uploads/avatar/${userId}.jpg`,
data: base64Data,
isBase64: true,
});
} catch (err) {
console.log("Error saving image:", err);
}
} else {
console.log("A file larger than 1.5MB was uploaded.");
return {
response: "A file larger than 1.5MB was uploaded.",
status: 400,
};
}
} else if (data.image == "") {
removeFile({ filePath: `uploads/avatar/${userId}.jpg` });
}
}
const previousEmail = (
await prisma.user.findUnique({ where: { id: userId } })
)?.email;
// Other settings
// Other settings / Apply changes
const saltRounds = 10;
const newHashedPassword = bcrypt.hashSync(data.newPassword || "", saltRounds);
@@ -179,14 +179,20 @@ export default async function updateUserById(
},
data: {
name: data.name,
username: data.username?.toLowerCase().trim(),
email: data.email?.toLowerCase().trim(),
username: data.username,
isPrivate: data.isPrivate,
image: data.image ? `uploads/avatar/${userId}.jpg` : "",
collectionOrder: data.collectionOrder.filter(
image:
data.image && data.image.startsWith("http")
? data.image
: data.image
? `uploads/avatar/${userId}.jpg`
: "",
collectionOrder: data.collectionOrder?.filter(
(value, index, self) => self.indexOf(value) === index
),
locale: i18n.locales.includes(data.locale || "") ? data.locale : "en",
archiveAsScreenshot: data.archiveAsScreenshot,
archiveAsMonolith: data.archiveAsMonolith,
archiveAsPDF: data.archiveAsPDF,
archiveAsWaybackMachine: data.archiveAsWaybackMachine,
linksRouteTo: data.linksRouteTo,
@@ -244,15 +250,6 @@ export default async function updateUserById(
});
}
const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY;
if (STRIPE_SECRET_KEY && emailEnabled && previousEmail !== data.email)
await updateCustomerEmail(
STRIPE_SECRET_KEY,
previousEmail as string,
data.email as string
);
const response: Omit<AccountSettings, "password"> = {
...userInfo,
whitelistedUsers: newWhitelistedUsernames,
+1 -1
View File
@@ -5,7 +5,7 @@ const globalForPrisma = global as unknown as { prisma: PrismaClient };
export const prisma =
globalForPrisma.prisma ||
new PrismaClient({
log: ["query"],
log: process.env.DEBUG === "true" ? ["query", "info", "warn", "error"] : ["warn", "error"]
});
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
@@ -2,7 +2,7 @@ import fetch from "node-fetch";
import https from "https";
import { SocksProxyAgent } from "socks-proxy-agent";
export default async function validateUrlSize(url: string) {
export default async function fetchHeaders(url: string) {
if (process.env.IGNORE_URL_SIZE_LIMIT === "true") return null;
try {
@@ -29,13 +29,17 @@ export default async function validateUrlSize(url: string) {
};
}
const response = await fetch(url, fetchOpts);
const responsePromise = fetch(url, fetchOpts);
const totalSizeMB =
Number(response.headers.get("content-length")) / Math.pow(1024, 2);
if (totalSizeMB > (Number(process.env.NEXT_PUBLIC_MAX_FILE_SIZE) || 30))
return null;
else return response.headers;
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => {
reject(new Error("Fetch header timeout"));
}, 10 * 1000); // Stop after 10 seconds
});
const response = await Promise.race([responsePromise, timeoutPromise]);
return (response as Response)?.headers || null;
} catch (err) {
console.log(err);
return null;
+52
View File
@@ -0,0 +1,52 @@
import Jimp from "jimp";
import { prisma } from "./db";
import createFile from "./storage/createFile";
const generatePreview = async (
buffer: Buffer,
collectionId: number,
linkId: number
) => {
if (buffer && collectionId && linkId) {
try {
const image = await Jimp.read(buffer);
if (!image) {
console.log("Error generating preview: Image not found");
return;
}
image.resize(1000, Jimp.AUTO).quality(20);
const processedBuffer = await image.getBufferAsync(Jimp.MIME_JPEG);
if (
Buffer.byteLength(processedBuffer) >
1024 * 1024 * Number(process.env.PREVIEW_MAX_BUFFER || 0.1)
) {
console.log("Error generating preview: Buffer size exceeded");
return prisma.link.update({
where: { id: linkId },
data: {
preview: "unavailable",
},
});
}
await createFile({
data: processedBuffer,
filePath: `archives/preview/${collectionId}/${linkId}.jpeg`,
});
await prisma.link.update({
where: { id: linkId },
data: {
preview: `archives/preview/${collectionId}/${linkId}.jpeg`,
},
});
} catch (err) {
console.error("Error processing the image:", err);
}
}
};
export default generatePreview;
+2 -5
View File
@@ -3,14 +3,12 @@ import { prisma } from "@/lib/api/db";
type Props = {
userId: number;
collectionId?: number;
collectionName?: string;
linkId?: number;
};
export default async function getPermission({
userId,
collectionId,
collectionName,
linkId,
}: Props) {
if (linkId) {
@@ -26,11 +24,10 @@ export default async function getPermission({
});
return check;
} else if (collectionId || collectionName) {
} else if (collectionId) {
const check = await prisma.collection.findFirst({
where: {
id: collectionId || undefined,
name: collectionName || undefined,
id: collectionId,
OR: [{ ownerId: userId }, { members: { some: { userId } } }],
},
include: { members: true },
+44
View File
@@ -0,0 +1,44 @@
import { NextApiRequest } from "next";
import { getToken } from "next-auth/jwt";
import { prisma } from "./db";
type Props = {
req: NextApiRequest;
};
export default async function isServerAdmin({ req }: Props): Promise<boolean> {
const token = await getToken({ req });
const userId = token?.id;
if (!userId) {
return false;
}
if (token.exp < Date.now() / 1000) {
return false;
}
// check if token is revoked
const revoked = await prisma.accessToken.findFirst({
where: {
token: token.jti,
revoked: true,
},
});
if (revoked) {
return false;
}
const findUser = await prisma.user.findFirst({
where: {
id: userId,
},
});
if (findUser?.id === Number(process.env.NEXT_PUBLIC_ADMIN || 1)) {
return true;
} else {
return false;
}
}
+70
View File
@@ -0,0 +1,70 @@
import moveFile from "./storage/moveFile";
import removeFile from "./storage/removeFile";
const removeFiles = async (linkId: number, collectionId: number) => {
// PDF
await removeFile({
filePath: `archives/${collectionId}/${linkId}.pdf`,
});
// Images
await removeFile({
filePath: `archives/${collectionId}/${linkId}.png`,
});
await removeFile({
filePath: `archives/${collectionId}/${linkId}.jpeg`,
});
await removeFile({
filePath: `archives/${collectionId}/${linkId}.jpg`,
});
// HTML
await removeFile({
filePath: `archives/${collectionId}/${linkId}.html`,
});
// Preview
await removeFile({
filePath: `archives/preview/${collectionId}/${linkId}.jpeg`,
});
// Readability
await removeFile({
filePath: `archives/${collectionId}/${linkId}_readability.json`,
});
};
const moveFiles = async (linkId: number, from: number, to: number) => {
await moveFile(
`archives/${from}/${linkId}.pdf`,
`archives/${to}/${linkId}.pdf`
);
await moveFile(
`archives/${from}/${linkId}.png`,
`archives/${to}/${linkId}.png`
);
await moveFile(
`archives/${from}/${linkId}.jpeg`,
`archives/${to}/${linkId}.jpeg`
);
await moveFile(
`archives/${from}/${linkId}.jpg`,
`archives/${to}/${linkId}.jpg`
);
await moveFile(
`archives/${from}/${linkId}.html`,
`archives/${to}/${linkId}.html`
);
await moveFile(
`archives/preview/${from}/${linkId}.jpeg`,
`archives/preview/${to}/${linkId}.jpeg`
);
await moveFile(
`archives/${from}/${linkId}_readability.json`,
`archives/${to}/${linkId}_readability.json`
);
};
export { removeFiles, moveFiles };
@@ -0,0 +1,61 @@
import { Collection, Link, User } from "@prisma/client";
import { Page } from "playwright";
import generatePreview from "../generatePreview";
import createFile from "../storage/createFile";
import { prisma } from "../db";
type LinksAndCollectionAndOwner = Link & {
collection: Collection & {
owner: User;
};
};
const handleArchivePreview = async (
link: LinksAndCollectionAndOwner,
page: Page
) => {
const ogImageUrl = await page.evaluate(() => {
const metaTag = document.querySelector('meta[property="og:image"]');
return metaTag ? (metaTag as any).content : null;
});
if (ogImageUrl) {
console.log("Found og:image URL:", ogImageUrl);
// Download the image
const imageResponse = await page.goto(ogImageUrl);
// Check if imageResponse is not null
if (imageResponse && !link.preview?.startsWith("archive")) {
const buffer = await imageResponse.body();
generatePreview(buffer, link.collectionId, link.id);
}
await page.goBack();
} else if (!link.preview?.startsWith("archive")) {
console.log("No og:image found");
await page
.screenshot({ type: "jpeg", quality: 20 })
.then(async (screenshot) => {
if (
Buffer.byteLength(screenshot) >
1024 * 1024 * Number(process.env.PREVIEW_MAX_BUFFER || 0.1)
)
return console.log("Error generating preview: Buffer size exceeded");
await createFile({
data: screenshot,
filePath: `archives/preview/${link.collectionId}/${link.id}.jpeg`,
});
await prisma.link.update({
where: { id: link.id },
data: {
preview: `archives/preview/${link.collectionId}/${link.id}.jpeg`,
},
});
});
}
};
export default handleArchivePreview;
@@ -0,0 +1,46 @@
import { execSync } from "child_process";
import createFile from "../storage/createFile";
import { prisma } from "../db";
import { Link } from "@prisma/client";
const handleMonolith = async (link: Link, content: string) => {
if (!link.url) return;
try {
let html = execSync(
`monolith - -I -b ${link.url} ${
process.env.MONOLITH_CUSTOM_OPTIONS || "-j -F -s"
} -o -`,
{
timeout: 120000,
maxBuffer: 1024 * 1024 * Number(process.env.MONOLITH_MAX_BUFFER || 5),
input: content,
}
);
if (!html?.length)
return console.error("Error archiving as Monolith: Empty buffer");
if (
Buffer.byteLength(html) >
1024 * 1024 * Number(process.env.MONOLITH_MAX_BUFFER || 6)
)
return console.error("Error archiving as Monolith: Buffer size exceeded");
await createFile({
data: html,
filePath: `archives/${link.collectionId}/${link.id}.html`,
}).then(async () => {
await prisma.link.update({
where: { id: link.id },
data: {
monolith: `archives/${link.collectionId}/${link.id}.html`,
},
});
});
} catch (err) {
console.log("Error running MONOLITH:", err);
}
};
export default handleMonolith;
@@ -0,0 +1,51 @@
import { Readability } from "@mozilla/readability";
import { JSDOM } from "jsdom";
import DOMPurify from "dompurify";
import { prisma } from "../db";
import createFile from "../storage/createFile";
import { Link } from "@prisma/client";
const handleReadablility = async (content: string, link: Link) => {
const window = new JSDOM("").window;
const purify = DOMPurify(window);
const cleanedUpContent = purify.sanitize(content);
const dom = new JSDOM(cleanedUpContent, { url: link.url || "" });
const article = new Readability(dom.window.document).parse();
const articleText = article?.textContent
.replace(/ +(?= )/g, "") // strip out multiple spaces
.replace(/(\r\n|\n|\r)/gm, " "); // strip out line breaks
if (articleText && articleText !== "") {
const collectionId = (
await prisma.link.findUnique({
where: { id: link.id },
select: { collectionId: true },
})
)?.collectionId;
const data = JSON.stringify(article);
if (
Buffer.byteLength(data, "utf8") >
1024 * 1024 * Number(process.env.READABILITY_MAX_BUFFER || 1)
)
return console.error(
"Error archiving as Readability: Buffer size exceeded"
);
await createFile({
data,
filePath: `archives/${collectionId}/${link.id}_readability.json`,
});
await prisma.link.update({
where: { id: link.id },
data: {
readable: `archives/${collectionId}/${link.id}_readability.json`,
textContent: articleText,
},
});
}
};
export default handleReadablility;
@@ -0,0 +1,122 @@
import { Collection, Link, User } from "@prisma/client";
import { Page } from "playwright";
import createFile from "../storage/createFile";
import { prisma } from "../db";
type LinksAndCollectionAndOwner = Link & {
collection: Collection & {
owner: User;
};
};
const handleScreenshotAndPdf = async (
link: LinksAndCollectionAndOwner,
page: Page,
user: User
) => {
await page.evaluate(autoScroll, Number(process.env.AUTOSCROLL_TIMEOUT) || 30);
// Check if the user hasn't deleted the link by the time we're done scrolling
const linkExists = await prisma.link.findUnique({
where: { id: link.id },
});
if (linkExists) {
const processingPromises = [];
if (user.archiveAsScreenshot && !link.image?.startsWith("archive")) {
processingPromises.push(
page
.screenshot({ fullPage: true, type: "jpeg" })
.then(async (screenshot) => {
if (
Buffer.byteLength(screenshot) >
1024 * 1024 * Number(process.env.SCREENSHOT_MAX_BUFFER || 2)
)
return console.log(
"Error archiving as Screenshot: Buffer size exceeded"
);
await createFile({
data: screenshot,
filePath: `archives/${linkExists.collectionId}/${link.id}.jpeg`,
});
await prisma.link.update({
where: { id: link.id },
data: {
image: user.archiveAsScreenshot
? `archives/${linkExists.collectionId}/${link.id}.jpeg`
: undefined,
},
});
})
);
}
const margins = {
top: process.env.PDF_MARGIN_TOP || "15px",
bottom: process.env.PDF_MARGIN_BOTTOM || "15px",
};
if (user.archiveAsPDF && !link.pdf?.startsWith("archive")) {
processingPromises.push(
page
.pdf({
width: "1366px",
height: "1931px",
printBackground: true,
margin: margins,
})
.then(async (pdf) => {
if (
Buffer.byteLength(pdf) >
1024 * 1024 * Number(process.env.PDF_MAX_BUFFER || 2)
)
return console.log(
"Error archiving as PDF: Buffer size exceeded"
);
await createFile({
data: pdf,
filePath: `archives/${linkExists.collectionId}/${link.id}.pdf`,
});
await prisma.link.update({
where: { id: link.id },
data: {
pdf: user.archiveAsPDF
? `archives/${linkExists.collectionId}/${link.id}.pdf`
: undefined,
},
});
})
);
}
await Promise.allSettled(processingPromises);
}
};
const autoScroll = async (AUTOSCROLL_TIMEOUT: number) => {
const timeoutPromise = new Promise<void>((resolve) => {
setTimeout(() => {
resolve();
}, AUTOSCROLL_TIMEOUT * 1000);
});
const scrollingPromise = new Promise<void>((resolve) => {
let totalHeight = 0;
let distance = 100;
let scrollDown = setInterval(() => {
let scrollHeight = document.body.scrollHeight;
window.scrollBy(0, distance);
totalHeight += distance;
if (totalHeight >= scrollHeight) {
clearInterval(scrollDown);
window.scroll(0, 0);
resolve();
}
}, 100);
});
await Promise.race([scrollingPromise, timeoutPromise]);
};
export default handleScreenshotAndPdf;
@@ -0,0 +1,38 @@
import { Link } from "@prisma/client";
import { prisma } from "../db";
import createFile from "../storage/createFile";
import generatePreview from "../generatePreview";
const imageHandler = async ({ url, id }: Link, extension: string) => {
const image = await fetch(url as string).then((res) => res.blob());
const buffer = Buffer.from(await image.arrayBuffer());
if (
Buffer.byteLength(buffer) >
1024 * 1024 * Number(process.env.SCREENSHOT_MAX_BUFFER || 2)
)
return console.log("Error archiving as Screenshot: Buffer size exceeded");
const linkExists = await prisma.link.findUnique({
where: { id },
});
if (linkExists) {
await generatePreview(buffer, linkExists.collectionId, id);
await createFile({
data: buffer,
filePath: `archives/${linkExists.collectionId}/${id}.${extension}`,
});
await prisma.link.update({
where: { id },
data: {
image: `archives/${linkExists.collectionId}/${id}.${extension}`,
},
});
}
};
export default imageHandler;
+35
View File
@@ -0,0 +1,35 @@
import { Link } from "@prisma/client";
import { prisma } from "../db";
import createFile from "../storage/createFile";
const pdfHandler = async ({ url, id }: Link) => {
const pdf = await fetch(url as string).then((res) => res.blob());
const buffer = Buffer.from(await pdf.arrayBuffer());
if (
Buffer.byteLength(buffer) >
1024 * 1024 * Number(process.env.PDF_MAX_BUFFER || 2)
)
return console.log("Error archiving as PDF: Buffer size exceeded");
const linkExists = await prisma.link.findUnique({
where: { id },
});
if (linkExists) {
await createFile({
data: buffer,
filePath: `archives/${linkExists.collectionId}/${id}.pdf`,
});
await prisma.link.update({
where: { id },
data: {
pdf: `archives/${linkExists.collectionId}/${id}.pdf`,
},
});
}
};
export default pdfHandler;
@@ -0,0 +1,57 @@
import { randomBytes } from "crypto";
import { prisma } from "./db";
import transporter from "./transporter";
import Handlebars from "handlebars";
import { readFileSync } from "fs";
import path from "path";
export default async function sendChangeEmailVerificationRequest(
oldEmail: string,
newEmail: string,
user: string
) {
const token = randomBytes(32).toString("hex");
await prisma.$transaction(async () => {
await prisma.verificationToken.create({
data: {
identifier: oldEmail?.toLowerCase(),
token,
expires: new Date(Date.now() + 24 * 3600 * 1000), // 1 day
},
});
await prisma.user.update({
where: {
email: oldEmail?.toLowerCase(),
},
data: {
unverifiedNewEmail: newEmail?.toLowerCase(),
},
});
});
const emailsDir = path.resolve(process.cwd(), "templates");
const templateFile = readFileSync(
path.join(emailsDir, "verifyEmailChange.html"),
"utf8"
);
const emailTemplate = Handlebars.compile(templateFile);
transporter.sendMail({
from: {
name: "Linkwarden",
address: process.env.EMAIL_FROM as string,
},
to: newEmail,
subject: "Verify your new Linkwarden email address",
html: emailTemplate({
user,
baseUrl: process.env.BASE_URL,
oldEmail,
newEmail,
verifyUrl: `${process.env.BASE_URL}/auth/verify-email?token=${token}`,
}),
});
}
+44
View File
@@ -0,0 +1,44 @@
import { randomBytes } from "crypto";
import { prisma } from "./db";
import transporter from "./transporter";
import Handlebars from "handlebars";
import { readFileSync } from "fs";
import path from "path";
export default async function sendPasswordResetRequest(
email: string,
user: string
) {
const token = randomBytes(32).toString("hex");
await prisma.passwordResetToken.create({
data: {
identifier: email?.toLowerCase(),
token,
expires: new Date(Date.now() + 24 * 3600 * 1000), // 1 day
},
});
const emailsDir = path.resolve(process.cwd(), "templates");
const templateFile = readFileSync(
path.join(emailsDir, "passwordReset.html"),
"utf8"
);
const emailTemplate = Handlebars.compile(templateFile);
transporter.sendMail({
from: {
name: "Linkwarden",
address: process.env.EMAIL_FROM as string,
},
to: email,
subject: "Linkwarden: Reset password instructions",
html: emailTemplate({
user,
baseUrl: process.env.BASE_URL,
url: `${process.env.BASE_URL}/auth/reset-password?token=${token}`,
}),
});
}
+37 -61
View File
@@ -1,19 +1,44 @@
import { Theme } from "next-auth";
import { SendVerificationRequestParams } from "next-auth/providers";
import { createTransport } from "nodemailer";
import { readFileSync } from "fs";
import path from "path";
import Handlebars from "handlebars";
import transporter from "./transporter";
type Params = {
identifier: string;
url: string;
from: string;
token: string;
};
export default async function sendVerificationRequest({
identifier,
url,
from,
token,
}: Params) {
const emailsDir = path.resolve(process.cwd(), "templates");
const templateFile = readFileSync(
path.join(emailsDir, "verifyEmail.html"),
"utf8"
);
const emailTemplate = Handlebars.compile(templateFile);
export default async function sendVerificationRequest(
params: SendVerificationRequestParams
) {
const { identifier, url, provider, theme } = params;
const { host } = new URL(url);
const transport = createTransport(provider.server);
const result = await transport.sendMail({
const result = await transporter.sendMail({
to: identifier,
from: provider.from,
subject: `Sign in to ${host}`,
from: {
name: "Linkwarden",
address: from as string,
},
subject: `Please verify your email address`,
text: text({ url, host }),
html: html({ url, host, theme }),
html: emailTemplate({
url: `${
process.env.NEXTAUTH_URL
}/callback/email?token=${token}&email=${encodeURIComponent(identifier)}`,
}),
});
const failed = result.rejected.concat(result.pending).filter(Boolean);
if (failed.length) {
@@ -21,55 +46,6 @@ export default async function sendVerificationRequest(
}
}
function html(params: { url: string; host: string; theme: Theme }) {
const { url, host, theme } = params;
const escapedHost = host.replace(/\./g, "&#8203;.");
const brandColor = theme.brandColor || "#0029cf";
const color = {
background: "#f9f9f9",
text: "#444",
mainBackground: "#fff",
buttonBackground: brandColor,
buttonBorder: brandColor,
buttonText: theme.buttonText || "#fff",
};
return `
<body style="background: ${color.background};">
<table width="100%" border="0" cellspacing="20" cellpadding="0"
style="background: ${color.mainBackground}; max-width: 600px; margin: auto; border-radius: 10px;">
<tr>
<td align="center"
style="padding: 10px 0px; font-size: 22px; font-family: Helvetica, Arial, sans-serif; color: ${color.text};">
Sign in to <strong>${escapedHost}</strong>
</td>
</tr>
<tr>
<td align="center" style="padding: 20px 0;">
<table border="0" cellspacing="0" cellpadding="0">
<tr>
<td align="center" style="border-radius: 5px;" bgcolor="${color.buttonBackground}">
<a href="${url}" target="_blank" style="font-size: 18px; font-family: Helvetica, Arial, sans-serif; color: ${color.buttonText}; text-decoration: none; border-radius: 5px; padding: 10px 20px; border: 1px solid ${color.buttonBorder}; display: inline-block; font-weight: bold;">
Sign in
</a>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td align="center"
style="padding: 0px 0px 10px 0px; font-size: 16px; line-height: 22px; font-family: Helvetica, Arial, sans-serif; color: ${color.text};">
If you did not request this email you can safely ignore it.
</td>
</tr>
</table>
</body>
`;
}
/** Email Text body (fallback for email clients that don't render HTML, e.g. feature phones) */
function text({ url, host }: { url: string; host: string }) {
return `Sign in to ${host}\n${url}\n\n`;
+86
View File
@@ -0,0 +1,86 @@
import { prisma } from "./db";
import getPermission from "./getPermission";
import { UsersAndCollections } from "@prisma/client";
import { PostLinkSchemaType } from "../shared/schemaValidation";
const setLinkCollection = async (link: PostLinkSchemaType, userId: number) => {
if (link.collection?.id && typeof link.collection?.id === "number") {
const existingCollection = await prisma.collection.findUnique({
where: {
id: link.collection.id,
},
});
if (!existingCollection) return null;
const collectionIsAccessible = await getPermission({
userId,
collectionId: existingCollection.id,
});
const memberHasAccess = collectionIsAccessible?.members.some(
(e: UsersAndCollections) => e.userId === userId && e.canCreate
);
if (!(collectionIsAccessible?.ownerId === userId || memberHasAccess))
return null;
return existingCollection;
} else if (link.collection?.name) {
if (link.collection.name === "Unorganized") {
const firstTopLevelUnorganizedCollection =
await prisma.collection.findFirst({
where: {
name: "Unorganized",
ownerId: userId,
parentId: null,
},
});
if (firstTopLevelUnorganizedCollection)
return firstTopLevelUnorganizedCollection;
}
const newCollection = await prisma.collection.create({
data: {
name: link.collection.name.trim(),
ownerId: userId,
},
});
await prisma.user.update({
where: {
id: userId,
},
data: {
collectionOrder: {
push: newCollection.id,
},
},
});
return newCollection;
} else {
const firstTopLevelUnorganizedCollection =
await prisma.collection.findFirst({
where: {
name: "Unorganized",
ownerId: userId,
parentId: null,
},
});
if (firstTopLevelUnorganizedCollection)
return firstTopLevelUnorganizedCollection;
else
return await prisma.collection.create({
data: {
name: "Unorganized",
ownerId: userId,
parentId: null,
},
});
}
};
export default setLinkCollection;
+1 -1
View File
@@ -14,7 +14,7 @@ export default async function moveFile(from: string, to: string) {
};
try {
s3Client.copyObject(copyParams, async (err: any) => {
s3Client.copyObject(copyParams, async (err: unknown) => {
if (err) {
console.error("Error copying the object:", err);
} else {
+5
View File
@@ -10,6 +10,7 @@ import util from "util";
type ReturnContentTypes =
| "text/plain"
| "text/html"
| "image/jpeg"
| "image/png"
| "application/pdf"
@@ -61,6 +62,8 @@ export default async function readFile(filePath: string) {
contentType = "image/png";
} else if (filePath.endsWith("_readability.json")) {
contentType = "application/json";
} else if (filePath.endsWith(".html")) {
contentType = "text/html";
} else {
// if (filePath.endsWith(".jpg"))
contentType = "image/jpeg";
@@ -88,6 +91,8 @@ export default async function readFile(filePath: string) {
contentType = "image/png";
} else if (filePath.endsWith("_readability.json")) {
contentType = "application/json";
} else if (filePath.endsWith(".html")) {
contentType = "text/html";
} else {
// if (filePath.endsWith(".jpg"))
contentType = "image/jpeg";
+8
View File
@@ -0,0 +1,8 @@
import { createTransport } from "nodemailer";
export default createTransport({
url: process.env.EMAIL_SERVER,
auth: {
user: process.env.EMAIL_FROM,
},
});
+63
View File
@@ -0,0 +1,63 @@
import { prisma } from "./db";
import { User } from "@prisma/client";
import verifySubscription from "./verifySubscription";
import bcrypt from "bcrypt";
type Props = {
username: string;
password: string;
};
const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY;
const emailEnabled =
process.env.EMAIL_FROM && process.env.EMAIL_SERVER ? true : false;
export default async function verifyByCredentials({
username,
password,
}: Props): Promise<User | null> {
const user = await prisma.user.findFirst({
where: emailEnabled
? {
OR: [
{
username: username.toLowerCase(),
},
{
email: username?.toLowerCase(),
},
],
}
: {
username: username.toLowerCase(),
},
include: {
subscriptions: true,
},
});
if (!user) {
return null;
}
let passwordMatches: boolean = false;
if (user?.password) {
passwordMatches = bcrypt.compareSync(password, user.password);
if (!passwordMatches) {
return null;
} else {
if (STRIPE_SECRET_KEY) {
const subscribedUser = await verifySubscription(user);
if (!subscribedUser) {
return null;
}
}
return user;
}
} else {
return null;
}
}
+1 -7
View File
@@ -32,19 +32,13 @@ export default async function verifyUser({
subscriptions: true,
},
});
const ssoUser = await prisma.account.findFirst({
where: {
userId: userId,
},
});
if (!user) {
res.status(404).json({ response: "User not found." });
return null;
}
if (!user.username && !ssoUser) {
// SSO users don't need a username
if (!user.username) {
res.status(401).json({
response: "Username not found.",
});