Merge branch 'dev' of https://github.com/linkwarden/linkwarden into feat/single-file
This commit is contained in:
+67
-55
@@ -4,9 +4,9 @@ import createFile from "./storage/createFile";
|
||||
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 createFolder from "./storage/createFolder";
|
||||
import generatePreview from "./generatePreview";
|
||||
import { removeFiles } from "./manageLinkFiles";
|
||||
import archiveAsSinglefile from "./preservationScheme/archiveAsSinglefile";
|
||||
import archiveAsReadability from "./preservationScheme/archiveAsReadablility";
|
||||
|
||||
@@ -43,6 +43,32 @@ export default async function archiveHandler(link: LinksAndCollectionAndOwner) {
|
||||
}
|
||||
|
||||
const browser = await chromium.launch(browserOptions);
|
||||
const context = await browser.newContext({
|
||||
...devices["Desktop Chrome"],
|
||||
ignoreHTTPSErrors: process.env.IGNORE_HTTPS_ERRORS === "true",
|
||||
});
|
||||
|
||||
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([
|
||||
@@ -53,7 +79,10 @@ export default async function archiveHandler(link: LinksAndCollectionAndOwner) {
|
||||
? await validateUrlSize(link.url)
|
||||
: undefined;
|
||||
|
||||
if (validatedUrl === null)
|
||||
if (
|
||||
validatedUrl === null &&
|
||||
process.env.IGNORE_URL_SIZE_LIMIT !== "true"
|
||||
)
|
||||
throw "Something went wrong while retrieving the file size.";
|
||||
|
||||
const contentType = validatedUrl?.get("content-type");
|
||||
@@ -134,20 +163,10 @@ export default async function archiveHandler(link: LinksAndCollectionAndOwner) {
|
||||
|
||||
// Preview
|
||||
|
||||
if (
|
||||
!link.preview?.startsWith("archives") &&
|
||||
!link.preview?.startsWith("unavailable")
|
||||
) {
|
||||
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}`,
|
||||
});
|
||||
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);
|
||||
@@ -155,39 +174,40 @@ export default async function archiveHandler(link: LinksAndCollectionAndOwner) {
|
||||
// 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 imageResponse is not null
|
||||
if (imageResponse && !link.preview?.startsWith("archive")) {
|
||||
const buffer = await imageResponse.body();
|
||||
await generatePreview(buffer, link.collectionId, link.id);
|
||||
|
||||
// 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
|
||||
);
|
||||
// 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`,
|
||||
},
|
||||
});
|
||||
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.");
|
||||
}
|
||||
});
|
||||
}
|
||||
}).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")) {
|
||||
@@ -317,15 +337,7 @@ export default async function archiveHandler(link: LinksAndCollectionAndOwner) {
|
||||
},
|
||||
});
|
||||
else {
|
||||
removeFile({ filePath: `archives/${link.collectionId}/${link.id}.png` });
|
||||
removeFile({ filePath: `archives/${link.collectionId}/${link.id}.html` });
|
||||
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();
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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,18 +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`,
|
||||
});
|
||||
removeFile({
|
||||
filePath: `archives/${collectionIsAccessible?.id}/${linkId}.html`,
|
||||
});
|
||||
if (collectionIsAccessible) removeFiles(linkId, collectionIsAccessible.id);
|
||||
}
|
||||
|
||||
return { response: deletedLinks, status: 200 };
|
||||
|
||||
@@ -2,6 +2,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 +13,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,18 +25,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`,
|
||||
});
|
||||
removeFile({
|
||||
filePath: `archives/${collectionIsAccessible?.id}/${linkId}.html`,
|
||||
});
|
||||
removeFiles(linkId, collectionIsAccessible.id);
|
||||
|
||||
return { response: deleteLink, status: 200 };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -146,25 +146,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 moveFile(
|
||||
`archives/${collectionIsAccessible?.id}/${linkId}.html`,
|
||||
`archives/${data.collection.id}/${linkId}.html`
|
||||
);
|
||||
await moveFiles(linkId, collectionIsAccessible?.id, data.collection.id);
|
||||
}
|
||||
|
||||
return { response: updatedLink, status: 200 };
|
||||
|
||||
@@ -12,14 +12,16 @@ 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.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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!link.collection.id && link.collection.name) {
|
||||
@@ -117,15 +119,24 @@ 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.`);
|
||||
|
||||
console.log(url, urlWithoutWww, urlWithWww);
|
||||
|
||||
const existingLink = await prisma.link.findFirst({
|
||||
where: {
|
||||
url: link.url?.trim(),
|
||||
OR: [{ url: urlWithWww }, { url: urlWithoutWww }],
|
||||
collection: {
|
||||
ownerId: userId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
console.log(url, urlWithoutWww, urlWithWww, "DONE!");
|
||||
|
||||
if (existingLink)
|
||||
return {
|
||||
response: "Link already exists",
|
||||
@@ -149,12 +160,13 @@ export default async function postLink(
|
||||
|
||||
link.collection.name = link.collection.name.trim();
|
||||
|
||||
const description =
|
||||
link.description && link.description !== ""
|
||||
? link.description
|
||||
: link.url
|
||||
? await getTitle(link.url)
|
||||
: undefined;
|
||||
const title =
|
||||
!(link.name && link.name !== "") && link.url
|
||||
? await getTitle(link.url)
|
||||
: "";
|
||||
|
||||
const name =
|
||||
link.name && link.name !== "" ? link.name : link.url ? title : "";
|
||||
|
||||
const validatedUrl = link.url ? await validateUrlSize(link.url) : undefined;
|
||||
|
||||
@@ -172,9 +184,9 @@ 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: {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { prisma } from "@/lib/api/db";
|
||||
import createFolder from "@/lib/api/storage/createFolder";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { parse, Node, Element, TextNode } from "himalaya";
|
||||
import { writeFileSync } from "fs";
|
||||
|
||||
const MAX_LINKS_PER_USER = Number(process.env.MAX_LINKS_PER_USER) || 30000;
|
||||
|
||||
@@ -36,7 +37,9 @@ export default async function importFromHTMLFile(
|
||||
|
||||
const jsonData = parse(document.documentElement.outerHTML);
|
||||
|
||||
for (const item of jsonData) {
|
||||
const processedArray = processNodes(jsonData);
|
||||
|
||||
for (const item of processedArray) {
|
||||
console.log(item);
|
||||
await processBookmarks(userId, item as Element);
|
||||
}
|
||||
@@ -74,7 +77,9 @@ async function processBookmarks(
|
||||
} else if (item.type === "element" && item.tagName === "a") {
|
||||
// process link
|
||||
|
||||
const linkUrl = item?.attributes.find((e) => e.key === "href")?.value;
|
||||
const linkUrl = item?.attributes.find(
|
||||
(e) => e.key.toLowerCase() === "href"
|
||||
)?.value;
|
||||
const linkName = (
|
||||
item?.children.find((e) => e.type === "text") as TextNode
|
||||
)?.content;
|
||||
@@ -82,14 +87,33 @@ async function processBookmarks(
|
||||
.find((e) => e.key === "tags")
|
||||
?.value.split(",");
|
||||
|
||||
// set date if available
|
||||
const linkDateValue = item?.attributes.find(
|
||||
(e) => e.key.toLowerCase() === "add_date"
|
||||
)?.value;
|
||||
|
||||
const linkDate = linkDateValue
|
||||
? new Date(Number(linkDateValue) * 1000)
|
||||
: undefined;
|
||||
|
||||
let linkDesc =
|
||||
(
|
||||
(
|
||||
item?.children?.find(
|
||||
(e) => e.type === "element" && e.tagName === "dd"
|
||||
) as Element
|
||||
)?.children[0] as TextNode
|
||||
)?.content || "";
|
||||
|
||||
if (linkUrl && parentCollectionId) {
|
||||
await createLink(
|
||||
userId,
|
||||
linkUrl,
|
||||
parentCollectionId,
|
||||
linkName,
|
||||
"",
|
||||
linkTags
|
||||
linkDesc,
|
||||
linkTags,
|
||||
linkDate
|
||||
);
|
||||
} else if (linkUrl) {
|
||||
// create a collection named "Imported Bookmarks" and add the link to it
|
||||
@@ -100,8 +124,9 @@ async function processBookmarks(
|
||||
linkUrl,
|
||||
collectionId,
|
||||
linkName,
|
||||
"",
|
||||
linkTags
|
||||
linkDesc,
|
||||
linkTags,
|
||||
linkDate
|
||||
);
|
||||
}
|
||||
|
||||
@@ -160,7 +185,8 @@ const createLink = async (
|
||||
collectionId: number,
|
||||
name?: string,
|
||||
description?: string,
|
||||
tags?: string[]
|
||||
tags?: string[],
|
||||
importDate?: Date
|
||||
) => {
|
||||
await prisma.link.create({
|
||||
data: {
|
||||
@@ -193,6 +219,48 @@ const createLink = async (
|
||||
}),
|
||||
}
|
||||
: undefined,
|
||||
importDate: importDate || undefined,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
function processNodes(nodes: Node[]) {
|
||||
const findAndProcessDL = (node: Node) => {
|
||||
if (node.type === "element" && node.tagName === "dl") {
|
||||
processDLChildren(node);
|
||||
} else if (
|
||||
node.type === "element" &&
|
||||
node.children &&
|
||||
node.children.length
|
||||
) {
|
||||
node.children.forEach((child) => findAndProcessDL(child));
|
||||
}
|
||||
};
|
||||
|
||||
const processDLChildren = (dlNode: Element) => {
|
||||
dlNode.children.forEach((child, i) => {
|
||||
if (child.type === "element" && child.tagName === "dt") {
|
||||
const nextSibling = dlNode.children[i + 1];
|
||||
if (
|
||||
nextSibling &&
|
||||
nextSibling.type === "element" &&
|
||||
nextSibling.tagName === "dd"
|
||||
) {
|
||||
const aElement = child.children.find(
|
||||
(el) => el.type === "element" && el.tagName === "a"
|
||||
);
|
||||
if (aElement && aElement.type === "element") {
|
||||
// Add the 'dd' element as a child of the 'a' element
|
||||
aElement.children.push(nextSibling);
|
||||
// Remove the 'dd' from the parent 'dl' to avoid duplicate processing
|
||||
dlNode.children.splice(i + 1, 1);
|
||||
// Adjust the loop counter due to the removal
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
nodes.forEach(findAndProcessDL);
|
||||
return nodes;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, string>;
|
||||
}[];
|
||||
|
||||
export default async function importFromWallabag(
|
||||
userId: number,
|
||||
rawData: string
|
||||
) {
|
||||
const data: WallabagBackup = JSON.parse(rawData);
|
||||
|
||||
const backup = data.filter((e) => e.url);
|
||||
|
||||
let totalImports = backup.length;
|
||||
|
||||
const numberOfLinksTheUserHas = await prisma.link.count({
|
||||
where: {
|
||||
collection: {
|
||||
ownerId: userId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (totalImports + numberOfLinksTheUserHas > MAX_LINKS_PER_USER)
|
||||
return {
|
||||
response: `Error: Each user 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 };
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { prisma } from "@/lib/api/db";
|
||||
|
||||
export default async function getUsers() {
|
||||
// Get all users
|
||||
const users = await prisma.user.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
emailVerified: true,
|
||||
subscriptions: {
|
||||
select: {
|
||||
active: true,
|
||||
},
|
||||
},
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
return { response: users, status: 200 };
|
||||
}
|
||||
@@ -1,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<Data>
|
||||
) {
|
||||
if (process.env.NEXT_PUBLIC_DISABLE_REGISTRATION === "true") {
|
||||
return res.status(400).json({ response: "Registration is disabled." });
|
||||
res: NextApiResponse
|
||||
): Promise<Data> {
|
||||
let isAdmin = await isServerAdmin({ req });
|
||||
|
||||
if (process.env.NEXT_PUBLIC_DISABLE_REGISTRATION === "true" && !isAdmin) {
|
||||
return { response: "Registration is disabled.", status: 400 };
|
||||
}
|
||||
|
||||
const body: User = req.body;
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
@@ -71,6 +81,10 @@ export default async function deleteUserById(
|
||||
|
||||
// Delete archive folders
|
||||
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: {
|
||||
|
||||
@@ -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,14 +193,19 @@ 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,
|
||||
archiveAsSinglefile: data.archiveAsSinglefile,
|
||||
archiveAsPDF: data.archiveAsPDF,
|
||||
@@ -245,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<AccountSettings, "password"> = {
|
||||
...userInfo,
|
||||
whitelistedUsers: newWhitelistedUsernames,
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import Jimp from "jimp";
|
||||
import { prisma } from "./db";
|
||||
import createFile from "./storage/createFile";
|
||||
import createFolder from "./storage/createFolder";
|
||||
|
||||
const generatePreview = async (
|
||||
buffer: Buffer,
|
||||
collectionId: number,
|
||||
linkId: number
|
||||
) => {
|
||||
if (buffer && collectionId && linkId) {
|
||||
// Load the image using Jimp
|
||||
await 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/${collectionId}/${linkId}.jpeg`,
|
||||
}).then(() => {
|
||||
return 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;
|
||||
@@ -0,0 +1,44 @@
|
||||
import { NextApiRequest } from "next";
|
||||
import { getToken } from "next-auth/jwt";
|
||||
import { prisma } from "./db";
|
||||
|
||||
type Props = {
|
||||
req: NextApiRequest;
|
||||
};
|
||||
|
||||
export default async function isServerAdmin({ req }: Props): Promise<boolean> {
|
||||
const token = await getToken({ req });
|
||||
const userId = token?.id;
|
||||
|
||||
if (!userId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (token.exp < Date.now() / 1000) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// check if token is revoked
|
||||
const revoked = await prisma.accessToken.findFirst({
|
||||
where: {
|
||||
token: token.jti,
|
||||
revoked: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (revoked) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const findUser = await prisma.user.findFirst({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (findUser?.username === process.env.ADMINISTRATOR) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
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`,
|
||||
});
|
||||
// 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/preview/${from}/${linkId}.jpeg`,
|
||||
`archives/preview/${to}/${linkId}.jpeg`
|
||||
);
|
||||
|
||||
await moveFile(
|
||||
`archives/${from}/${linkId}_readability.json`,
|
||||
`archives/${to}/${linkId}_readability.json`
|
||||
);
|
||||
};
|
||||
|
||||
export { removeFiles, moveFiles };
|
||||
@@ -0,0 +1,57 @@
|
||||
import { randomBytes } from "crypto";
|
||||
import { prisma } from "./db";
|
||||
import transporter from "./transporter";
|
||||
import Handlebars from "handlebars";
|
||||
import { readFileSync } from "fs";
|
||||
import path from "path";
|
||||
|
||||
export default async function sendChangeEmailVerificationRequest(
|
||||
oldEmail: string,
|
||||
newEmail: string,
|
||||
user: string
|
||||
) {
|
||||
const token = randomBytes(32).toString("hex");
|
||||
|
||||
await prisma.$transaction(async () => {
|
||||
await prisma.verificationToken.create({
|
||||
data: {
|
||||
identifier: oldEmail?.toLowerCase(),
|
||||
token,
|
||||
expires: new Date(Date.now() + 24 * 3600 * 1000), // 1 day
|
||||
},
|
||||
});
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
email: oldEmail?.toLowerCase(),
|
||||
},
|
||||
data: {
|
||||
unverifiedNewEmail: newEmail?.toLowerCase(),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const emailsDir = path.resolve(process.cwd(), "templates");
|
||||
|
||||
const templateFile = readFileSync(
|
||||
path.join(emailsDir, "verifyEmailChange.html"),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
const emailTemplate = Handlebars.compile(templateFile);
|
||||
|
||||
transporter.sendMail({
|
||||
from: {
|
||||
name: "Linkwarden",
|
||||
address: process.env.EMAIL_FROM as string,
|
||||
},
|
||||
to: newEmail,
|
||||
subject: "Verify your new Linkwarden email address",
|
||||
html: emailTemplate({
|
||||
user,
|
||||
baseUrl: process.env.BASE_URL,
|
||||
oldEmail,
|
||||
newEmail,
|
||||
verifyUrl: `${process.env.BASE_URL}/auth/verify-email?token=${token}`,
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { randomBytes } from "crypto";
|
||||
import { prisma } from "./db";
|
||||
import transporter from "./transporter";
|
||||
import Handlebars from "handlebars";
|
||||
import { readFileSync } from "fs";
|
||||
import path from "path";
|
||||
|
||||
export default async function sendPasswordResetRequest(
|
||||
email: string,
|
||||
user: string
|
||||
) {
|
||||
const token = randomBytes(32).toString("hex");
|
||||
|
||||
await prisma.passwordResetToken.create({
|
||||
data: {
|
||||
identifier: email?.toLowerCase(),
|
||||
token,
|
||||
expires: new Date(Date.now() + 24 * 3600 * 1000), // 1 day
|
||||
},
|
||||
});
|
||||
|
||||
const emailsDir = path.resolve(process.cwd(), "templates");
|
||||
|
||||
const templateFile = readFileSync(
|
||||
path.join(emailsDir, "passwordReset.html"),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
const emailTemplate = Handlebars.compile(templateFile);
|
||||
|
||||
transporter.sendMail({
|
||||
from: {
|
||||
name: "Linkwarden",
|
||||
address: process.env.EMAIL_FROM as string,
|
||||
},
|
||||
to: email,
|
||||
subject: "Linkwarden: Reset password instructions",
|
||||
html: emailTemplate({
|
||||
user,
|
||||
baseUrl: process.env.BASE_URL,
|
||||
url: `${process.env.BASE_URL}/auth/reset-password?token=${token}`,
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -1,19 +1,44 @@
|
||||
import { Theme } from "next-auth";
|
||||
import { SendVerificationRequestParams } from "next-auth/providers";
|
||||
import { createTransport } from "nodemailer";
|
||||
import { readFileSync } from "fs";
|
||||
import path from "path";
|
||||
import Handlebars from "handlebars";
|
||||
import transporter from "./transporter";
|
||||
|
||||
type Params = {
|
||||
identifier: string;
|
||||
url: string;
|
||||
from: string;
|
||||
token: string;
|
||||
};
|
||||
|
||||
export default async function sendVerificationRequest({
|
||||
identifier,
|
||||
url,
|
||||
from,
|
||||
token,
|
||||
}: Params) {
|
||||
const emailsDir = path.resolve(process.cwd(), "templates");
|
||||
|
||||
const templateFile = readFileSync(
|
||||
path.join(emailsDir, "verifyEmail.html"),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
const emailTemplate = Handlebars.compile(templateFile);
|
||||
|
||||
export default async function sendVerificationRequest(
|
||||
params: SendVerificationRequestParams
|
||||
) {
|
||||
const { identifier, url, provider, theme } = params;
|
||||
const { host } = new URL(url);
|
||||
const transport = createTransport(provider.server);
|
||||
const result = await transport.sendMail({
|
||||
const result = await transporter.sendMail({
|
||||
to: identifier,
|
||||
from: provider.from,
|
||||
subject: `Sign in to ${host}`,
|
||||
from: {
|
||||
name: "Linkwarden",
|
||||
address: from as string,
|
||||
},
|
||||
subject: `Please verify your email address`,
|
||||
text: text({ url, host }),
|
||||
html: html({ url, host, theme }),
|
||||
html: emailTemplate({
|
||||
url: `${
|
||||
process.env.NEXTAUTH_URL
|
||||
}/callback/email?token=${token}&email=${encodeURIComponent(identifier)}`,
|
||||
}),
|
||||
});
|
||||
const failed = result.rejected.concat(result.pending).filter(Boolean);
|
||||
if (failed.length) {
|
||||
@@ -21,55 +46,6 @@ export default async function sendVerificationRequest(
|
||||
}
|
||||
}
|
||||
|
||||
function html(params: { url: string; host: string; theme: Theme }) {
|
||||
const { url, host, theme } = params;
|
||||
|
||||
const escapedHost = host.replace(/\./g, "​.");
|
||||
|
||||
const brandColor = theme.brandColor || "#0029cf";
|
||||
const color = {
|
||||
background: "#f9f9f9",
|
||||
text: "#444",
|
||||
mainBackground: "#fff",
|
||||
buttonBackground: brandColor,
|
||||
buttonBorder: brandColor,
|
||||
buttonText: theme.buttonText || "#fff",
|
||||
};
|
||||
|
||||
return `
|
||||
<body style="background: ${color.background};">
|
||||
<table width="100%" border="0" cellspacing="20" cellpadding="0"
|
||||
style="background: ${color.mainBackground}; max-width: 600px; margin: auto; border-radius: 10px;">
|
||||
<tr>
|
||||
<td align="center"
|
||||
style="padding: 10px 0px; font-size: 22px; font-family: Helvetica, Arial, sans-serif; color: ${color.text};">
|
||||
Sign in to <strong>${escapedHost}</strong>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="padding: 20px 0;">
|
||||
<table border="0" cellspacing="0" cellpadding="0">
|
||||
<tr>
|
||||
<td align="center" style="border-radius: 5px;" bgcolor="${color.buttonBackground}">
|
||||
<a href="${url}" target="_blank" style="font-size: 18px; font-family: Helvetica, Arial, sans-serif; color: ${color.buttonText}; text-decoration: none; border-radius: 5px; padding: 10px 20px; border: 1px solid ${color.buttonBorder}; display: inline-block; font-weight: bold;">
|
||||
Sign in
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"
|
||||
style="padding: 0px 0px 10px 0px; font-size: 16px; line-height: 22px; font-family: Helvetica, Arial, sans-serif; color: ${color.text};">
|
||||
If you did not request this email you can safely ignore it.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
`;
|
||||
}
|
||||
|
||||
/** Email Text body (fallback for email clients that don't render HTML, e.g. feature phones) */
|
||||
function text({ url, host }: { url: string; host: string }) {
|
||||
return `Sign in to ${host}\n${url}\n\n`;
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { createTransport } from "nodemailer";
|
||||
|
||||
export default createTransport({
|
||||
url: process.env.EMAIL_SERVER,
|
||||
auth: {
|
||||
user: process.env.EMAIL_FROM,
|
||||
},
|
||||
});
|
||||
@@ -1,17 +1,35 @@
|
||||
import fetch from "node-fetch";
|
||||
import https from "https";
|
||||
import { SocksProxyAgent } from "socks-proxy-agent";
|
||||
|
||||
export default async function validateUrlSize(url: string) {
|
||||
if (process.env.IGNORE_URL_SIZE_LIMIT === "true") return null;
|
||||
|
||||
try {
|
||||
const httpsAgent = new https.Agent({
|
||||
rejectUnauthorized:
|
||||
process.env.IGNORE_UNAUTHORIZED_CA === "true" ? false : true,
|
||||
});
|
||||
|
||||
const response = await fetch(url, {
|
||||
let fetchOpts = {
|
||||
method: "HEAD",
|
||||
agent: httpsAgent,
|
||||
});
|
||||
};
|
||||
|
||||
if (process.env.PROXY) {
|
||||
let proxy = new URL(process.env.PROXY);
|
||||
if (process.env.PROXY_USERNAME) {
|
||||
proxy.username = process.env.PROXY_USERNAME;
|
||||
proxy.password = process.env.PROXY_PASSWORD || "";
|
||||
}
|
||||
|
||||
fetchOpts = {
|
||||
method: "HEAD",
|
||||
agent: new SocksProxyAgent(proxy.toString()),
|
||||
};
|
||||
}
|
||||
|
||||
const response = await fetch(url, fetchOpts);
|
||||
|
||||
const totalSizeMB =
|
||||
Number(response.headers.get("content-length")) / Math.pow(1024, 2);
|
||||
|
||||
@@ -32,19 +32,13 @@ export default async function verifyUser({
|
||||
subscriptions: true,
|
||||
},
|
||||
});
|
||||
const ssoUser = await prisma.account.findFirst({
|
||||
where: {
|
||||
userId: userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
res.status(404).json({ response: "User not found." });
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!user.username && !ssoUser) {
|
||||
// SSO users don't need a username
|
||||
if (!user.username) {
|
||||
res.status(401).json({
|
||||
response: "Username not found.",
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user