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

This commit is contained in:
Oliver Schwamb
2024-10-30 12:10:30 +01:00
266 changed files with 18575 additions and 6564 deletions
+223 -65
View File
@@ -9,6 +9,9 @@ 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";
import { UploadFileSchema } from "@/lib/shared/schemaValidation";
export const config = {
api: {
@@ -27,6 +30,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 +77,237 @@ 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.",
});
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",
];
const dataValidation = UploadFileSchema.safeParse({
id: Number(req.query.linkId),
format: Number(req.query.format),
file: files.file,
});
if (!dataValidation.success) {
return res.status(400).json({
response: `Error: ${
dataValidation.error.issues[0].message
} [${dataValidation.error.issues[0].path.join(", ")}]`,
});
}
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 },
});
const { mimetype } = files.file[0];
const isPDF = mimetype?.includes("pdf");
const isImage = mimetype?.includes("image");
if (linkStillExists && isImage) {
const collectionId = collectionPermissions.id;
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: isPDF ? "unavailable" : undefined,
image: isImage
? `archives/${collectionPermissions.id}/${linkId + suffix}`
: null,
pdf: isPDF
? `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;
// To update the link preview
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 collectionPermissions = await getPermission({
// userId: user.id,
// linkId,
// });
const user = await verifyUser({ req, res });
if (!user) return;
// const memberHasAccess = collectionPermissions?.members.some(
// (e: UsersAndCollections) => e.userId === user.id && e.canCreate
// );
const collectionPermissions = await getPermission({
userId: user.id,
linkId,
});
// if (!(collectionPermissions?.ownerId === user.id || memberHasAccess))
// return { response: "Collection is not accessible.", status: 401 };
if (!collectionPermissions)
return res.status(400).json({
response: "Collection is not accessible.",
});
// // await uploadHandler(linkId, )
const memberHasAccess = collectionPermissions.members.some(
(e: UsersAndCollections) => e.userId === user.id && e.canCreate
);
// const MAX_UPLOAD_SIZE = Number(process.env.NEXT_PUBLIC_MAX_FILE_SIZE);
if (!(collectionPermissions.ownerId === user.id || memberHasAccess))
return res.status(400).json({
response: "Collection is not accessible.",
});
// const form = formidable({
// maxFields: 1,
// maxFiles: 1,
// maxFileSize: MAX_UPLOAD_SIZE || 30 * 1048576,
// });
const NEXT_PUBLIC_MAX_FILE_BUFFER = Number(
process.env.NEXT_PUBLIC_MAX_FILE_BUFFER || 10
);
// form.parse(req, async (err, fields, files) => {
// const allowedMIMETypes = [
// "application/pdf",
// "image/png",
// "image/jpg",
// "image/jpeg",
// ];
const form = formidable({
maxFields: 1,
maxFiles: 1,
maxFileSize: NEXT_PUBLIC_MAX_FILE_BUFFER * 1024 * 1024,
});
// 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);
form.parse(req, async (err, fields, files) => {
const allowedMIMETypes = ["image/png", "image/jpg", "image/jpeg"];
// const linkStillExists = await prisma.link.findUnique({
// where: { id: linkId },
// });
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 (linkStillExists) {
// await createFile({
// filePath: `archives/${collectionPermissions?.id}/${
// linkId + suffix
// }`,
// data: fileBuffer,
// });
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 PNG, or JPG format and doesn't exceed ${NEXT_PUBLIC_MAX_FILE_BUFFER}MB.`,
});
// await prisma.link.update({
// where: { id: linkId },
// data: {
// image: `archives/${collectionPermissions?.id}/${
// linkId + suffix
// }`,
// lastPreserved: new Date().toISOString(),
// },
// });
// }
const linkStillExists = await prisma.link.update({
where: { id: linkId },
data: {
updatedAt: new Date(),
},
});
// fs.unlinkSync(files.file[0].filepath);
// }
if (linkStillExists) {
const collectionId = collectionPermissions.id;
createFolder({
filePath: `archives/preview/${collectionId}`,
});
// return res.status(200).json({
// response: files,
// });
// });
// }
await generatePreview(fileBuffer, collectionId, linkId);
}
fs.unlinkSync(files.file[0].filepath);
if (linkStillExists)
return res.status(200).json({
response: linkStillExists,
});
else return res.status(400).json({ response: "Link not found." });
}
});
}
}
+213 -20
View File
@@ -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;
@@ -77,10 +79,7 @@ const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY;
const providers: Provider[] = [];
if (
process.env.NEXT_PUBLIC_CREDENTIALS_ENABLED === "true" ||
process.env.NEXT_PUBLIC_CREDENTIALS_ENABLED === undefined
) {
if (process.env.NEXT_PUBLIC_CREDENTIALS_ENABLED !== "false") {
// undefined is for backwards compatibility
providers.push(
CredentialsProvider({
@@ -106,22 +105,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 +136,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 +261,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 +316,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 +622,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,
},
})
);
@@ -1081,10 +1186,42 @@ export default async function auth(req: NextApiRequest, res: NextApiResponse) {
providerAccountId: account?.providerAccountId,
},
});
if (existingUser && newSsoUsersDisabled) {
if (!existingUser && newSsoUsersDisabled) {
return false;
}
// If user is already registered, link the provider
if (user.email && account) {
const findUser = await prisma.user.findFirst({
where: {
email: user.email,
},
include: {
accounts: true,
},
});
if (findUser && findUser.accounts.length === 0) {
await prisma.account.create({
data: {
userId: findUser.id,
type: account.type,
provider: account.provider,
providerAccountId: account.providerAccountId,
id_token: account.id_token,
access_token: account.access_token,
refresh_token: account.refresh_token,
expires_at: account.expires_at,
token_type: account.token_type,
scope: account.scope,
session_state: account.session_state,
},
});
}
}
}
return true;
},
async jwt({ token, trigger, user }) {
@@ -1092,11 +1229,66 @@ export default async function auth(req: NextApiRequest, res: NextApiResponse) {
if (trigger === "signIn" || trigger === "signUp")
token.id = user?.id as number;
if (trigger === "signUp") {
const userExists = await prisma.user.findUnique({
where: {
id: token.id,
},
include: {
accounts: true,
},
});
// Verify SSO user email
if (userExists && userExists.accounts.length > 0) {
await prisma.user.update({
where: {
id: userExists.id,
},
data: {
emailVerified: new Date(),
},
});
}
if (userExists && !userExists.username) {
const autoGeneratedUsername =
"user" + Math.round(Math.random() * 1000000000);
await prisma.user.update({
where: {
id: token.id,
},
data: {
username: autoGeneratedUsername,
},
});
}
} else if (trigger === "signIn") {
const user = await prisma.user.findUnique({
where: {
id: token.id,
},
});
if (user && !user.username) {
const autoGeneratedUsername =
"user" + Math.round(Math.random() * 1000000000);
await prisma.user.update({
where: { id: user.id },
data: { username: autoGeneratedUsername },
});
}
}
return token;
},
async session({ session, token }) {
session.user.id = token.id;
console.log("session", session);
if (STRIPE_SECRET_KEY) {
const user = await prisma.user.findUnique({
where: {
@@ -1108,6 +1300,7 @@ export default async function auth(req: NextApiRequest, res: NextApiResponse) {
});
if (user) {
//
const subscribedUser = await verifySubscription(user);
}
}
+63
View File
@@ -0,0 +1,63 @@
import { prisma } from "@/lib/api/db";
import sendPasswordResetRequest from "@/lib/api/sendPasswordResetRequest";
import { ForgotPasswordSchema } from "@/lib/shared/schemaValidation";
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 dataValidation = ForgotPasswordSchema.safeParse(req.body);
if (!dataValidation.success) {
return res.status(400).json({
response: `Error: ${
dataValidation.error.issues[0].message
} [${dataValidation.error.issues[0].path.join(", ")}]`,
});
}
const { email } = dataValidation.data;
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: "No user found with that email.",
});
}
sendPasswordResetRequest(user.email, user.name);
return res.status(200).json({
response: "Password reset email sent.",
});
}
}
+84
View File
@@ -0,0 +1,84 @@
import { prisma } from "@/lib/api/db";
import type { NextApiRequest, NextApiResponse } from "next";
import bcrypt from "bcrypt";
import { ResetPasswordSchema } from "@/lib/shared/schemaValidation";
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 dataValidation = ResetPasswordSchema.safeParse(req.body);
if (!dataValidation.success) {
return res.status(400).json({
response: `Error: ${
dataValidation.error.issues[0].message
} [${dataValidation.error.issues[0].path.join(", ")}]`,
});
}
const { token, password } = dataValidation.data;
// 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.",
});
}
}
+125
View File
@@ -0,0 +1,125 @@
import { prisma } from "@/lib/api/db";
import updateCustomerEmail from "@/lib/api/updateCustomerEmail";
import { VerifyEmailSchema } from "@/lib/shared/schemaValidation";
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 dataValidation = VerifyEmailSchema.safeParse(req.query);
if (!dataValidation.success) {
return res.status(400).json({
response: `Error: ${
dataValidation.error.issues[0].message
} [${dataValidation.error.issues[0].path.join(", ")}]`,
});
}
const { token } = dataValidation.data;
// 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,
});
}
}
+12
View File
@@ -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 });
}
+6
View File
@@ -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)
+4 -1
View File
@@ -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;
+34 -31
View File
@@ -2,8 +2,10 @@ 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 { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import { UsersAndCollections } from "@prisma/client";
import getPermission from "@/lib/api/getPermission";
import { moveFiles, removeFiles } from "@/lib/api/manageLinkFiles";
const RE_ARCHIVE_LIMIT = Number(process.env.RE_ARCHIVE_LIMIT) || 5;
@@ -23,12 +25,27 @@ export default async function links(req: NextApiRequest, res: NextApiResponse) {
response: "Link not found.",
});
if (link.collection.ownerId !== user.id)
const collectionIsAccessible = await getPermission({
userId: user.id,
collectionId: link.collectionId,
});
const memberHasAccess = collectionIsAccessible?.members.some(
(e: UsersAndCollections) => e.userId === user.id && e.canUpdate
);
if (!(collectionIsAccessible?.ownerId === user.id || memberHasAccess))
return res.status(401).json({
response: "Permission denied.",
});
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) <
@@ -48,7 +65,20 @@ export default async function links(req: NextApiRequest, res: NextApiResponse) {
response: "Invalid URL.",
});
await deleteArchivedFiles(link);
await prisma.link.update({
where: {
id: link.id,
},
data: {
image: null,
pdf: null,
readable: null,
monolith: null,
preview: null,
},
});
await removeFiles(link.id, link.collection.id);
return res.status(200).json({
response: "Link is being archived.",
@@ -66,30 +96,3 @@ const getTimezoneDifferenceInMinutes = (future: Date, past: Date) => {
return diffInMinutes;
};
const deleteArchivedFiles = async (link: Link & { collection: Collection }) => {
await prisma.link.update({
where: {
id: link.id,
},
data: {
image: null,
pdf: null,
readable: 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`,
});
};
+12
View File
@@ -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,
+19
View File
@@ -37,21 +37,40 @@ 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,
req.body.removePreviousTags,
req.body.newData
);
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 deleteLinksById(user.id, req.body.linkIds);
return res.status(deleted.status).json({
response: deleted.response,
+22 -2
View File
@@ -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,10 +405,16 @@ 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" ||
process.env.NEXT_PUBLIC_CREDENTIALS_ENABLED === undefined
process.env.NEXT_PUBLIC_CREDENTIALS_ENABLED !== "false"
? "true"
: "false",
emailEnabled:
+13 -3
View File
@@ -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 });
}
+34
View File
@@ -0,0 +1,34 @@
import type { NextApiRequest, NextApiResponse } from "next";
import verifyByCredentials from "@/lib/api/verifyByCredentials";
import createSession from "@/lib/api/controllers/session/createSession";
import { PostSessionSchema } from "@/lib/shared/schemaValidation";
export default async function session(
req: NextApiRequest,
res: NextApiResponse
) {
const dataValidation = PostSessionSchema.safeParse(req.body);
if (!dataValidation.success) {
return res.status(400).json({
response: `Error: ${
dataValidation.error.issues[0].message
} [${dataValidation.error.issues[0].path.join(", ")}]`,
});
}
const { username, password, sessionName } = dataValidation.data;
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 });
}
}
+17
View File
@@ -9,10 +9,27 @@ export default async function tags(req: NextApiRequest, res: NextApiResponse) {
const tagId = Number(req.query.id);
if (!tagId)
return res.status(400).json({
response: "Please choose a valid name for the tag.",
});
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 });
}
+6
View File
@@ -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 });
}
+7 -1
View File
@@ -11,7 +11,13 @@ export default async function tokens(
if (!user) return;
if (req.method === "POST") {
const token = await postToken(JSON.parse(req.body), user.id);
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(req.body, user.id);
return res.status(token.status).json({ response: token.response });
} else if (req.method === "GET") {
const token = await getTokens(user.id);
+23 -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 = 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 });
}
}
+17 -1
View File
@@ -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 });
}
}