Files
linkwarden/apps/web/pages/api/v1/archives/[linkId].ts
Daniel 389e5df117 Chore/tech debts (#1536)
* build(deps): bump the npm_and_yarn group across 5 directories with 22 updates

Bumps the npm_and_yarn group with 18 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [axios](https://github.com/axios/axios) | `1.5.1` | `1.12.0` |
| [dompurify](https://github.com/cure53/DOMPurify) | `3.0.6` | `3.2.4` |
| [formidable](https://github.com/node-formidable/formidable) | `3.5.1` | `3.5.4` |
| [next](https://github.com/vercel/next.js) | `13.4.12` | `14.2.35` |
| [next-auth](https://github.com/nextauthjs/next-auth) | `4.22.1` | `4.24.12` |
| [playwright](https://github.com/microsoft/playwright) | `1.55.0` | `1.55.1` |
| [@mozilla/readability](https://github.com/mozilla/readability) | `0.4.4` | `0.6.0` |
| [ai](https://github.com/vercel/ai) | `4.3.9` | `5.0.52` |
| [nodemailer](https://github.com/nodemailer/nodemailer) | `6.9.3` | `7.0.11` |
| [brace-expansion](https://github.com/juliangruber/brace-expansion) | `1.1.11` | `1.1.12` |
| [braces](https://github.com/micromatch/braces) | `3.0.2` | `3.0.3` |
| [form-data](https://github.com/form-data/form-data) | `3.0.3` | `3.0.4` |
| [js-yaml](https://github.com/nodeca/js-yaml) | `3.14.1` | `3.14.2` |
| [micromatch](https://github.com/micromatch/micromatch) | `4.0.5` | `4.0.8` |
| [min-document](https://github.com/Raynos/min-document) | `2.19.0` | `2.19.2` |
| [nanoid](https://github.com/ai/nanoid) | `3.3.6` | `3.3.8` |
| [node-forge](https://github.com/digitalbazaar/forge) | `1.3.1` | `1.3.3` |
| [tar](https://github.com/isaacs/node-tar) | `6.1.13` | `6.2.1` |

Bumps the npm_and_yarn group with 1 update in the /apps/web directory: [next](https://github.com/vercel/next.js).
Bumps the npm_and_yarn group with 2 updates in the /apps/worker directory: [@mozilla/readability](https://github.com/mozilla/readability) and [ai](https://github.com/vercel/ai).
Bumps the npm_and_yarn group with 1 update in the /packages/lib directory: [nodemailer](https://github.com/nodemailer/nodemailer).
Bumps the npm_and_yarn group with 1 update in the /packages/router directory: [next](https://github.com/vercel/next.js).


Updates `axios` from 1.5.1 to 1.12.0
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.5.1...v1.12.0)

Updates `dompurify` from 3.0.6 to 3.2.4
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/3.0.6...3.2.4)

Updates `formidable` from 3.5.1 to 3.5.4
- [Release notes](https://github.com/node-formidable/formidable/releases)
- [Changelog](https://github.com/node-formidable/formidable/blob/master/CHANGELOG.md)
- [Commits](https://github.com/node-formidable/formidable/commits)

Updates `next` from 13.4.12 to 14.2.35
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v13.4.12...v14.2.35)

Updates `next-auth` from 4.22.1 to 4.24.12
- [Release notes](https://github.com/nextauthjs/next-auth/releases)
- [Commits](https://github.com/nextauthjs/next-auth/compare/next-auth@4.22.1...next-auth@4.24.12)

Updates `playwright` from 1.55.0 to 1.55.1
- [Release notes](https://github.com/microsoft/playwright/releases)
- [Commits](https://github.com/microsoft/playwright/compare/v1.55.0...v1.55.1)

Updates `postcss` from 8.4.26 to 8.5.3
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.4.26...8.5.3)

Updates `@mozilla/readability` from 0.4.4 to 0.6.0
- [Changelog](https://github.com/mozilla/readability/blob/main/CHANGELOG.md)
- [Commits](https://github.com/mozilla/readability/compare/0.4.4...0.6.0)

Updates `ai` from 4.3.9 to 5.0.52
- [Release notes](https://github.com/vercel/ai/releases)
- [Changelog](https://github.com/vercel/ai/blob/main/CHANGELOG.md)
- [Commits](https://github.com/vercel/ai/compare/ai@4.3.9...ai@5.0.52)

Updates `nodemailer` from 6.9.3 to 7.0.11
- [Release notes](https://github.com/nodemailer/nodemailer/releases)
- [Changelog](https://github.com/nodemailer/nodemailer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodemailer/nodemailer/compare/v6.9.3...v7.0.11)

Updates `@babel/runtime` from 7.21.5 to 7.27.0
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.27.0/packages/babel-runtime)

Updates `brace-expansion` from 1.1.11 to 1.1.12
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/1.1.11...v1.1.12)

Updates `braces` from 3.0.2 to 3.0.3
- [Changelog](https://github.com/micromatch/braces/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/braces/compare/3.0.2...3.0.3)

Updates `follow-redirects` from 1.15.3 to 1.15.11
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.15.3...v1.15.11)

Updates `form-data` from 3.0.3 to 3.0.4
- [Release notes](https://github.com/form-data/form-data/releases)
- [Changelog](https://github.com/form-data/form-data/blob/master/CHANGELOG.md)
- [Commits](https://github.com/form-data/form-data/compare/v3.0.3...v3.0.4)

Updates `jose` from 4.14.4 to 4.15.9
- [Release notes](https://github.com/panva/jose/releases)
- [Changelog](https://github.com/panva/jose/blob/v4.15.9/CHANGELOG.md)
- [Commits](https://github.com/panva/jose/compare/v4.14.4...v4.15.9)

Updates `js-yaml` from 3.14.1 to 3.14.2
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/3.14.1...3.14.2)

Updates `micromatch` from 4.0.5 to 4.0.8
- [Release notes](https://github.com/micromatch/micromatch/releases)
- [Changelog](https://github.com/micromatch/micromatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/micromatch/compare/4.0.5...4.0.8)

Updates `min-document` from 2.19.0 to 2.19.2
- [Commits](https://github.com/Raynos/min-document/compare/v2.19.0...v2.19.2)

Updates `nanoid` from 3.3.6 to 3.3.8
- [Release notes](https://github.com/ai/nanoid/releases)
- [Changelog](https://github.com/ai/nanoid/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ai/nanoid/compare/3.3.6...3.3.8)

Updates `node-forge` from 1.3.1 to 1.3.3
- [Changelog](https://github.com/digitalbazaar/forge/blob/main/CHANGELOG.md)
- [Commits](https://github.com/digitalbazaar/forge/compare/v1.3.1...v1.3.3)

Updates `tar` from 6.1.13 to 6.2.1
- [Release notes](https://github.com/isaacs/node-tar/releases)
- [Changelog](https://github.com/isaacs/node-tar/blob/main/CHANGELOG.md)
- [Commits](https://github.com/isaacs/node-tar/compare/v6.1.13...v6.2.1)

Updates `next` from 13.4.12 to 14.2.35
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v13.4.12...v14.2.35)

Updates `@mozilla/readability` from 0.4.4 to 0.6.0
- [Changelog](https://github.com/mozilla/readability/blob/main/CHANGELOG.md)
- [Commits](https://github.com/mozilla/readability/compare/0.4.4...0.6.0)

Updates `ai` from 4.3.19 to 5.0.113
- [Release notes](https://github.com/vercel/ai/releases)
- [Changelog](https://github.com/vercel/ai/blob/main/CHANGELOG.md)
- [Commits](https://github.com/vercel/ai/compare/ai@4.3.9...ai@5.0.52)

Updates `nodemailer` from 6.10.1 to 7.0.11
- [Release notes](https://github.com/nodemailer/nodemailer/releases)
- [Changelog](https://github.com/nodemailer/nodemailer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodemailer/nodemailer/compare/v6.9.3...v7.0.11)

Updates `next` from 13.4.12 to 14.2.35
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v13.4.12...v14.2.35)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.12.0
  dependency-type: direct:production
  dependency-group: npm_and_yarn
- dependency-name: dompurify
  dependency-version: 3.2.4
  dependency-type: direct:production
  dependency-group: npm_and_yarn
- dependency-name: formidable
  dependency-version: 3.5.4
  dependency-type: direct:production
  dependency-group: npm_and_yarn
- dependency-name: next
  dependency-version: 14.2.35
  dependency-type: direct:production
  dependency-group: npm_and_yarn
- dependency-name: next-auth
  dependency-version: 4.24.12
  dependency-type: direct:production
  dependency-group: npm_and_yarn
- dependency-name: playwright
  dependency-version: 1.55.1
  dependency-type: direct:production
  dependency-group: npm_and_yarn
- dependency-name: postcss
  dependency-version: 8.5.3
  dependency-type: direct:development
  dependency-group: npm_and_yarn
- dependency-name: "@mozilla/readability"
  dependency-version: 0.6.0
  dependency-type: direct:production
  dependency-group: npm_and_yarn
- dependency-name: ai
  dependency-version: 5.0.52
  dependency-type: direct:production
  dependency-group: npm_and_yarn
- dependency-name: nodemailer
  dependency-version: 7.0.11
  dependency-type: direct:production
  dependency-group: npm_and_yarn
- dependency-name: "@babel/runtime"
  dependency-version: 7.27.0
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: brace-expansion
  dependency-version: 1.1.12
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: braces
  dependency-version: 3.0.3
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: follow-redirects
  dependency-version: 1.15.11
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: form-data
  dependency-version: 3.0.4
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: jose
  dependency-version: 4.15.9
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: js-yaml
  dependency-version: 3.14.2
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: micromatch
  dependency-version: 4.0.8
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: min-document
  dependency-version: 2.19.2
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: nanoid
  dependency-version: 3.3.8
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: node-forge
  dependency-version: 1.3.3
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: tar
  dependency-version: 6.2.1
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: next
  dependency-version: 14.2.35
  dependency-type: direct:production
  dependency-group: npm_and_yarn
- dependency-name: "@mozilla/readability"
  dependency-version: 0.6.0
  dependency-type: direct:production
  dependency-group: npm_and_yarn
- dependency-name: ai
  dependency-version: 5.0.113
  dependency-type: direct:production
  dependency-group: npm_and_yarn
- dependency-name: nodemailer
  dependency-version: 7.0.11
  dependency-type: direct:production
  dependency-group: npm_and_yarn
- dependency-name: next
  dependency-version: 14.2.35
  dependency-type: direct:production
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>

* bug fixes and improvements

* always show navbar in reader view

* bug fix and small performance improvement

* minor fix

* Refactor link selection management and bulk actions

- Replaced the use of selectedLinks with selectedIds in the link store for better performance and clarity.
- Updated LinkListOptions, BulkDeleteLinksModal, and BulkEditLinksModal components to utilize the new selection management.
- Modified LinkCard, LinkMasonry, and LinkList components to handle selection state through props.
- Enhanced updateLinks API to support bulk updates with improved tag management.
- Cleaned up unused imports and code related to previous selection methods.

* move refetching logic to Links component

* move disableDraggable and user hook out of each card to improve efficiency

* cleaner code

* memoize components and increase performance

* fix: update announcement links to use the correct domain

* feat: add favicon field to Link model + update packages + bug fix

* feat: implement favicon fetching API and update Link model for favicon support

* feat: add priority attribute to Image components in Sidebar

* Refactor pages to use consistent layout handling (yes, I forgot to do that until now :P)

* bump version

* Refactor setting pages to use consistent layout handling

* upgrade yarn to 4.12.0

* fix DnD bug

* Enhance announcement handling by adding support for announcement messages

* slimmed down the docker image size

* update Node and yarn versions in playwright tests workflow

* small fix

* fix attempt 2

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-21 18:09:05 -05:00

324 lines
9.1 KiB
TypeScript

import type { NextApiRequest, NextApiResponse } from "next";
import formidable from "formidable";
import fs from "fs";
import { readFile, createFile, createFolder } from "@linkwarden/filesystem";
import { generatePreview } from "@linkwarden/lib";
import verifyToken from "@/lib/api/verifyToken";
import verifyUser from "@/lib/api/verifyUser";
import getPermission from "@/lib/api/getPermission";
import { prisma } from "@linkwarden/prisma";
import { UsersAndCollections } from "@linkwarden/prisma/client";
import { UploadFileSchema } from "@linkwarden/lib/schemaValidation";
import isDemoMode from "@/lib/api/isDemoMode";
import getSuffixFromFormat from "@/lib/shared/getSuffixFromFormat";
import { ArchivedFormat } from "@linkwarden/types";
export const config = {
api: {
bodyParser: false,
responseLimit: "50mb",
},
};
/** ------------------ */
/** Helper Functions */
/** ------------------ */
// Check whether user can create in the given collection
function userHasCreatePermission(
collectionPermissions: any,
userId: number
): boolean {
if (!collectionPermissions) return false;
const memberHasAccess = collectionPermissions.members.some(
(e: UsersAndCollections) => e.userId === userId && e.canCreate
);
return collectionPermissions.ownerId === userId || Boolean(memberHasAccess);
}
// Ensure user does not exceed maximum link limit
async function verifyLinkLimit(userId: number) {
const MAX_LINKS_PER_USER = Number(process.env.MAX_LINKS_PER_USER || 30000);
const userLinkCount = await prisma.link.count({
where: {
collection: {
ownerId: userId,
},
},
});
if (userLinkCount > MAX_LINKS_PER_USER) {
throw new Error(
`Each collection owner can only have a maximum of ${MAX_LINKS_PER_USER} Links.`
);
}
}
// Common validation for file size and type
function validateFile(
file: formidable.File,
maxMB: number,
allowedTypes: string[]
) {
if (!file || !allowedTypes.includes(file.mimetype || "")) {
throw new Error(
`Sorry, we couldn't process your file. Please ensure it's in [${allowedTypes.join(
", "
)}] format and doesn't exceed ${maxMB}MB.`
);
}
const fileBuffer = fs.readFileSync(file.filepath);
if (Buffer.byteLength(fileBuffer as any) > 1024 * 1024 * maxMB) {
throw new Error(
`Sorry, we couldn't process your file. Please ensure it doesn't exceed ${maxMB}MB.`
);
}
return fileBuffer;
}
// Retrieve collection if accessible (for both GET and file uploads/updates)
async function getAccessibleCollection(
linkId: number,
userId: number | undefined
) {
return prisma.collection.findFirst({
where: {
links: {
some: {
id: linkId,
},
},
OR: [
{ ownerId: userId || -1 },
{ members: { some: { userId: userId || -1 } } },
{ isPublic: true },
],
},
});
}
/** ------------------ */
/** Route Handlers */
/** ------------------ */
async function handleGet(req: NextApiRequest, res: NextApiResponse) {
const linkId = Number(req.query.linkId);
const format = Number(req.query.format);
const isPreview = Boolean(req.query.preview);
const suffix = getSuffixFromFormat(format);
if (!linkId || !suffix) {
return res.status(401).json({ response: "Invalid parameters." });
}
// Verify token => If present, get user ID
const token = await verifyToken({ req });
const userId = typeof token === "string" ? undefined : token?.id;
// Check if user can access the collection
const collection = await getAccessibleCollection(linkId, userId);
if (!collection) {
return res
.status(401)
.json({ response: "You don't have access to this collection." });
}
// Decide the file path
const filePath = isPreview
? `archives/preview/${collection.id}/${linkId}.jpeg`
: `archives/${collection.id}/${linkId + suffix}`;
const { file, contentType, status } = await readFile(filePath);
res
.setHeader("Content-Type", contentType)
.setHeader("Cache-Control", "private, max-age=31536000, immutable")
.status(status as number);
return res.send(file);
}
async function handlePost(req: NextApiRequest, res: NextApiResponse) {
if (isDemoMode()) {
return res.status(400).json({
response:
"This action is disabled because this is a read-only demo of Linkwarden.",
});
}
const linkId = Number(req.query.linkId);
const format = Number(req.query.format);
const suffix = getSuffixFromFormat(format);
const isPreview = Boolean(req.query.preview);
if (!linkId || !suffix) {
return res.status(401).json({ response: "Invalid parameters." });
}
// Verify user and collection permissions
const user = await verifyUser({ req, res });
if (!user) return; // verifyUser already handles the response on failure
const collectionPermissions = await getPermission({
userId: user.id,
linkId,
});
if (
!userHasCreatePermission(collectionPermissions, user.id) ||
!collectionPermissions
) {
return res.status(400).json({ response: "Collection is not accessible." });
}
const link = await prisma.link.findUnique({
where: {
id: linkId,
},
});
if (!link) {
return res.status(400).json({ response: "Link not found." });
}
// Ensure if the png already exists, the user is not trying to upload a jpeg (and vice versa)
if (
((link.image?.endsWith("jpeg") && format === ArchivedFormat.png) ||
(link.image?.endsWith("png") && format === ArchivedFormat.jpeg)) &&
!isPreview
) {
return res
.status(400)
.json({ response: "PNG or JPEG file already exists." });
}
try {
await verifyLinkLimit(user.id);
} catch (err: any) {
return res.status(400).json({ response: err.message });
}
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) => {
try {
if (err || !files.file || !files.file[0]) {
throw new Error(
`Sorry, we couldn't process your file. Please ensure it doesn't exceed ${NEXT_PUBLIC_MAX_FILE_BUFFER}MB.`
);
}
// Validate input against Zod schema
const dataValidation = UploadFileSchema.safeParse({
id: linkId,
format,
file: files.file,
});
if (!dataValidation.success) {
const issue = dataValidation.error.issues[0];
throw new Error(`Error: ${issue.message} [${issue.path.join(", ")}]`);
}
// Check file type and size
const allowedMIMETypes = [
"application/pdf",
"image/png",
"image/jpg",
"image/jpeg",
"text/html",
];
const fileBuffer = validateFile(
files.file[0],
NEXT_PUBLIC_MAX_FILE_BUFFER,
allowedMIMETypes
);
// Confirm the link still exists
const linkStillExists = await prisma.link.findUnique({
where: { id: linkId },
});
if (!linkStillExists) {
throw new Error("Link not found.");
}
// Generate a preview if it's an image
const { mimetype } = files.file[0];
const isPDF = mimetype?.includes("pdf");
const isImage = mimetype?.includes("image");
const isHTML = mimetype === "text/html";
if (isImage) {
const collectionId = collectionPermissions.id;
createFolder({ filePath: `archives/preview/${collectionId}` });
await generatePreview(fileBuffer, collectionId, linkId);
}
if (!isPreview) {
// Store the file
await createFile({
filePath: `archives/${collectionPermissions.id}/${linkId + suffix}`,
data: fileBuffer,
});
}
// Update link in DB
const updateLink = await prisma.link.update({
where: { id: linkId },
data: {
preview: isPDF ? "unavailable" : undefined,
image:
isImage && !isPreview
? `archives/${collectionPermissions.id}/${linkId + suffix}`
: undefined,
pdf: isPDF
? `archives/${collectionPermissions.id}/${linkId + suffix}`
: undefined,
monolith:
isHTML && !isPreview
? `archives/${collectionPermissions.id}/${linkId + suffix}`
: undefined,
clientSide: true,
updatedAt: new Date().toISOString(),
},
});
// Clean up temporary file
fs.unlinkSync(files.file[0].filepath);
return res.status(200).json({ response: updateLink });
} catch (error: any) {
return res.status(400).json({ response: error.message });
}
});
}
/** ------------------ */
/** Main API Handler */
/** ------------------ */
export default async function Index(req: NextApiRequest, res: NextApiResponse) {
const method = req.method;
try {
switch (method) {
case "GET":
await handleGet(req, res);
break;
case "POST":
await handlePost(req, res);
break;
default:
return res.status(405).json({ response: "Method not allowed" });
}
} catch (error: any) {
return res.status(400).json({ response: error.message });
}
}