Merge branch 'dev' of https://github.com/linkwarden/linkwarden into feat/single-file

This commit is contained in:
daniel31x13
2024-06-18 12:19:52 -04:00
161 changed files with 7449 additions and 2996 deletions
+94 -78
View File
@@ -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: {
@@ -74,83 +76,97 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) {
return res.send(file);
}
} 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 && 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,
// });
// });
// }
}
+60 -6
View File
@@ -1,7 +1,6 @@
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";
@@ -66,6 +65,7 @@ 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";
import { randomBytes } from "crypto";
const emailEnabled =
process.env.EMAIL_FROM && process.env.EMAIL_SERVER ? true : false;
@@ -106,22 +106,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 +137,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 +262,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(
@@ -520,6 +571,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,
},
})
);
+52
View File
@@ -0,0 +1,52 @@
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") {
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.",
});
}
}
+80
View File
@@ -0,0 +1,80 @@
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") {
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.",
});
}
}
+114
View File
@@ -0,0 +1,114 @@
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") {
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,
});
}
}
+2 -16
View File
@@ -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;
@@ -81,19 +81,5 @@ const deleteArchivedFiles = async (link: Link & { collection: Collection }) => {
},
});
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/${link.collection.id}/${link.id}.html`,
});
await removeFile({
filePath: `archives/preview/${link.collection.id}/${link.id}.png`,
});
await removeFiles(link.id, link.collection.id);
};
+7
View File
@@ -391,6 +391,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" ||
+4 -2
View File
@@ -4,6 +4,7 @@ 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: {
@@ -32,9 +33,10 @@ export default async function users(req: NextApiRequest, res: NextApiResponse) {
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 });
}
+11 -3
View File
@@ -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 = process.env.ADMINISTRATOR === user?.username;
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") {
@@ -53,7 +61,7 @@ export default async function users(req: NextApiRequest, res: NextApiResponse) {
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);
const updated = await deleteUserById(userId, req.body, isServerAdmin);
return res.status(updated.status).json({ response: updated.response });
}
}
+10 -1
View File
@@ -1,9 +1,18 @@
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") {
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 || process.env.ADMINISTRATOR !== user.username)
return res.status(401).json({ response: "Unauthorized..." });
const response = await getUsers();
return res.status(response.status).json({ response: response.response });
}
}