Merge remote-tracking branch 'upstream/dev' into tags-in-public-collection
This commit is contained in:
+69
-287
@@ -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: {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ export default async function getToken(userId: number) {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
isSession: true,
|
||||
expires: true,
|
||||
createdAt: true,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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}`,
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -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}`,
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -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, "​.");
|
||||
|
||||
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`;
|
||||
|
||||
@@ -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;
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { createTransport } from "nodemailer";
|
||||
|
||||
export default createTransport({
|
||||
url: process.env.EMAIL_SERVER,
|
||||
auth: {
|
||||
user: process.env.EMAIL_FROM,
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.",
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user