diff --git a/layouts/MainLayout.tsx b/layouts/MainLayout.tsx
index cc7fc8f..2c606e5 100644
--- a/layouts/MainLayout.tsx
+++ b/layouts/MainLayout.tsx
@@ -1,5 +1,5 @@
import Navbar from "@/components/Navbar";
-import AnnouncementBar from "@/components/AnnouncementBar";
+import Announcement from "@/components/Announcement";
import Sidebar from "@/components/Sidebar";
import { ReactNode, useEffect, useState } from "react";
import getLatestVersion from "@/lib/client/getLatestVersion";
@@ -33,27 +33,20 @@ export default function MainLayout({ children }: Props) {
};
return (
- <>
+
{showAnnouncement ? (
-
+
) : undefined}
-
-
-
-
-
-
-
-
- {children}
-
+
+
- >
+
+
+
+ {children}
+
+
);
}
diff --git a/lib/api/archiveHandler.ts b/lib/api/archiveHandler.ts
index aaf1c98..c5e5f09 100644
--- a/lib/api/archiveHandler.ts
+++ b/lib/api/archiveHandler.ts
@@ -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,12 +79,7 @@ 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,
@@ -96,6 +94,9 @@ export default async function archiveHandler(link: LinksAndCollectionAndOwner) {
readable: !link.readable?.startsWith("archive")
? "pending"
: undefined,
+ monolith: !link.monolith?.startsWith("archive")
+ ? "pending"
+ : undefined,
preview: !link.readable?.startsWith("archive")
? "pending"
: undefined,
@@ -103,6 +104,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 +120,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 +175,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 +187,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
((_, reject) => {
- setTimeout(() => {
- reject(new Error(`Webpage was too long to be archived.`));
- }, AUTOSCROLL_TIMEOUT * 1000);
- });
-
- const scrollingPromise = new Promise((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`,
- },
- });
- }
-};
diff --git a/lib/api/controllers/collections/collectionId/deleteCollectionById.ts b/lib/api/controllers/collections/collectionId/deleteCollectionById.ts
index e8fe51a..1e21809 100644
--- a/lib/api/controllers/collections/collectionId/deleteCollectionById.ts
+++ b/lib/api/controllers/collections/collectionId/deleteCollectionById.ts
@@ -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(
@@ -58,6 +58,7 @@ export default async function deleteCollection(
});
await removeFolder({ filePath: `archives/${collectionId}` });
+ await removeFolder({ filePath: `archives/preview/${collectionId}` });
await removeFromOrders(userId, collectionId);
@@ -100,6 +101,7 @@ async function deleteSubCollections(collectionId: number) {
});
await removeFolder({ filePath: `archives/${subCollection.id}` });
+ await removeFolder({ filePath: `archives/preview/${subCollection.id}` });
}
}
diff --git a/lib/api/controllers/dashboard/getDashboardData.ts b/lib/api/controllers/dashboard/getDashboardData.ts
index b4dfbb0..3b0f751 100644
--- a/lib/api/controllers/dashboard/getDashboardData.ts
+++ b/lib/api/controllers/dashboard/getDashboardData.ts
@@ -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: [
{
@@ -46,7 +46,7 @@ export default async function getDashboardData(
});
const recentlyAddedLinks = await prisma.link.findMany({
- take: 8,
+ take: 10,
where: {
collection: {
OR: [
diff --git a/lib/api/controllers/dashboard/getDashboardDataV2.ts b/lib/api/controllers/dashboard/getDashboardDataV2.ts
new file mode 100644
index 0000000..6d2fb02
--- /dev/null
+++ b/lib/api/controllers/dashboard/getDashboardDataV2.ts
@@ -0,0 +1,119 @@
+import { prisma } from "@/lib/api/db";
+import { LinkRequestQuery, Sort } from "@/types/global";
+
+type Response =
+ | {
+ data: D;
+ message: string;
+ status: number;
+ }
+ | {
+ data: D;
+ message: string;
+ status: number;
+ };
+
+export default async function getDashboardData(
+ userId: number,
+ query: LinkRequestQuery
+): Promise> {
+ let order: any;
+ 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: 10,
+ 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: 10,
+ 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) as any) - (new Date(a.id) as any)
+ );
+
+ return {
+ data: {
+ links,
+ numberOfPinnedLinks,
+ },
+ message: "Dashboard data fetched successfully.",
+ status: 200,
+ };
+}
diff --git a/lib/api/controllers/links/bulk/deleteLinksById.ts b/lib/api/controllers/links/bulk/deleteLinksById.ts
index 466db98..a9c395b 100644
--- a/lib/api/controllers/links/bulk/deleteLinksById.ts
+++ b/lib/api/controllers/links/bulk/deleteLinksById.ts
@@ -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 };
diff --git a/lib/api/controllers/links/getLinks.ts b/lib/api/controllers/links/getLinks.ts
index 0086e93..561c919 100644
--- a/lib/api/controllers/links/getLinks.ts
+++ b/lib/api/controllers/links/getLinks.ts
@@ -2,7 +2,8 @@ import { prisma } from "@/lib/api/db";
import { LinkRequestQuery, 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;
if (query.sort === Sort.DateNewestFirst) order = { id: "desc" };
@@ -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: {
diff --git a/lib/api/controllers/links/linkId/deleteLinkById.ts b/lib/api/controllers/links/linkId/deleteLinkById.ts
index db68ee7..abff6f0 100644
--- a/lib/api/controllers/links/linkId/deleteLinkById.ts
+++ b/lib/api/controllers/links/linkId/deleteLinkById.ts
@@ -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 };
}
diff --git a/lib/api/controllers/links/linkId/updateLinkById.ts b/lib/api/controllers/links/linkId/updateLinkById.ts
index e6f7f0d..306696a 100644
--- a/lib/api/controllers/links/linkId/updateLinkById.ts
+++ b/lib/api/controllers/links/linkId/updateLinkById.ts
@@ -2,7 +2,7 @@ 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 } from "@/lib/api/manageLinkFiles";
export default async function updateLinkById(
userId: number,
@@ -48,7 +48,7 @@ export default async function updateLinkById(
},
});
- return { response: updatedLink, status: 200 };
+ // return { response: updatedLink, status: 200 };
}
const targetCollectionIsAccessible = await getPermission({
@@ -60,9 +60,6 @@ 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
@@ -71,12 +68,7 @@ export default async function updateLinkById(
? 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,
@@ -146,20 +138,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 };
diff --git a/lib/api/controllers/links/postLink.ts b/lib/api/controllers/links/postLink.ts
index ba4e513..8056c11 100644
--- a/lib/api/controllers/links/postLink.ts
+++ b/lib/api/controllers/links/postLink.ts
@@ -1,10 +1,8 @@
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";
const MAX_LINKS_PER_USER = Number(process.env.MAX_LINKS_PER_USER) || 30000;
@@ -12,104 +10,23 @@ export default async function postLink(
link: LinkIncludingShortenedCollectionAndTags,
userId: number
) {
- try {
- new URL(link.url || "");
- } catch (error) {
- return {
- response:
- "Please enter a valid Address for the Link. (It should start with http/https)",
- status: 400,
- };
- }
-
- if (!link.collection.id && link.collection.name) {
- link.collection.name = link.collection.name.trim();
-
- // 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,
- },
- });
-
- 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,
- },
- },
- });
+ if (link.url || link.type === "url") {
+ try {
+ new URL(link.url || "");
+ } catch (error) {
+ return {
+ response:
+ "Please enter a valid Address for the Link. (It should start with http/https)",
+ status: 400,
+ };
}
- } 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 };
}
+ const linkCollection = await setLinkCollection(link, userId);
+
+ if (!linkCollection)
+ return { response: "Collection is not accessible.", status: 400 };
+
const user = await prisma.user.findUnique({
where: {
id: userId,
@@ -117,9 +34,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 +58,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";
@@ -172,13 +88,13 @@ export default async function postLink(
const newLink = await prisma.link.create({
data: {
- url: link.url?.trim(),
- name: link.name,
- description,
+ url: link.url?.trim().replace(/\/+$/, "") || null,
+ name,
+ description: link.description,
type: linkType,
collection: {
connect: {
- id: link.collection.id,
+ id: linkCollection.id,
},
},
tags: {
@@ -186,14 +102,14 @@ export default async function postLink(
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,
},
},
},
diff --git a/lib/api/controllers/migration/importFromHTMLFile.ts b/lib/api/controllers/migration/importFromHTMLFile.ts
index 21fb3c4..b2b58a1 100644
--- a/lib/api/controllers/migration/importFromHTMLFile.ts
+++ b/lib/api/controllers/migration/importFromHTMLFile.ts
@@ -31,7 +31,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 +63,21 @@ 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,
@@ -264,3 +274,4 @@ function processNodes(nodes: Node[]) {
nodes.forEach(findAndProcessDL);
return nodes;
}
+
diff --git a/lib/api/controllers/migration/importFromLinkwarden.ts b/lib/api/controllers/migration/importFromLinkwarden.ts
index 3f91f19..5e872e8 100644
--- a/lib/api/controllers/migration/importFromLinkwarden.ts
+++ b/lib/api/controllers/migration/importFromLinkwarden.ts
@@ -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,
};
@@ -54,7 +54,7 @@ export default async function importFromLinkwarden(
// Import Links
for (const link of e.links) {
- const newLink = await prisma.link.create({
+ await prisma.link.create({
data: {
url: link.url,
name: link.name,
diff --git a/lib/api/controllers/migration/importFromWallabag.ts b/lib/api/controllers/migration/importFromWallabag.ts
new file mode 100644
index 0000000..568952a
--- /dev/null
+++ b/lib/api/controllers/migration/importFromWallabag.ts
@@ -0,0 +1,115 @@
+import { prisma } from "@/lib/api/db";
+import { Backup } from "@/types/global";
+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;
+}[];
+
+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) {
+ await prisma.link.create({
+ data: {
+ pinnedBy: link.is_starred
+ ? { connect: { id: userId } }
+ : undefined,
+ url: link.url,
+ name: link.title || "",
+ textContent: link.content || "",
+ 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(),
+ ownerId: userId,
+ },
+ },
+ create: {
+ name: tag.trim(),
+ owner: {
+ connect: {
+ id: userId,
+ },
+ },
+ },
+ })),
+ }
+ : undefined,
+ },
+ });
+ }
+ },
+ { timeout: 30000 }
+ )
+ .catch((err) => console.log(err));
+
+ return { response: "Success.", status: 200 };
+}
diff --git a/lib/api/controllers/public/links/getPublicLinksUnderCollection.ts b/lib/api/controllers/public/links/getPublicLinksUnderCollection.ts
index e94a3e1..d93e804 100644
--- a/lib/api/controllers/public/links/getPublicLinksUnderCollection.ts
+++ b/lib/api/controllers/public/links/getPublicLinksUnderCollection.ts
@@ -4,7 +4,8 @@ import { LinkRequestQuery, Sort } from "@/types/global";
export default async function getLink(
query: Omit
) {
- const POSTGRES_IS_ENABLED = process.env.DATABASE_URL.startsWith("postgresql");
+ const POSTGRES_IS_ENABLED =
+ process.env.DATABASE_URL?.startsWith("postgresql");
let order: any;
if (query.sort === Sort.DateNewestFirst) order = { id: "desc" };
@@ -68,7 +69,7 @@ export default async function getLink(
}
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: {
diff --git a/lib/api/controllers/public/users/getPublicUser.ts b/lib/api/controllers/public/users/getPublicUser.ts
index 04c7994..68b6fab 100644
--- a/lib/api/controllers/public/users/getPublicUser.ts
+++ b/lib/api/controllers/public/users/getPublicUser.ts
@@ -75,6 +75,7 @@ export default async function getPublicUser(
username: lessSensitiveInfo.username,
image: lessSensitiveInfo.image,
archiveAsScreenshot: lessSensitiveInfo.archiveAsScreenshot,
+ archiveAsMonolith: lessSensitiveInfo.archiveAsMonolith,
archiveAsPDF: lessSensitiveInfo.archiveAsPDF,
};
diff --git a/lib/api/controllers/session/createSession.ts b/lib/api/controllers/session/createSession.ts
new file mode 100644
index 0000000..c1a6aa7
--- /dev/null
+++ b/lib/api/controllers/session/createSession.ts
@@ -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,
+ });
+
+ const createToken = await prisma.accessToken.create({
+ data: {
+ name: sessionName || "Unknown Device",
+ userId,
+ token: tokenBody?.jti as string,
+ isSession: true,
+ expires: expiryDate,
+ },
+ });
+
+ return {
+ response: {
+ token,
+ },
+ status: 200,
+ };
+}
diff --git a/lib/api/controllers/tokens/getTokens.ts b/lib/api/controllers/tokens/getTokens.ts
index a5db351..1c6efe3 100644
--- a/lib/api/controllers/tokens/getTokens.ts
+++ b/lib/api/controllers/tokens/getTokens.ts
@@ -9,6 +9,7 @@ export default async function getToken(userId: number) {
select: {
id: true,
name: true,
+ isSession: true,
expires: true,
createdAt: true,
},
diff --git a/lib/api/controllers/tokens/postToken.ts b/lib/api/controllers/tokens/postToken.ts
index f88030d..faedd8a 100644
--- a/lib/api/controllers/tokens/postToken.ts
+++ b/lib/api/controllers/tokens/postToken.ts
@@ -65,12 +65,12 @@ 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({
diff --git a/lib/api/controllers/users/getUsers.ts b/lib/api/controllers/users/getUsers.ts
new file mode 100644
index 0000000..496efcf
--- /dev/null
+++ b/lib/api/controllers/users/getUsers.ts
@@ -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 };
+}
diff --git a/lib/api/controllers/users/postUser.ts b/lib/api/controllers/users/postUser.ts
index f24738b..464760e 100644
--- a/lib/api/controllers/users/postUser.ts
+++ b/lib/api/controllers/users/postUser.ts
@@ -1,12 +1,15 @@
import { prisma } from "@/lib/api/db";
import type { NextApiRequest, NextApiResponse } from "next";
import bcrypt from "bcrypt";
+import isServerAdmin from "../../isServerAdmin";
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;
+ status: number;
}
interface User {
@@ -18,10 +21,12 @@ interface User {
export default async function postUser(
req: NextApiRequest,
- res: NextApiResponse
-) {
- if (process.env.NEXT_PUBLIC_DISABLE_REGISTRATION === "true") {
- return res.status(400).json({ response: "Registration is disabled." });
+ res: NextApiResponse
+): Promise {
+ 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;
@@ -31,61 +36,106 @@ export default async function postUser(
: !body.username || !body.password || !body.name;
if (!body.password || body.password.length < 8)
- return res
- .status(400)
- .json({ response: "Password must be at least 8 characters." });
+ return { response: "Password must be at least 8 characters.", status: 400 };
if (checkHasEmptyFields)
- return res
- .status(400)
- .json({ response: "Please fill out all the fields." });
+ return { response: "Please fill out all the fields.", status: 400 };
// 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.",
- });
+ return { response: "Please enter a valid email.", status: 400 };
// 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({
+ return {
response:
"Username has to be between 3-30 characters, no spaces and special characters are allowed.",
- });
+ status: 400,
+ };
const checkIfUserExists = await prisma.user.findFirst({
- where: emailEnabled
- ? {
- email: body.email?.toLowerCase().trim(),
- }
- : {
- username: (body.username as string).toLowerCase().trim(),
+ where: {
+ OR: [
+ {
+ email: body.email ? body.email.toLowerCase().trim() : undefined,
},
+ {
+ username: body.username
+ ? body.username.toLowerCase().trim()
+ : undefined,
+ },
+ ],
+ },
});
if (!checkIfUserExists) {
+ const autoGeneratedUsername =
+ "user" + Math.round(Math.random() * 1000000000);
+
const saltRounds = 10;
const hashedPassword = bcrypt.hashSync(body.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: body.name,
+ username: emailEnabled
+ ? autoGeneratedUsername
+ : (body.username as string).toLowerCase().trim(),
+ email: emailEnabled ? body.email?.toLowerCase().trim() : 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: body.name,
+ username: emailEnabled
+ ? autoGeneratedUsername
+ : (body.username as string).toLowerCase().trim(),
+ email: emailEnabled ? body.email?.toLowerCase().trim() : undefined,
+ password: hashedPassword,
+ },
+ });
+
+ return { response: "User successfully created.", status: 201 };
+ }
+ } else {
+ return { response: "Email or Username already exists.", status: 400 };
}
}
diff --git a/lib/api/controllers/users/userId/deleteUserById.ts b/lib/api/controllers/users/userId/deleteUserById.ts
index 2a5a083..a6e6106 100644
--- a/lib/api/controllers/users/userId/deleteUserById.ts
+++ b/lib/api/controllers/users/userId/deleteUserById.ts
@@ -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,23 @@ 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 as string
+ );
- 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 +48,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 +80,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 +94,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: {
diff --git a/lib/api/controllers/users/userId/updateUserById.ts b/lib/api/controllers/users/userId/updateUserById.ts
index f2b5e91..ebae737 100644
--- a/lib/api/controllers/users/userId/updateUserById.ts
+++ b/lib/api/controllers/users/userId/updateUserById.ts
@@ -3,8 +3,9 @@ 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";
const emailEnabled =
process.env.EMAIL_FROM && process.env.EMAIL_SERVER ? true : false;
@@ -13,162 +14,176 @@ export default async function updateUserById(
userId: number,
data: AccountSettings
) {
- const ssoUser = await prisma.account.findFirst({
+ if (emailEnabled && !data.email)
+ return {
+ response: "Email invalid.",
+ status: 400,
+ };
+ else if (!data.username)
+ return {
+ response: "Username invalid.",
+ status: 400,
+ };
+
+ // Check email (if enabled)
+ const checkEmail =
+ /^(([^<>()[\]\.,;:\s@\"]+(\.[^<>()[\]\.,;:\s@\"]+)*)|(\".+\"))@(([^<>()[\]\.,;:\s@\"]+\.)+[^<>()[\]\.,;:\s@\"]{2,})$/i;
+ if (emailEnabled && !checkEmail.test(data.email?.toLowerCase() || ""))
+ return {
+ response: "Please enter a valid email.",
+ 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: {
- 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 },
+ });
+
+ 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()
+ );
+ }
+
+ // 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);
@@ -178,15 +193,21 @@ export default async function updateUserById(
id: userId,
},
data: {
- name: data.name,
+ name: data.name.trim(),
username: data.username?.toLowerCase().trim(),
- email: data.email?.toLowerCase().trim(),
isPrivate: data.isPrivate,
- image: data.image ? `uploads/avatar/${userId}.jpg` : "",
+ 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 +265,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 = {
...userInfo,
whitelistedUsers: newWhitelistedUsernames,
diff --git a/lib/api/validateUrlSize.ts b/lib/api/fetchHeaders.ts
similarity index 65%
rename from lib/api/validateUrlSize.ts
rename to lib/api/fetchHeaders.ts
index d5826b8..f423661 100644
--- a/lib/api/validateUrlSize.ts
+++ b/lib/api/fetchHeaders.ts
@@ -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;
diff --git a/lib/api/generatePreview.ts b/lib/api/generatePreview.ts
new file mode 100644
index 0000000..2693be2
--- /dev/null
+++ b/lib/api/generatePreview.ts
@@ -0,0 +1,47 @@
+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(1280, 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;
+ }
+
+ 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;
diff --git a/lib/api/getPermission.ts b/lib/api/getPermission.ts
index 93dd04c..61dc5c5 100644
--- a/lib/api/getPermission.ts
+++ b/lib/api/getPermission.ts
@@ -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 },
diff --git a/lib/api/isServerAdmin.ts b/lib/api/isServerAdmin.ts
new file mode 100644
index 0000000..d34cc07
--- /dev/null
+++ b/lib/api/isServerAdmin.ts
@@ -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 {
+ 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;
+ }
+}
diff --git a/lib/api/manageLinkFiles.ts b/lib/api/manageLinkFiles.ts
new file mode 100644
index 0000000..7498772
--- /dev/null
+++ b/lib/api/manageLinkFiles.ts
@@ -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 };
diff --git a/lib/api/preservationScheme/handleArchivePreview.ts b/lib/api/preservationScheme/handleArchivePreview.ts
new file mode 100644
index 0000000..9714985
--- /dev/null
+++ b/lib/api/preservationScheme/handleArchivePreview.ts
@@ -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;
diff --git a/lib/api/preservationScheme/handleMonolith.ts b/lib/api/preservationScheme/handleMonolith.ts
new file mode 100644
index 0000000..f7c08f5
--- /dev/null
+++ b/lib/api/preservationScheme/handleMonolith.ts
@@ -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;
diff --git a/lib/api/preservationScheme/handleReadablility.ts b/lib/api/preservationScheme/handleReadablility.ts
new file mode 100644
index 0000000..e274fc2
--- /dev/null
+++ b/lib/api/preservationScheme/handleReadablility.ts
@@ -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;
diff --git a/lib/api/preservationScheme/handleScreenshotAndPdf.ts b/lib/api/preservationScheme/handleScreenshotAndPdf.ts
new file mode 100644
index 0000000..fee5126
--- /dev/null
+++ b/lib/api/preservationScheme/handleScreenshotAndPdf.ts
@@ -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((resolve) => {
+ setTimeout(() => {
+ resolve();
+ }, AUTOSCROLL_TIMEOUT * 1000);
+ });
+
+ const scrollingPromise = new Promise((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;
diff --git a/lib/api/preservationScheme/imageHandler.ts b/lib/api/preservationScheme/imageHandler.ts
new file mode 100644
index 0000000..273c010
--- /dev/null
+++ b/lib/api/preservationScheme/imageHandler.ts
@@ -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;
diff --git a/lib/api/preservationScheme/pdfHandler.ts b/lib/api/preservationScheme/pdfHandler.ts
new file mode 100644
index 0000000..7216cef
--- /dev/null
+++ b/lib/api/preservationScheme/pdfHandler.ts
@@ -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;
diff --git a/lib/api/sendToWayback.ts b/lib/api/preservationScheme/sendToWayback.ts
similarity index 100%
rename from lib/api/sendToWayback.ts
rename to lib/api/preservationScheme/sendToWayback.ts
diff --git a/lib/api/sendChangeEmailVerificationRequest.ts b/lib/api/sendChangeEmailVerificationRequest.ts
new file mode 100644
index 0000000..475ef8a
--- /dev/null
+++ b/lib/api/sendChangeEmailVerificationRequest.ts
@@ -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}`,
+ }),
+ });
+}
diff --git a/lib/api/sendPasswordResetRequest.ts b/lib/api/sendPasswordResetRequest.ts
new file mode 100644
index 0000000..b94ccef
--- /dev/null
+++ b/lib/api/sendPasswordResetRequest.ts
@@ -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}`,
+ }),
+ });
+}
diff --git a/lib/api/sendVerificationRequest.ts b/lib/api/sendVerificationRequest.ts
index 5951e9f..eb19a88 100644
--- a/lib/api/sendVerificationRequest.ts
+++ b/lib/api/sendVerificationRequest.ts
@@ -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 `
-
-
-
-
- Sign in to ${escapedHost}
-
-
-
-
-
-
-
-
-
- If you did not request this email you can safely ignore it.
-
-
-
-
-`;
-}
-
/** 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`;
diff --git a/lib/api/setLinkCollection.ts b/lib/api/setLinkCollection.ts
new file mode 100644
index 0000000..901e585
--- /dev/null
+++ b/lib/api/setLinkCollection.ts
@@ -0,0 +1,89 @@
+import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
+import { prisma } from "./db";
+import getPermission from "./getPermission";
+import { UsersAndCollections } from "@prisma/client";
+
+const setLinkCollection = async (
+ link: LinkIncludingShortenedCollectionAndTags,
+ 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;
diff --git a/lib/api/storage/readFile.ts b/lib/api/storage/readFile.ts
index 350726d..fc9d2e7 100644
--- a/lib/api/storage/readFile.ts
+++ b/lib/api/storage/readFile.ts
@@ -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";
diff --git a/lib/api/transporter.ts b/lib/api/transporter.ts
new file mode 100644
index 0000000..f07dd9c
--- /dev/null
+++ b/lib/api/transporter.ts
@@ -0,0 +1,8 @@
+import { createTransport } from "nodemailer";
+
+export default createTransport({
+ url: process.env.EMAIL_SERVER,
+ auth: {
+ user: process.env.EMAIL_FROM,
+ },
+});
diff --git a/lib/api/verifyByCredentials.ts b/lib/api/verifyByCredentials.ts
new file mode 100644
index 0000000..a0bbba2
--- /dev/null
+++ b/lib/api/verifyByCredentials.ts
@@ -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 {
+ 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;
+ }
+}
diff --git a/lib/api/verifyUser.ts b/lib/api/verifyUser.ts
index 6e5fdf5..d77a74f 100644
--- a/lib/api/verifyUser.ts
+++ b/lib/api/verifyUser.ts
@@ -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.",
});
diff --git a/lib/client/addMemberToCollection.ts b/lib/client/addMemberToCollection.ts
index 8d1e0b1..4e03720 100644
--- a/lib/client/addMemberToCollection.ts
+++ b/lib/client/addMemberToCollection.ts
@@ -1,12 +1,14 @@
import { CollectionIncludingMembersAndLinkCount, Member } from "@/types/global";
import getPublicUserData from "./getPublicUserData";
import { toast } from "react-hot-toast";
+import { TFunction } from "i18next";
const addMemberToCollection = async (
ownerUsername: string,
memberUsername: string,
collection: CollectionIncludingMembersAndLinkCount,
- setMember: (newMember: Member) => null | undefined
+ setMember: (newMember: Member) => null | undefined,
+ t: TFunction<"translation", undefined>
) => {
const checkIfMemberAlreadyExists = collection.members.find((e) => {
const username = (e.user.username || "").toLowerCase();
@@ -39,9 +41,9 @@ const addMemberToCollection = async (
},
});
}
- } else if (checkIfMemberAlreadyExists) toast.error("User already exists.");
+ } else if (checkIfMemberAlreadyExists) toast.error(t("user_already_member"));
else if (memberUsername.trim().toLowerCase() === ownerUsername.toLowerCase())
- toast.error("You are already the collection owner.");
+ toast.error(t("you_are_already_collection_owner"));
};
export default addMemberToCollection;
diff --git a/lib/client/generateLinkHref.ts b/lib/client/generateLinkHref.ts
index 47c1888..721c671 100644
--- a/lib/client/generateLinkHref.ts
+++ b/lib/client/generateLinkHref.ts
@@ -8,6 +8,7 @@ import {
pdfAvailable,
readabilityAvailable,
screenshotAvailable,
+ monolithAvailable,
} from "../shared/getArchiveValidity";
export const generateLinkHref = (
@@ -16,24 +17,33 @@ export const generateLinkHref = (
): string => {
// Return the links href based on the account's preference
// If the user's preference is not available, return the original link
- switch (account.linksRouteTo) {
- case LinksRouteTo.ORIGINAL:
- return link.url || "";
- case LinksRouteTo.PDF:
- if (!pdfAvailable(link)) return link.url || "";
+ if (account.linksRouteTo === LinksRouteTo.ORIGINAL && link.type === "url") {
+ return link.url || "";
+ } else if (account.linksRouteTo === LinksRouteTo.PDF || link.type === "pdf") {
+ if (!pdfAvailable(link)) return link.url || "";
- return `/preserved/${link?.id}?format=${ArchivedFormat.pdf}`;
- case LinksRouteTo.READABLE:
- if (!readabilityAvailable(link)) return link.url || "";
+ return `/preserved/${link?.id}?format=${ArchivedFormat.pdf}`;
+ } else if (
+ account.linksRouteTo === LinksRouteTo.READABLE &&
+ link.type === "url"
+ ) {
+ if (!readabilityAvailable(link)) return link.url || "";
- return `/preserved/${link?.id}?format=${ArchivedFormat.readability}`;
- case LinksRouteTo.SCREENSHOT:
- if (!screenshotAvailable(link)) return link.url || "";
+ return `/preserved/${link?.id}?format=${ArchivedFormat.readability}`;
+ } else if (
+ account.linksRouteTo === LinksRouteTo.SCREENSHOT ||
+ link.type === "image"
+ ) {
+ if (!screenshotAvailable(link)) return link.url || "";
- return `/preserved/${link?.id}?format=${
- link?.image?.endsWith("png") ? ArchivedFormat.png : ArchivedFormat.jpeg
- }`;
- default:
- return link.url || "";
+ return `/preserved/${link?.id}?format=${
+ link?.image?.endsWith("png") ? ArchivedFormat.png : ArchivedFormat.jpeg
+ }`;
+ } else if (account.linksRouteTo === LinksRouteTo.MONOLITH) {
+ if (!monolithAvailable(link)) return link.url || "";
+
+ return `/preserved/${link?.id}?format=${ArchivedFormat.monolith}`;
+ } else {
+ return link.url || "";
}
};
diff --git a/lib/client/getServerSideProps.ts b/lib/client/getServerSideProps.ts
new file mode 100644
index 0000000..b208f3e
--- /dev/null
+++ b/lib/client/getServerSideProps.ts
@@ -0,0 +1,57 @@
+import { GetServerSideProps } from "next";
+import { serverSideTranslations } from "next-i18next/serverSideTranslations";
+import { i18n } from "next-i18next.config";
+import { getToken } from "next-auth/jwt";
+import { prisma } from "../api/db";
+
+const getServerSideProps: GetServerSideProps = async (ctx) => {
+ const acceptLanguageHeader = ctx.req.headers["accept-language"];
+ const availableLanguages = i18n.locales;
+
+ const token = await getToken({ req: ctx.req });
+
+ if (token) {
+ const user = await prisma.user.findUnique({
+ where: {
+ id: token.id,
+ },
+ });
+
+ if (user) {
+ return {
+ props: {
+ ...(await serverSideTranslations(user.locale ?? "en", ["common"])),
+ },
+ };
+ }
+ }
+
+ const acceptedLanguages = acceptLanguageHeader
+ ?.split(",")
+ .map((lang) => lang.split(";")[0]);
+
+ let bestMatch = acceptedLanguages?.find((lang) =>
+ availableLanguages.includes(lang)
+ );
+
+ if (!bestMatch) {
+ acceptedLanguages?.some((acceptedLang) => {
+ const partialMatch = availableLanguages.find((lang) =>
+ lang.startsWith(acceptedLang)
+ );
+ if (partialMatch) {
+ bestMatch = partialMatch;
+ return true;
+ }
+ return false;
+ });
+ }
+
+ return {
+ props: {
+ ...(await serverSideTranslations(bestMatch ?? "en", ["common"])),
+ },
+ };
+};
+
+export default getServerSideProps;
diff --git a/lib/client/utils.ts b/lib/client/utils.ts
index 7d139c5..e067933 100644
--- a/lib/client/utils.ts
+++ b/lib/client/utils.ts
@@ -18,3 +18,7 @@ export function dropdownTriggerer(e: any) {
}, 0);
}
}
+
+import clsx, { ClassValue } from "clsx";
+import { twMerge } from "tailwind-merge";
+export const cn = (...classes: ClassValue[]) => twMerge(clsx(...classes));
diff --git a/lib/shared/getTitle.ts b/lib/shared/fetchTitleAndHeaders.ts
similarity index 83%
rename from lib/shared/getTitle.ts
rename to lib/shared/fetchTitleAndHeaders.ts
index 01488fd..a777964 100644
--- a/lib/shared/getTitle.ts
+++ b/lib/shared/fetchTitleAndHeaders.ts
@@ -2,7 +2,7 @@ import fetch from "node-fetch";
import https from "https";
import { SocksProxyAgent } from "socks-proxy-agent";
-export default async function getTitle(url: string) {
+export default async function fetchTitleAndHeaders(url: string) {
try {
const httpsAgent = new https.Agent({
rejectUnauthorized:
@@ -41,12 +41,16 @@ export default async function getTitle(url: string) {
// regular expression to find the tag
let match = text.match(/([^<]*)<\/title>/);
- if (match) return match[1];
- else return "";
+
+ const title = match[1] || "";
+ const headers = (response as Response)?.headers || null;
+
+ return { title, headers };
} else {
- return "";
+ return { title: "", headers: null };
}
} catch (err) {
console.log(err);
+ return { title: "", headers: null };
}
}
diff --git a/lib/shared/getArchiveValidity.ts b/lib/shared/getArchiveValidity.ts
index 0da5504..8ec8c44 100644
--- a/lib/shared/getArchiveValidity.ts
+++ b/lib/shared/getArchiveValidity.ts
@@ -28,6 +28,17 @@ export function readabilityAvailable(
);
}
+export function monolithAvailable(
+ link: LinkIncludingShortenedCollectionAndTags
+) {
+ return (
+ link &&
+ link.monolith &&
+ link.monolith !== "pending" &&
+ link.monolith !== "unavailable"
+ );
+}
+
export function previewAvailable(link: any) {
return (
link &&
diff --git a/next-i18next.config.js b/next-i18next.config.js
new file mode 100644
index 0000000..06c3d23
--- /dev/null
+++ b/next-i18next.config.js
@@ -0,0 +1,8 @@
+/** @type {import('next-i18next').UserConfig} */
+module.exports = {
+ i18n: {
+ defaultLocale: "en",
+ locales: ["en","it"],
+ },
+ reloadOnPrerender: process.env.NODE_ENV === "development",
+};
diff --git a/next.config.js b/next.config.js
index 71cfd8e..79d2d0f 100644
--- a/next.config.js
+++ b/next.config.js
@@ -1,10 +1,26 @@
/** @type {import('next').NextConfig} */
+const { version } = require("./package.json");
+const { i18n } = require("./next-i18next.config");
+
const nextConfig = {
+ i18n,
reactStrictMode: true,
images: {
+ // For fetching the favicons
domains: ["t2.gstatic.com"],
+
+ // For profile pictures (Google OAuth)
+ remotePatterns: [
+ {
+ hostname: "*.googleusercontent.com",
+ },
+ ],
+
minimumCacheTTL: 10,
},
+ env: {
+ version,
+ },
};
module.exports = nextConfig;
diff --git a/package.json b/package.json
index c8f48ec..db8cf5d 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "linkwarden",
- "version": "0.0.0",
+ "version": "v2.6.0",
"main": "index.js",
"repository": "https://github.com/linkwarden/linkwarden.git",
"author": "Daniel31X13 ",
@@ -16,6 +16,7 @@
"start": "concurrently -P \"next start {@}\" \"yarn worker:prod\" --",
"build": "next build",
"lint": "next lint",
+ "e2e": "playwright test e2e",
"format": "prettier --write \"**/*.{ts,tsx,js,json,md}\""
},
"dependencies": {
@@ -35,6 +36,8 @@
"axios": "^1.5.1",
"bcrypt": "^5.1.0",
"bootstrap-icons": "^1.11.2",
+ "class-variance-authority": "^0.7.0",
+ "clsx": "^2.1.1",
"colorthief": "^2.4.0",
"concurrently": "^8.2.2",
"crypto-js": "^4.2.0",
@@ -45,30 +48,36 @@
"eslint-config-next": "13.4.9",
"formidable": "^3.5.1",
"framer-motion": "^10.16.4",
+ "handlebars": "^4.7.8",
"himalaya": "^1.1.0",
+ "i18next": "^23.11.5",
"jimp": "^0.22.10",
"jsdom": "^22.1.0",
"lottie-web": "^5.12.2",
"micro": "^10.0.1",
"next": "13.4.12",
"next-auth": "^4.22.1",
+ "next-i18next": "^15.3.0",
"node-fetch": "^2.7.0",
"nodemailer": "^6.9.3",
- "playwright": "^1.35.1",
+ "playwright": "^1.45.0",
"react": "18.2.0",
"react-colorful": "^5.6.1",
"react-dom": "18.2.0",
"react-hot-toast": "^2.4.1",
+ "react-i18next": "^14.1.2",
"react-image-file-resizer": "^0.4.8",
+ "react-masonry-css": "^1.0.16",
"react-select": "^5.7.4",
"react-spinners": "^0.13.8",
"socks-proxy-agent": "^8.0.2",
"stripe": "^12.13.0",
+ "tailwind-merge": "^2.3.0",
"vaul": "^0.8.8",
"zustand": "^4.3.8"
},
"devDependencies": {
- "@playwright/test": "^1.35.1",
+ "@playwright/test": "^1.45.0",
"@types/bcrypt": "^5.0.0",
"@types/dompurify": "^3.0.4",
"@types/jsdom": "^21.1.3",
@@ -79,7 +88,7 @@
"nodemon": "^3.0.2",
"postcss": "^8.4.26",
"prettier": "3.1.1",
- "prisma": "^5.1.0",
+ "prisma": "^4.16.2",
"tailwindcss": "^3.3.3",
"ts-node": "^10.9.2",
"typescript": "4.9.4"
diff --git a/pages/_app.tsx b/pages/_app.tsx
index b965941..0cb79ff 100644
--- a/pages/_app.tsx
+++ b/pages/_app.tsx
@@ -5,11 +5,15 @@ import { SessionProvider } from "next-auth/react";
import type { AppProps } from "next/app";
import Head from "next/head";
import AuthRedirect from "@/layouts/AuthRedirect";
-import { Toaster } from "react-hot-toast";
+import toast from "react-hot-toast";
+import { Toaster, ToastBar } from "react-hot-toast";
import { Session } from "next-auth";
import { isPWA } from "@/lib/client/utils";
+// import useInitialData from "@/hooks/useInitialData";
+import { appWithTranslation } from "next-i18next";
+import nextI18nextConfig from "../next-i18next.config";
-export default function App({
+function App({
Component,
pageProps,
}: AppProps<{
@@ -54,6 +58,7 @@ export default function App({
+ {/* */}
+ >
+ {(t) => (
+
+ {({ icon, message }) => (
+
+ {icon}
+ {message}
+ {t.type !== "loading" && (
+ toast.dismiss(t.id)}
+ >
+
+
+ )}
+
+ )}
+
+ )}
+
+ {/* */}
);
}
+
+export default appWithTranslation(App);
+
+// function GetData({ children }: { children: React.ReactNode }) {
+// const status = useInitialData();
+// return typeof window !== "undefined" && status !== "loading" ? (
+// children
+// ) : (
+// <>>
+// );
+// }
diff --git a/pages/admin.tsx b/pages/admin.tsx
new file mode 100644
index 0000000..7211e08
--- /dev/null
+++ b/pages/admin.tsx
@@ -0,0 +1,114 @@
+import NewUserModal from "@/components/ModalContent/NewUserModal";
+import useUserStore from "@/store/admin/users";
+import { User as U } from "@prisma/client";
+import Link from "next/link";
+import { useEffect, useState } from "react";
+import { useTranslation } from "next-i18next";
+import getServerSideProps from "@/lib/client/getServerSideProps";
+import UserListing from "@/components/UserListing";
+
+interface User extends U {
+ subscriptions: {
+ active: boolean;
+ };
+}
+
+type UserModal = {
+ isOpen: boolean;
+ userId: number | null;
+};
+
+export default function Admin() {
+ const { t } = useTranslation();
+
+ const { users, setUsers } = useUserStore();
+
+ const [searchQuery, setSearchQuery] = useState("");
+ const [filteredUsers, setFilteredUsers] = useState();
+
+ const [deleteUserModal, setDeleteUserModal] = useState({
+ isOpen: false,
+ userId: null,
+ });
+
+ const [newUserModal, setNewUserModal] = useState(false);
+
+ useEffect(() => {
+ setUsers();
+ }, []);
+
+ return (
+
+
+
+
+
+
+
+ {t("user_administration")}
+
+
+
+
+
+
+
+
+
+ {
+ setSearchQuery(e.target.value);
+
+ if (users) {
+ setFilteredUsers(
+ users.filter((user) =>
+ JSON.stringify(user)
+ .toLowerCase()
+ .includes(e.target.value.toLowerCase())
+ )
+ );
+ }
+ }}
+ className="border border-neutral-content bg-base-200 focus:border-primary py-1 rounded-md pl-9 pr-2 w-full max-w-[15rem] md:w-[15rem] md:max-w-full duration-200 outline-none"
+ />
+
+
+
setNewUserModal(true)}
+ className="flex items-center btn btn-accent dark:border-violet-400 text-white btn-sm px-2 aspect-square relative"
+ >
+
+
+
+
+
+
+
+ {filteredUsers && filteredUsers.length > 0 && searchQuery !== "" ? (
+ UserListing(filteredUsers, deleteUserModal, setDeleteUserModal, t)
+ ) : searchQuery !== "" ? (
+
{t("no_user_found_in_search")}
+ ) : users && users.length > 0 ? (
+ UserListing(users, deleteUserModal, setDeleteUserModal, t)
+ ) : (
+
{t("no_users_found")}
+ )}
+
+ {newUserModal ? (
+
setNewUserModal(false)} />
+ ) : null}
+
+ );
+}
+
+export { getServerSideProps };
diff --git a/pages/api/v1/archives/[linkId].ts b/pages/api/v1/archives/[linkId].ts
index b13e690..aed8288 100644
--- a/pages/api/v1/archives/[linkId].ts
+++ b/pages/api/v1/archives/[linkId].ts
@@ -9,6 +9,8 @@ import formidable from "formidable";
import createFile from "@/lib/api/storage/createFile";
import fs from "fs";
import verifyToken from "@/lib/api/verifyToken";
+import generatePreview from "@/lib/api/generatePreview";
+import createFolder from "@/lib/api/storage/createFolder";
export const config = {
api: {
@@ -27,6 +29,7 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) {
else if (format === ArchivedFormat.jpeg) suffix = ".jpeg";
else if (format === ArchivedFormat.pdf) suffix = ".pdf";
else if (format === ArchivedFormat.readability) suffix = "_readability.json";
+ else if (format === ArchivedFormat.monolith) suffix = ".html";
//@ts-ignore
if (!linkId || !suffix)
@@ -73,83 +76,134 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) {
return res.send(file);
}
+ } else if (req.method === "POST") {
+ if (process.env.NEXT_PUBLIC_DEMO === "true")
+ return res.status(400).json({
+ response:
+ "This action is disabled because this is a read-only demo of Linkwarden.",
+ });
+
+ const user = await verifyUser({ req, res });
+ if (!user) return;
+
+ const collectionPermissions = await getPermission({
+ userId: user.id,
+ linkId,
+ });
+
+ if (!collectionPermissions)
+ return res.status(400).json({
+ response: "Collection is not accessible.",
+ });
+
+ const memberHasAccess = collectionPermissions.members.some(
+ (e: UsersAndCollections) => e.userId === user.id && e.canCreate
+ );
+
+ if (!(collectionPermissions.ownerId === user.id || memberHasAccess))
+ return res.status(400).json({
+ response: "Collection is not accessible.",
+ });
+
+ // await uploadHandler(linkId, )
+
+ const MAX_LINKS_PER_USER = Number(process.env.MAX_LINKS_PER_USER || 30000);
+
+ const numberOfLinksTheUserHas = await prisma.link.count({
+ where: {
+ collection: {
+ ownerId: user.id,
+ },
+ },
+ });
+
+ if (numberOfLinksTheUserHas > MAX_LINKS_PER_USER)
+ return res.status(400).json({
+ response:
+ "Each collection owner can only have a maximum of ${MAX_LINKS_PER_USER} Links.",
+ });
+
+ const NEXT_PUBLIC_MAX_FILE_BUFFER = Number(
+ process.env.NEXT_PUBLIC_MAX_FILE_BUFFER || 10
+ );
+
+ const form = formidable({
+ maxFields: 1,
+ maxFiles: 1,
+ maxFileSize: NEXT_PUBLIC_MAX_FILE_BUFFER * 1024 * 1024,
+ });
+
+ form.parse(req, async (err, fields, files) => {
+ const allowedMIMETypes = [
+ "application/pdf",
+ "image/png",
+ "image/jpg",
+ "image/jpeg",
+ ];
+
+ if (
+ err ||
+ !files.file ||
+ !files.file[0] ||
+ !allowedMIMETypes.includes(files.file[0].mimetype || "")
+ ) {
+ // Handle parsing error
+ return res.status(400).json({
+ response: `Sorry, we couldn't process your file. Please ensure it's a PDF, PNG, or JPG format and doesn't exceed ${NEXT_PUBLIC_MAX_FILE_BUFFER}MB.`,
+ });
+ } else {
+ const fileBuffer = fs.readFileSync(files.file[0].filepath);
+
+ if (
+ Buffer.byteLength(fileBuffer) >
+ 1024 * 1024 * Number(NEXT_PUBLIC_MAX_FILE_BUFFER)
+ )
+ return res.status(400).json({
+ response: `Sorry, we couldn't process your file. Please ensure it's a PDF, PNG, or JPG format and doesn't exceed ${NEXT_PUBLIC_MAX_FILE_BUFFER}MB.`,
+ });
+
+ const linkStillExists = await prisma.link.findUnique({
+ where: { id: linkId },
+ });
+
+ if (linkStillExists && files.file[0].mimetype?.includes("image")) {
+ const collectionId = collectionPermissions.id as number;
+ createFolder({
+ filePath: `archives/preview/${collectionId}`,
+ });
+
+ generatePreview(fileBuffer, collectionId, linkId);
+ }
+
+ if (linkStillExists) {
+ await createFile({
+ filePath: `archives/${collectionPermissions.id}/${linkId + suffix}`,
+ data: fileBuffer,
+ });
+
+ await prisma.link.update({
+ where: { id: linkId },
+ data: {
+ preview: files.file[0].mimetype?.includes("pdf")
+ ? "unavailable"
+ : undefined,
+ image: files.file[0].mimetype?.includes("image")
+ ? `archives/${collectionPermissions.id}/${linkId + suffix}`
+ : null,
+ pdf: files.file[0].mimetype?.includes("pdf")
+ ? `archives/${collectionPermissions.id}/${linkId + suffix}`
+ : null,
+ lastPreserved: new Date().toISOString(),
+ },
+ });
+ }
+
+ fs.unlinkSync(files.file[0].filepath);
+ }
+
+ return res.status(200).json({
+ response: files,
+ });
+ });
}
- // else if (req.method === "POST") {
- // const user = await verifyUser({ req, res });
- // if (!user) return;
-
- // const collectionPermissions = await getPermission({
- // userId: user.id,
- // linkId,
- // });
-
- // const memberHasAccess = collectionPermissions?.members.some(
- // (e: UsersAndCollections) => e.userId === user.id && e.canCreate
- // );
-
- // if (!(collectionPermissions?.ownerId === user.id || memberHasAccess))
- // return { response: "Collection is not accessible.", status: 401 };
-
- // // await uploadHandler(linkId, )
-
- // const MAX_UPLOAD_SIZE = Number(process.env.NEXT_PUBLIC_MAX_FILE_SIZE);
-
- // const form = formidable({
- // maxFields: 1,
- // maxFiles: 1,
- // maxFileSize: MAX_UPLOAD_SIZE || 30 * 1048576,
- // });
-
- // form.parse(req, async (err, fields, files) => {
- // const allowedMIMETypes = [
- // "application/pdf",
- // "image/png",
- // "image/jpg",
- // "image/jpeg",
- // ];
-
- // if (
- // err ||
- // !files.file ||
- // !files.file[0] ||
- // !allowedMIMETypes.includes(files.file[0].mimetype || "")
- // ) {
- // // Handle parsing error
- // return res.status(500).json({
- // response: `Sorry, we couldn't process your file. Please ensure it's a PDF, PNG, or JPG format and doesn't exceed ${MAX_UPLOAD_SIZE}MB.`,
- // });
- // } else {
- // const fileBuffer = fs.readFileSync(files.file[0].filepath);
-
- // const linkStillExists = await prisma.link.findUnique({
- // where: { id: linkId },
- // });
-
- // if (linkStillExists) {
- // await createFile({
- // filePath: `archives/${collectionPermissions?.id}/${
- // linkId + suffix
- // }`,
- // data: fileBuffer,
- // });
-
- // await prisma.link.update({
- // where: { id: linkId },
- // data: {
- // image: `archives/${collectionPermissions?.id}/${
- // linkId + suffix
- // }`,
- // lastPreserved: new Date().toISOString(),
- // },
- // });
- // }
-
- // fs.unlinkSync(files.file[0].filepath);
- // }
-
- // return res.status(200).json({
- // response: files,
- // });
- // });
- // }
}
diff --git a/pages/api/v1/auth/[...nextauth].ts b/pages/api/v1/auth/[...nextauth].ts
index c72d60d..dd38aa7 100644
--- a/pages/api/v1/auth/[...nextauth].ts
+++ b/pages/api/v1/auth/[...nextauth].ts
@@ -1,28 +1,31 @@
import { prisma } from "@/lib/api/db";
-import NextAuth from "next-auth/next";
-import CredentialsProvider from "next-auth/providers/credentials";
-import { AuthOptions } from "next-auth";
-import bcrypt from "bcrypt";
-import EmailProvider from "next-auth/providers/email";
-import { PrismaAdapter } from "@auth/prisma-adapter";
-import { Adapter } from "next-auth/adapters";
import sendVerificationRequest from "@/lib/api/sendVerificationRequest";
-import { Provider } from "next-auth/providers";
import verifySubscription from "@/lib/api/verifySubscription";
+import { PrismaAdapter } from "@auth/prisma-adapter";
+import bcrypt from "bcrypt";
+import { randomBytes } from "crypto";
+import type { NextApiRequest, NextApiResponse } from "next";
+import { Adapter } from "next-auth/adapters";
+import NextAuth from "next-auth/next";
+import { Provider } from "next-auth/providers";
import FortyTwoProvider from "next-auth/providers/42-school";
import AppleProvider from "next-auth/providers/apple";
import AtlassianProvider from "next-auth/providers/atlassian";
import Auth0Provider from "next-auth/providers/auth0";
import AuthentikProvider from "next-auth/providers/authentik";
+import AzureAdProvider from "next-auth/providers/azure-ad";
+import AzureAdB2CProvider from "next-auth/providers/azure-ad-b2c";
import BattleNetProvider, {
BattleNetIssuer,
} from "next-auth/providers/battlenet";
import BoxProvider from "next-auth/providers/box";
import CognitoProvider from "next-auth/providers/cognito";
import CoinbaseProvider from "next-auth/providers/coinbase";
+import CredentialsProvider from "next-auth/providers/credentials";
import DiscordProvider from "next-auth/providers/discord";
import DropboxProvider from "next-auth/providers/dropbox";
import DuendeIDS6Provider from "next-auth/providers/duende-identity-server6";
+import EmailProvider from "next-auth/providers/email";
import EVEOnlineProvider from "next-auth/providers/eveonline";
import FacebookProvider from "next-auth/providers/facebook";
import FaceItProvider from "next-auth/providers/faceit";
@@ -65,7 +68,6 @@ import ZitadelProvider from "next-auth/providers/zitadel";
import ZohoProvider from "next-auth/providers/zoho";
import ZoomProvider from "next-auth/providers/zoom";
import * as process from "process";
-import type { NextApiRequest, NextApiResponse } from "next";
const emailEnabled =
process.env.EMAIL_FROM && process.env.EMAIL_SERVER ? true : false;
@@ -106,22 +108,26 @@ if (
email: username?.toLowerCase(),
},
],
- emailVerified: { not: null },
}
: {
username: username.toLowerCase(),
},
});
+ if (!user) throw Error("Invalid credentials.");
+ else if (!user?.emailVerified && emailEnabled) {
+ throw Error("Email not verified.");
+ }
+
let passwordMatches: boolean = false;
if (user?.password) {
passwordMatches = bcrypt.compareSync(password, user.password);
}
- if (passwordMatches) {
+ if (passwordMatches && user?.password) {
return { id: user?.id };
- } else return null as any;
+ } else throw Error("Invalid credentials.");
},
})
);
@@ -133,8 +139,26 @@ if (emailEnabled) {
server: process.env.EMAIL_SERVER,
from: process.env.EMAIL_FROM,
maxAge: 1200,
- sendVerificationRequest(params) {
- sendVerificationRequest(params);
+ async sendVerificationRequest({ identifier, url, provider, token }) {
+ const recentVerificationRequestsCount =
+ await prisma.verificationToken.count({
+ where: {
+ identifier,
+ createdAt: {
+ gt: new Date(new Date().getTime() - 1000 * 60 * 5), // 5 minutes
+ },
+ },
+ });
+
+ if (recentVerificationRequestsCount >= 4)
+ throw Error("Too many requests. Please try again later.");
+
+ sendVerificationRequest({
+ identifier,
+ url,
+ from: provider.from as string,
+ token,
+ });
},
})
);
@@ -240,6 +264,35 @@ if (process.env.NEXT_PUBLIC_AUTH0_ENABLED === "true") {
};
}
+// Authelia
+if (process.env.NEXT_PUBLIC_AUTHELIA_ENABLED === "true") {
+ providers.push({
+ id: "authelia",
+ name: "Authelia",
+ type: "oauth",
+ clientId: process.env.AUTHELIA_CLIENT_ID!,
+ clientSecret: process.env.AUTHELIA_CLIENT_SECRET!,
+ wellKnown: process.env.AUTHELIA_WELLKNOWN_URL!,
+ authorization: { params: { scope: "openid email profile" } },
+ idToken: true,
+ checks: ["pkce", "state"],
+ profile(profile) {
+ return {
+ id: profile.sub,
+ name: profile.name,
+ email: profile.email,
+ username: profile.preferred_username,
+ };
+ },
+ });
+
+ const _linkAccount = adapter.linkAccount;
+ adapter.linkAccount = (account) => {
+ const { "not-before-policy": _, refresh_expires_in, ...data } = account;
+ return _linkAccount ? _linkAccount(data) : undefined;
+ };
+}
+
// Authentik
if (process.env.NEXT_PUBLIC_AUTHENTIK_ENABLED === "true") {
providers.push(
@@ -266,13 +319,65 @@ if (process.env.NEXT_PUBLIC_AUTHENTIK_ENABLED === "true") {
};
}
+// Azure AD B2C
+if (process.env.NEXT_PUBLIC_AZURE_AD_ENABLED === "true") {
+ providers.push(
+ AzureAdB2CProvider({
+ tenantId: process.env.AZURE_AD_B2C_TENANT_NAME,
+ clientId: process.env.AZURE_AD_B2C_CLIENT_ID!,
+ clientSecret: process.env.AZURE_AD_B2C_CLIENT_SECRET!,
+ primaryUserFlow: process.env.AZURE_AD_B2C_PRIMARY_USER_FLOW,
+ authorization: { params: { scope: "offline_access openid" } },
+ })
+ );
+
+ const _linkAccount = adapter.linkAccount;
+ adapter.linkAccount = (account) => {
+ const {
+ "not-before-policy": _,
+ refresh_expires_in,
+ refresh_token_expires_in,
+ not_before,
+ id_token_expires_in,
+ profile_info,
+ ...data
+ } = account;
+ return _linkAccount ? _linkAccount(data) : undefined;
+ };
+}
+
+// Azure AD
+if (process.env.NEXT_PUBLIC_AZURE_AD_ENABLED === "true") {
+ providers.push(
+ AzureAdProvider({
+ clientId: process.env.AZURE_AD_CLIENT_ID!,
+ clientSecret: process.env.AZURE_AD_CLIENT_SECRET!,
+ tenantId: process.env.AZURE_AD_TENANT_ID,
+ })
+ );
+
+ const _linkAccount = adapter.linkAccount;
+ adapter.linkAccount = (account) => {
+ const {
+ "not-before-policy": _,
+ refresh_expires_in,
+ token_type,
+ expires_in,
+ ext_expires_in,
+ access_token,
+ ...data
+ } = account;
+ return _linkAccount ? _linkAccount(data) : undefined;
+ };
+}
+
// Battle.net
if (process.env.NEXT_PUBLIC_BATTLENET_ENABLED === "true") {
providers.push(
BattleNetProvider({
clientId: process.env.BATTLENET_CLIENT_ID!,
clientSecret: process.env.BATTLENET_CLIENT_SECRET!,
- issuer: process.env.BATLLENET_ISSUER as BattleNetIssuer,
+ issuer: process.env.BATTLENET_ISSUER as BattleNetIssuer,
})
);
@@ -520,6 +625,9 @@ if (process.env.NEXT_PUBLIC_GOOGLE_ENABLED === "true") {
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
+ httpOptions: {
+ timeout: 10000,
+ },
})
);
@@ -1092,6 +1200,28 @@ export default async function auth(req: NextApiRequest, res: NextApiResponse) {
if (trigger === "signIn" || trigger === "signUp")
token.id = user?.id as number;
+ if (trigger === "signUp") {
+ const checkIfUserExists = await prisma.user.findUnique({
+ where: {
+ id: token.id,
+ },
+ });
+
+ if (checkIfUserExists && !checkIfUserExists.username) {
+ const autoGeneratedUsername =
+ "user" + Math.round(Math.random() * 1000000000);
+
+ await prisma.user.update({
+ where: {
+ id: token.id,
+ },
+ data: {
+ username: autoGeneratedUsername,
+ },
+ });
+ }
+ }
+
return token;
},
async session({ session, token }) {
diff --git a/pages/api/v1/auth/forgot-password.ts b/pages/api/v1/auth/forgot-password.ts
new file mode 100644
index 0000000..66fb807
--- /dev/null
+++ b/pages/api/v1/auth/forgot-password.ts
@@ -0,0 +1,58 @@
+import { prisma } from "@/lib/api/db";
+import sendPasswordResetRequest from "@/lib/api/sendPasswordResetRequest";
+import type { NextApiRequest, NextApiResponse } from "next";
+
+export default async function forgotPassword(
+ req: NextApiRequest,
+ res: NextApiResponse
+) {
+ if (req.method === "POST") {
+ if (process.env.NEXT_PUBLIC_DEMO === "true")
+ return res.status(400).json({
+ response:
+ "This action is disabled because this is a read-only demo of Linkwarden.",
+ });
+
+ const email = req.body.email;
+
+ if (!email) {
+ return res.status(400).json({
+ response: "Invalid email.",
+ });
+ }
+
+ const recentPasswordRequestsCount = await prisma.passwordResetToken.count({
+ where: {
+ identifier: email,
+ createdAt: {
+ gt: new Date(new Date().getTime() - 1000 * 60 * 5), // 5 minutes
+ },
+ },
+ });
+
+ // Rate limit password reset requests
+ if (recentPasswordRequestsCount >= 3) {
+ return res.status(400).json({
+ response: "Too many requests. Please try again later.",
+ });
+ }
+
+ const user = await prisma.user.findFirst({
+ where: {
+ email,
+ },
+ });
+
+ if (!user || !user.email) {
+ return res.status(400).json({
+ response: "Invalid email.",
+ });
+ }
+
+ sendPasswordResetRequest(user.email, user.name);
+
+ return res.status(200).json({
+ response: "Password reset email sent.",
+ });
+ }
+}
diff --git a/pages/api/v1/auth/reset-password.ts b/pages/api/v1/auth/reset-password.ts
new file mode 100644
index 0000000..5bbadfd
--- /dev/null
+++ b/pages/api/v1/auth/reset-password.ts
@@ -0,0 +1,86 @@
+import { prisma } from "@/lib/api/db";
+import type { NextApiRequest, NextApiResponse } from "next";
+import bcrypt from "bcrypt";
+
+export default async function resetPassword(
+ req: NextApiRequest,
+ res: NextApiResponse
+) {
+ if (req.method === "POST") {
+ if (process.env.NEXT_PUBLIC_DEMO === "true")
+ return res.status(400).json({
+ response:
+ "This action is disabled because this is a read-only demo of Linkwarden.",
+ });
+
+ const token = req.body.token;
+ const password = req.body.password;
+
+ if (!password || password.length < 8) {
+ return res.status(400).json({
+ response: "Password must be at least 8 characters.",
+ });
+ }
+
+ if (!token || typeof token !== "string") {
+ return res.status(400).json({
+ response: "Invalid token.",
+ });
+ }
+
+ // Hashed password
+ const saltRounds = 10;
+ const hashedPassword = await bcrypt.hash(password, saltRounds);
+
+ // Check token in db
+ const verifyToken = await prisma.passwordResetToken.findFirst({
+ where: {
+ token,
+ expires: {
+ gt: new Date(),
+ },
+ },
+ });
+
+ if (!verifyToken) {
+ return res.status(400).json({
+ response: "Invalid token.",
+ });
+ }
+
+ const email = verifyToken.identifier;
+
+ // Update password
+ await prisma.user.update({
+ where: {
+ email,
+ },
+ data: {
+ password: hashedPassword,
+ },
+ });
+
+ await prisma.passwordResetToken.update({
+ where: {
+ token,
+ },
+ data: {
+ expires: new Date(),
+ },
+ });
+
+ // Delete tokens older than 5 minutes
+ await prisma.passwordResetToken.deleteMany({
+ where: {
+ identifier: email,
+ createdAt: {
+ lt: new Date(new Date().getTime() - 1000 * 60 * 5), // 5 minutes
+ },
+ },
+ });
+
+ return res.status(200).json({
+ response: "Password has been reset successfully.",
+ });
+ }
+}
diff --git a/pages/api/v1/auth/verify-email.ts b/pages/api/v1/auth/verify-email.ts
new file mode 100644
index 0000000..41a9d0c
--- /dev/null
+++ b/pages/api/v1/auth/verify-email.ts
@@ -0,0 +1,120 @@
+import { prisma } from "@/lib/api/db";
+import updateCustomerEmail from "@/lib/api/updateCustomerEmail";
+import type { NextApiRequest, NextApiResponse } from "next";
+
+export default async function verifyEmail(
+ req: NextApiRequest,
+ res: NextApiResponse
+) {
+ if (req.method === "POST") {
+ if (process.env.NEXT_PUBLIC_DEMO === "true")
+ return res.status(400).json({
+ response:
+ "This action is disabled because this is a read-only demo of Linkwarden.",
+ });
+
+ const token = req.query.token;
+
+ if (!token || typeof token !== "string") {
+ return res.status(400).json({
+ response: "Invalid token.",
+ });
+ }
+
+ // Check token in db
+ const verifyToken = await prisma.verificationToken.findFirst({
+ where: {
+ token,
+ expires: {
+ gt: new Date(),
+ },
+ },
+ });
+
+ const oldEmail = verifyToken?.identifier;
+
+ if (!oldEmail) {
+ return res.status(400).json({
+ response: "Invalid token.",
+ });
+ }
+
+ // Ensure email isn't in use
+ const findNewEmail = await prisma.user.findFirst({
+ where: {
+ email: oldEmail,
+ },
+ select: {
+ unverifiedNewEmail: true,
+ },
+ });
+
+ const newEmail = findNewEmail?.unverifiedNewEmail;
+
+ if (!newEmail) {
+ return res.status(400).json({
+ response: "No unverified emails found.",
+ });
+ }
+
+ const emailInUse = await prisma.user.findFirst({
+ where: {
+ email: newEmail,
+ },
+ select: {
+ email: true,
+ },
+ });
+
+ console.log(emailInUse);
+
+ if (emailInUse) {
+ return res.status(400).json({
+ response: "Email is already in use.",
+ });
+ }
+
+ // Remove SSO provider
+ await prisma.account.deleteMany({
+ where: {
+ user: {
+ email: oldEmail,
+ },
+ },
+ });
+
+ // Update email in db
+ await prisma.user.update({
+ where: {
+ email: oldEmail,
+ },
+ data: {
+ email: newEmail.toLowerCase().trim(),
+ unverifiedNewEmail: null,
+ },
+ });
+
+ // Apply to Stripe
+ const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY;
+
+ if (STRIPE_SECRET_KEY)
+ await updateCustomerEmail(STRIPE_SECRET_KEY, oldEmail, newEmail);
+
+ // Clean up existing tokens
+ await prisma.verificationToken.delete({
+ where: {
+ token,
+ },
+ });
+
+ await prisma.verificationToken.deleteMany({
+ where: {
+ identifier: oldEmail,
+ },
+ });
+
+ return res.status(200).json({
+ response: token,
+ });
+ }
+}
diff --git a/pages/api/v1/collections/[id].ts b/pages/api/v1/collections/[id].ts
index 637e10f..9409031 100644
--- a/pages/api/v1/collections/[id].ts
+++ b/pages/api/v1/collections/[id].ts
@@ -19,9 +19,21 @@ export default async function collections(
.status(collections.status)
.json({ response: collections.response });
} else if (req.method === "PUT") {
+ if (process.env.NEXT_PUBLIC_DEMO === "true")
+ return res.status(400).json({
+ response:
+ "This action is disabled because this is a read-only demo of Linkwarden.",
+ });
+
const updated = await updateCollectionById(user.id, collectionId, req.body);
return res.status(updated.status).json({ response: updated.response });
} else if (req.method === "DELETE") {
+ if (process.env.NEXT_PUBLIC_DEMO === "true")
+ return res.status(400).json({
+ response:
+ "This action is disabled because this is a read-only demo of Linkwarden.",
+ });
+
const deleted = await deleteCollectionById(user.id, collectionId);
return res.status(deleted.status).json({ response: deleted.response });
}
diff --git a/pages/api/v1/collections/index.ts b/pages/api/v1/collections/index.ts
index 3b229dd..3198ea5 100644
--- a/pages/api/v1/collections/index.ts
+++ b/pages/api/v1/collections/index.ts
@@ -16,6 +16,12 @@ export default async function collections(
.status(collections.status)
.json({ response: collections.response });
} else if (req.method === "POST") {
+ if (process.env.NEXT_PUBLIC_DEMO === "true")
+ return res.status(400).json({
+ response:
+ "This action is disabled because this is a read-only demo of Linkwarden.",
+ });
+
const newCollection = await postCollection(req.body, user.id);
return res
.status(newCollection.status)
diff --git a/pages/api/v1/dashboard/index.ts b/pages/api/v1/dashboard/index.ts
index ea2a3da..e4abcba 100644
--- a/pages/api/v1/dashboard/index.ts
+++ b/pages/api/v1/dashboard/index.ts
@@ -3,7 +3,10 @@ import { LinkRequestQuery } from "@/types/global";
import getDashboardData from "@/lib/api/controllers/dashboard/getDashboardData";
import verifyUser from "@/lib/api/verifyUser";
-export default async function links(req: NextApiRequest, res: NextApiResponse) {
+export default async function dashboard(
+ req: NextApiRequest,
+ res: NextApiResponse
+) {
const user = await verifyUser({ req, res });
if (!user) return;
diff --git a/pages/api/v1/links/[id]/archive/index.ts b/pages/api/v1/links/[id]/archive/index.ts
index 4693fac..6cacf73 100644
--- a/pages/api/v1/links/[id]/archive/index.ts
+++ b/pages/api/v1/links/[id]/archive/index.ts
@@ -2,8 +2,8 @@ import type { NextApiRequest, NextApiResponse } from "next";
import { prisma } from "@/lib/api/db";
import verifyUser from "@/lib/api/verifyUser";
import isValidUrl from "@/lib/shared/isValidUrl";
-import removeFile from "@/lib/api/storage/removeFile";
import { Collection, Link } from "@prisma/client";
+import { removeFiles } from "@/lib/api/manageLinkFiles";
const RE_ARCHIVE_LIMIT = Number(process.env.RE_ARCHIVE_LIMIT) || 5;
@@ -29,6 +29,12 @@ export default async function links(req: NextApiRequest, res: NextApiResponse) {
});
if (req.method === "PUT") {
+ if (process.env.NEXT_PUBLIC_DEMO === "true")
+ return res.status(400).json({
+ response:
+ "This action is disabled because this is a read-only demo of Linkwarden.",
+ });
+
if (
link?.lastPreserved &&
getTimezoneDifferenceInMinutes(new Date(), link?.lastPreserved) <
@@ -76,20 +82,10 @@ const deleteArchivedFiles = async (link: Link & { collection: Collection }) => {
image: null,
pdf: null,
readable: null,
+ monolith: null,
preview: null,
},
});
- await removeFile({
- filePath: `archives/${link.collection.id}/${link.id}.pdf`,
- });
- await removeFile({
- filePath: `archives/${link.collection.id}/${link.id}.png`,
- });
- await removeFile({
- filePath: `archives/${link.collection.id}/${link.id}_readability.json`,
- });
- await removeFile({
- filePath: `archives/preview/${link.collection.id}/${link.id}.png`,
- });
+ await removeFiles(link.id, link.collection.id);
};
diff --git a/pages/api/v1/links/[id]/index.ts b/pages/api/v1/links/[id]/index.ts
index 146ecb2..7f33c66 100644
--- a/pages/api/v1/links/[id]/index.ts
+++ b/pages/api/v1/links/[id]/index.ts
@@ -14,6 +14,12 @@ export default async function links(req: NextApiRequest, res: NextApiResponse) {
response: updated.response,
});
} else if (req.method === "PUT") {
+ if (process.env.NEXT_PUBLIC_DEMO === "true")
+ return res.status(400).json({
+ response:
+ "This action is disabled because this is a read-only demo of Linkwarden.",
+ });
+
const updated = await updateLinkById(
user.id,
Number(req.query.id),
@@ -23,6 +29,12 @@ export default async function links(req: NextApiRequest, res: NextApiResponse) {
response: updated.response,
});
} else if (req.method === "DELETE") {
+ if (process.env.NEXT_PUBLIC_DEMO === "true")
+ return res.status(400).json({
+ response:
+ "This action is disabled because this is a read-only demo of Linkwarden.",
+ });
+
const deleted = await deleteLinkById(user.id, Number(req.query.id));
return res.status(deleted.status).json({
response: deleted.response,
diff --git a/pages/api/v1/links/index.ts b/pages/api/v1/links/index.ts
index 35b0243..2000e79 100644
--- a/pages/api/v1/links/index.ts
+++ b/pages/api/v1/links/index.ts
@@ -37,11 +37,23 @@ export default async function links(req: NextApiRequest, res: NextApiResponse) {
const links = await getLinks(user.id, convertedData);
return res.status(links.status).json({ response: links.response });
} else if (req.method === "POST") {
+ if (process.env.NEXT_PUBLIC_DEMO === "true")
+ return res.status(400).json({
+ response:
+ "This action is disabled because this is a read-only demo of Linkwarden.",
+ });
+
const newlink = await postLink(req.body, user.id);
return res.status(newlink.status).json({
response: newlink.response,
});
} else if (req.method === "PUT") {
+ if (process.env.NEXT_PUBLIC_DEMO === "true")
+ return res.status(400).json({
+ response:
+ "This action is disabled because this is a read-only demo of Linkwarden.",
+ });
+
const updated = await updateLinks(
user.id,
req.body.links,
@@ -52,6 +64,12 @@ export default async function links(req: NextApiRequest, res: NextApiResponse) {
response: updated.response,
});
} else if (req.method === "DELETE") {
+ if (process.env.NEXT_PUBLIC_DEMO === "true")
+ return res.status(400).json({
+ response:
+ "This action is disabled because this is a read-only demo of Linkwarden.",
+ });
+
const deleted = await deleteLinksById(user.id, req.body.linkIds);
return res.status(deleted.status).json({
response: deleted.response,
diff --git a/pages/api/v1/logins/index.ts b/pages/api/v1/logins/index.ts
index 34b3aaf..7e290af 100644
--- a/pages/api/v1/logins/index.ts
+++ b/pages/api/v1/logins/index.ts
@@ -55,6 +55,20 @@ export function getLogins() {
name: process.env.AUTHENTIK_CUSTOM_NAME ?? "Authentik",
});
}
+ // Azure AD B2C
+ if (process.env.NEXT_PUBLIC_AZURE_AD_B2C_ENABLED === "true") {
+ buttonAuths.push({
+ method: "azure-ad-b2c",
+ name: process.env.AZURE_AD_B2C_CUSTOM_NAME ?? "Azure AD B2C",
+ });
+ }
+ // Azure AD
+ if (process.env.NEXT_PUBLIC_AZURE_AD_ENABLED === "true") {
+ buttonAuths.push({
+ method: "azure-ad",
+ name: process.env.AZURE_AD_CUSTOM_NAME ?? "Azure AD",
+ });
+ }
// Battle.net
if (process.env.NEXT_PUBLIC_BATTLENET_ENABLED === "true") {
buttonAuths.push({
@@ -391,6 +405,13 @@ export function getLogins() {
name: process.env.ZOOM_CUSTOM_NAME ?? "Zoom",
});
}
+ // Authelia
+ if (process.env.NEXT_PUBLIC_AUTHELIA_ENABLED === "true") {
+ buttonAuths.push({
+ method: "authelia",
+ name: process.env.AUTHELIA_CUSTOM_NAME ?? "Authelia",
+ });
+ }
return {
credentialsEnabled:
process.env.NEXT_PUBLIC_CREDENTIALS_ENABLED === "true" ||
diff --git a/pages/api/v1/migration/index.ts b/pages/api/v1/migration/index.ts
index bfba453..4e47984 100644
--- a/pages/api/v1/migration/index.ts
+++ b/pages/api/v1/migration/index.ts
@@ -4,11 +4,14 @@ import importFromHTMLFile from "@/lib/api/controllers/migration/importFromHTMLFi
import importFromLinkwarden from "@/lib/api/controllers/migration/importFromLinkwarden";
import { MigrationFormat, MigrationRequest } from "@/types/global";
import verifyUser from "@/lib/api/verifyUser";
+import importFromWallabag from "@/lib/api/controllers/migration/importFromWallabag";
export const config = {
api: {
bodyParser: {
- sizeLimit: "10mb",
+ sizeLimit: process.env.IMPORT_LIMIT
+ ? process.env.IMPORT_LIMIT + "mb"
+ : "10mb",
},
},
};
@@ -27,14 +30,21 @@ export default async function users(req: NextApiRequest, res: NextApiResponse) {
.status(data.status)
.json(data.response);
} else if (req.method === "POST") {
+ if (process.env.NEXT_PUBLIC_DEMO === "true")
+ return res.status(400).json({
+ response:
+ "This action is disabled because this is a read-only demo of Linkwarden.",
+ });
+
const request: MigrationRequest = JSON.parse(req.body);
let data;
if (request.format === MigrationFormat.htmlFile)
data = await importFromHTMLFile(user.id, request.data);
-
- if (request.format === MigrationFormat.linkwarden)
+ else if (request.format === MigrationFormat.linkwarden)
data = await importFromLinkwarden(user.id, request.data);
+ else if (request.format === MigrationFormat.wallabag)
+ data = await importFromWallabag(user.id, request.data);
if (data) return res.status(data.status).json({ response: data.response });
}
diff --git a/pages/api/v1/session/index.ts b/pages/api/v1/session/index.ts
new file mode 100644
index 0000000..bd3e50b
--- /dev/null
+++ b/pages/api/v1/session/index.ts
@@ -0,0 +1,23 @@
+import type { NextApiRequest, NextApiResponse } from "next";
+import verifyByCredentials from "@/lib/api/verifyByCredentials";
+import createSession from "@/lib/api/controllers/session/createSession";
+
+export default async function session(
+ req: NextApiRequest,
+ res: NextApiResponse
+) {
+ const { username, password, sessionName } = req.body;
+
+ const user = await verifyByCredentials({ username, password });
+
+ if (!user)
+ return res.status(400).json({
+ response:
+ "Invalid credentials. You might need to reset your password if you're sure you already signed up with the current username/email.",
+ });
+
+ if (req.method === "POST") {
+ const token = await createSession(user.id, sessionName);
+ return res.status(token.status).json({ response: token.response });
+ }
+}
diff --git a/pages/api/v1/tags/[id].ts b/pages/api/v1/tags/[id].ts
index d82b1f7..606daf0 100644
--- a/pages/api/v1/tags/[id].ts
+++ b/pages/api/v1/tags/[id].ts
@@ -10,9 +10,21 @@ export default async function tags(req: NextApiRequest, res: NextApiResponse) {
const tagId = Number(req.query.id);
if (req.method === "PUT") {
+ if (process.env.NEXT_PUBLIC_DEMO === "true")
+ return res.status(400).json({
+ response:
+ "This action is disabled because this is a read-only demo of Linkwarden.",
+ });
+
const tags = await updeteTagById(user.id, tagId, req.body);
return res.status(tags.status).json({ response: tags.response });
} else if (req.method === "DELETE") {
+ if (process.env.NEXT_PUBLIC_DEMO === "true")
+ return res.status(400).json({
+ response:
+ "This action is disabled because this is a read-only demo of Linkwarden.",
+ });
+
const tags = await deleteTagById(user.id, tagId);
return res.status(tags.status).json({ response: tags.response });
}
diff --git a/pages/api/v1/tokens/[id].ts b/pages/api/v1/tokens/[id].ts
index 6c2e51b..c4501fb 100644
--- a/pages/api/v1/tokens/[id].ts
+++ b/pages/api/v1/tokens/[id].ts
@@ -7,6 +7,12 @@ export default async function token(req: NextApiRequest, res: NextApiResponse) {
if (!user) return;
if (req.method === "DELETE") {
+ if (process.env.NEXT_PUBLIC_DEMO === "true")
+ return res.status(400).json({
+ response:
+ "This action is disabled because this is a read-only demo of Linkwarden.",
+ });
+
const deleted = await deleteToken(user.id, Number(req.query.id) as number);
return res.status(deleted.status).json({ response: deleted.response });
}
diff --git a/pages/api/v1/tokens/index.ts b/pages/api/v1/tokens/index.ts
index 8af63fb..bc5117e 100644
--- a/pages/api/v1/tokens/index.ts
+++ b/pages/api/v1/tokens/index.ts
@@ -11,6 +11,12 @@ export default async function tokens(
if (!user) return;
if (req.method === "POST") {
+ if (process.env.NEXT_PUBLIC_DEMO === "true")
+ return res.status(400).json({
+ response:
+ "This action is disabled because this is a read-only demo of Linkwarden.",
+ });
+
const token = await postToken(JSON.parse(req.body), user.id);
return res.status(token.status).json({ response: token.response });
} else if (req.method === "GET") {
diff --git a/pages/api/v1/users/[id].ts b/pages/api/v1/users/[id].ts
index cf75e16..5213d7f 100644
--- a/pages/api/v1/users/[id].ts
+++ b/pages/api/v1/users/[id].ts
@@ -16,9 +16,17 @@ export default async function users(req: NextApiRequest, res: NextApiResponse) {
return null;
}
- const userId = token?.id;
+ const user = await prisma.user.findUnique({
+ where: {
+ id: token?.id,
+ },
+ });
- if (userId !== Number(req.query.id))
+ const isServerAdmin = user?.id === Number(process.env.NEXT_PUBLIC_ADMIN || 1);
+
+ const userId = isServerAdmin ? Number(req.query.id) : token.id;
+
+ if (userId !== Number(req.query.id) && !isServerAdmin)
return res.status(401).json({ response: "Permission denied." });
if (req.method === "GET") {
@@ -50,10 +58,22 @@ export default async function users(req: NextApiRequest, res: NextApiResponse) {
}
if (req.method === "PUT") {
+ if (process.env.NEXT_PUBLIC_DEMO === "true")
+ return res.status(400).json({
+ response:
+ "This action is disabled because this is a read-only demo of Linkwarden.",
+ });
+
const updated = await updateUserById(userId, req.body);
return res.status(updated.status).json({ response: updated.response });
} else if (req.method === "DELETE") {
- const updated = await deleteUserById(userId, req.body);
+ if (process.env.NEXT_PUBLIC_DEMO === "true")
+ return res.status(400).json({
+ response:
+ "This action is disabled because this is a read-only demo of Linkwarden.",
+ });
+
+ const updated = await deleteUserById(userId, req.body, isServerAdmin);
return res.status(updated.status).json({ response: updated.response });
}
}
diff --git a/pages/api/v1/users/index.ts b/pages/api/v1/users/index.ts
index 3af7bf8..356a9f0 100644
--- a/pages/api/v1/users/index.ts
+++ b/pages/api/v1/users/index.ts
@@ -1,9 +1,25 @@
import type { NextApiRequest, NextApiResponse } from "next";
import postUser from "@/lib/api/controllers/users/postUser";
+import getUsers from "@/lib/api/controllers/users/getUsers";
+import verifyUser from "@/lib/api/verifyUser";
export default async function users(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "POST") {
+ if (process.env.NEXT_PUBLIC_DEMO === "true")
+ return res.status(400).json({
+ response:
+ "This action is disabled because this is a read-only demo of Linkwarden.",
+ });
+
const response = await postUser(req, res);
- return response;
+ return res.status(response.status).json({ response: response.response });
+ } else if (req.method === "GET") {
+ const user = await verifyUser({ req, res });
+
+ if (!user || user.id !== Number(process.env.NEXT_PUBLIC_ADMIN || 1))
+ return res.status(401).json({ response: "Unauthorized..." });
+
+ const response = await getUsers();
+ return res.status(response.status).json({ response: response.response });
}
}
diff --git a/pages/api/v2/dashboard/index.ts b/pages/api/v2/dashboard/index.ts
new file mode 100644
index 0000000..2cb6c9e
--- /dev/null
+++ b/pages/api/v2/dashboard/index.ts
@@ -0,0 +1,28 @@
+import type { NextApiRequest, NextApiResponse } from "next";
+import { LinkRequestQuery } from "@/types/global";
+import getDashboardDataV2 from "@/lib/api/controllers/dashboard/getDashboardDataV2";
+import verifyUser from "@/lib/api/verifyUser";
+
+export default async function dashboard(
+ req: NextApiRequest,
+ res: NextApiResponse
+) {
+ const user = await verifyUser({ req, res });
+ if (!user) return;
+
+ if (req.method === "GET") {
+ const convertedData: LinkRequestQuery = {
+ sort: Number(req.query.sort as string),
+ cursor: req.query.cursor ? Number(req.query.cursor as string) : undefined,
+ };
+
+ const data = await getDashboardDataV2(user.id, convertedData);
+ return res.status(data.status).json({
+ data: {
+ links: data.data.links,
+ numberOfPinnedLinks: data.data.numberOfPinnedLinks,
+ },
+ message: data.message,
+ });
+ }
+}
diff --git a/pages/auth/reset-password.tsx b/pages/auth/reset-password.tsx
new file mode 100644
index 0000000..0ea71c0
--- /dev/null
+++ b/pages/auth/reset-password.tsx
@@ -0,0 +1,119 @@
+import Button from "@/components/ui/Button";
+import TextInput from "@/components/TextInput";
+import CenteredForm from "@/layouts/CenteredForm";
+import Link from "next/link";
+import { useRouter } from "next/router";
+import { FormEvent, useState } from "react";
+import { toast } from "react-hot-toast";
+import getServerSideProps from "@/lib/client/getServerSideProps";
+import { useTranslation } from "next-i18next";
+
+interface FormData {
+ password: string;
+ token: string;
+}
+
+export default function ResetPassword() {
+ const { t } = useTranslation();
+ const [submitLoader, setSubmitLoader] = useState(false);
+ const router = useRouter();
+
+ const [form, setForm] = useState({
+ password: "",
+ token: router.query.token as string,
+ });
+
+ const [requestSent, setRequestSent] = useState(false);
+
+ async function submit(event: FormEvent) {
+ event.preventDefault();
+
+ if (
+ form.password !== "" &&
+ form.token !== "" &&
+ !requestSent &&
+ !submitLoader
+ ) {
+ setSubmitLoader(true);
+
+ const load = toast.loading(t("sending_password_recovery_link"));
+
+ const response = await fetch("/api/v1/auth/reset-password", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(form),
+ });
+
+ const data = await response.json();
+
+ toast.dismiss(load);
+ if (response.ok) {
+ toast.success(data.response);
+ setRequestSent(true);
+ } else {
+ toast.error(data.response);
+ }
+
+ setSubmitLoader(false);
+ } else {
+ toast.error(t("please_fill_all_fields"));
+ }
+ }
+
+ return (
+
+
+
+ );
+}
+
+export { getServerSideProps };
diff --git a/pages/auth/verify-email.tsx b/pages/auth/verify-email.tsx
new file mode 100644
index 0000000..0a4d298
--- /dev/null
+++ b/pages/auth/verify-email.tsx
@@ -0,0 +1,42 @@
+import { signOut } from "next-auth/react";
+import { useRouter } from "next/router";
+import { useEffect } from "react";
+import toast from "react-hot-toast";
+import getServerSideProps from "@/lib/client/getServerSideProps";
+import { useTranslation } from "next-i18next";
+
+const VerifyEmail = () => {
+ const router = useRouter();
+ const { t } = useTranslation();
+
+ useEffect(() => {
+ const token = router.query.token;
+
+ if (!token || typeof token !== "string") {
+ router.push("/login");
+ }
+
+ // Verify token
+
+ fetch(`/api/v1/auth/verify-email?token=${token}`, {
+ method: "POST",
+ }).then((res) => {
+ if (res.ok) {
+ toast.success(t("email_verified_signing_out"));
+ setTimeout(() => {
+ signOut();
+ }, 3000);
+ } else {
+ toast.error(t("invalid_token"));
+ }
+ });
+
+ console.log(token);
+ }, []);
+
+ return <>>;
+};
+
+export default VerifyEmail;
+
+export { getServerSideProps };
diff --git a/pages/choose-username.tsx b/pages/choose-username.tsx
deleted file mode 100644
index e1e6b69..0000000
--- a/pages/choose-username.tsx
+++ /dev/null
@@ -1,93 +0,0 @@
-import SubmitButton from "@/components/SubmitButton";
-import { signOut } from "next-auth/react";
-import { FormEvent, useState } from "react";
-import { toast } from "react-hot-toast";
-import { useSession } from "next-auth/react";
-import useAccountStore from "@/store/account";
-import CenteredForm from "@/layouts/CenteredForm";
-import TextInput from "@/components/TextInput";
-import AccentSubmitButton from "@/components/AccentSubmitButton";
-
-export default function ChooseUsername() {
- const [submitLoader, setSubmitLoader] = useState(false);
- const [inputedUsername, setInputedUsername] = useState("");
-
- const { data, status, update } = useSession();
-
- const { updateAccount, account } = useAccountStore();
-
- async function submitUsername(event: FormEvent) {
- event.preventDefault();
-
- setSubmitLoader(true);
-
- const redirectionToast = toast.loading("Applying...");
-
- const response = await updateAccount({
- ...account,
- username: inputedUsername,
- });
-
- if (response.ok) {
- toast.success("Username Applied!");
-
- update({
- id: data?.user.id,
- });
- } else toast.error(response.data as string);
- toast.dismiss(redirectionToast);
- setSubmitLoader(false);
- }
-
- return (
-
-
-
- );
-}
diff --git a/pages/collections/[id].tsx b/pages/collections/[id].tsx
index 92456a7..bd24a95 100644
--- a/pages/collections/[id].tsx
+++ b/pages/collections/[id].tsx
@@ -9,7 +9,6 @@ import { useRouter } from "next/router";
import React, { useEffect, useState } from "react";
import MainLayout from "@/layouts/MainLayout";
import ProfilePhoto from "@/components/ProfilePhoto";
-import SortDropdown from "@/components/SortDropdown";
import useLinks from "@/hooks/useLinks";
import usePermissions from "@/hooks/usePermissions";
import NoLinksFound from "@/components/NoLinksFound";
@@ -19,23 +18,22 @@ import getPublicUserData from "@/lib/client/getPublicUserData";
import EditCollectionModal from "@/components/ModalContent/EditCollectionModal";
import EditCollectionSharingModal from "@/components/ModalContent/EditCollectionSharingModal";
import DeleteCollectionModal from "@/components/ModalContent/DeleteCollectionModal";
-import ViewDropdown from "@/components/ViewDropdown";
import CardView from "@/components/LinkViews/Layouts/CardView";
-// import GridView from "@/components/LinkViews/Layouts/GridView";
import ListView from "@/components/LinkViews/Layouts/ListView";
import { dropdownTriggerer } from "@/lib/client/utils";
import NewCollectionModal from "@/components/ModalContent/NewCollectionModal";
-import BulkDeleteLinksModal from "@/components/ModalContent/BulkDeleteLinksModal";
-import toast from "react-hot-toast";
-import BulkEditLinksModal from "@/components/ModalContent/BulkEditLinksModal";
+import MasonryView from "@/components/LinkViews/Layouts/MasonryView";
+import getServerSideProps from "@/lib/client/getServerSideProps";
+import { useTranslation } from "next-i18next";
+import LinkListOptions from "@/components/LinkListOptions";
export default function Index() {
+ const { t } = useTranslation();
const { settings } = useLocalSettingsStore();
const router = useRouter();
- const { links, selectedLinks, setSelectedLinks, deleteLinksById } =
- useLinkStore();
+ const { links } = useLinkStore();
const { collections } = useCollectionStore();
const [sortBy, setSortBy] = useState(Sort.DateNewestFirst);
@@ -61,6 +59,7 @@ export default function Index() {
username: "",
image: "",
archiveAsScreenshot: undefined as unknown as boolean,
+ archiveAsMonolith: undefined as unknown as boolean,
archiveAsPDF: undefined as unknown as boolean,
});
@@ -78,15 +77,13 @@ export default function Index() {
username: account.username as string,
image: account.image as string,
archiveAsScreenshot: account.archiveAsScreenshot as boolean,
+ archiveAsMonolith: account.archiveAsScreenshot as boolean,
archiveAsPDF: account.archiveAsPDF as boolean,
});
}
};
fetchOwner();
-
- // When the collection changes, reset the selected links
- setSelectedLinks([]);
}, [activeCollection]);
const [editCollectionModal, setEditCollectionModal] = useState(false);
@@ -94,8 +91,6 @@ export default function Index() {
const [editCollectionSharingModal, setEditCollectionSharingModal] =
useState(false);
const [deleteCollectionModal, setDeleteCollectionModal] = useState(false);
- const [bulkDeleteLinksModal, setBulkDeleteLinksModal] = useState(false);
- const [bulkEditLinksModal, setBulkEditLinksModal] = useState(false);
const [editMode, setEditMode] = useState(false);
useEffect(() => {
@@ -108,42 +103,13 @@ export default function Index() {
const linkView = {
[ViewMode.Card]: CardView,
- // [ViewMode.Grid]: GridView,
[ViewMode.List]: ListView,
+ [ViewMode.Masonry]: MasonryView,
};
// @ts-ignore
const LinkComponent = linkView[viewMode];
- const handleSelectAll = () => {
- if (selectedLinks.length === links.length) {
- setSelectedLinks([]);
- } else {
- setSelectedLinks(links.map((link) => link));
- }
- };
-
- const bulkDeleteLinks = async () => {
- const load = toast.loading(
- `Deleting ${selectedLinks.length} Link${
- selectedLinks.length > 1 ? "s" : ""
- }...`
- );
-
- const response = await deleteLinksById(
- selectedLinks.map((link) => link.id as number)
- );
-
- toast.dismiss(load);
-
- response.ok &&
- toast.success(
- `Deleted ${selectedLinks.length} Link${
- selectedLinks.length > 1 ? "s" : ""
- }!`
- );
- };
-
return (
-
+
{activeCollection?.name}
@@ -187,7 +153,7 @@ export default function Index() {
setEditCollectionModal(true);
}}
>
- Edit Collection Info
+ {t("edit_collection_info")}
)}
@@ -201,8 +167,8 @@ export default function Index() {
}}
>
{permissions === true
- ? "Share and Collaborate"
- : "View Team"}
+ ? t("share_and_collaborate")
+ : t("view_team")}