mirror of
https://github.com/linkwarden/linkwarden.git
synced 2026-03-03 02:27:00 +00:00
feat(web): add singlefile upload route
This commit is contained in:
234
apps/web/pages/api/v1/archives/index.ts
Normal file
234
apps/web/pages/api/v1/archives/index.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import formidable from "formidable";
|
||||
import fs from "fs";
|
||||
import { createFile, createFolder } from "@linkwarden/filesystem";
|
||||
import { generatePreview } from "@linkwarden/lib";
|
||||
import verifyUser from "@/lib/api/verifyUser";
|
||||
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 setCollection from "@/lib/api/setCollection";
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: false,
|
||||
},
|
||||
};
|
||||
|
||||
/** ------------------ */
|
||||
/** Helper Functions */
|
||||
/** ------------------ */
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
/** ------------------ */
|
||||
/** Route Handlers */
|
||||
/** ------------------ */
|
||||
|
||||
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 format = Number(req.query.format);
|
||||
const suffix = getSuffixFromFormat(format);
|
||||
const isPreview = Boolean(req.query.preview);
|
||||
|
||||
if (!suffix) {
|
||||
return res.status(401).json({ response: "Missing format" });
|
||||
}
|
||||
|
||||
// Verify user and collection permissions
|
||||
const user = await verifyUser({ req, res });
|
||||
if (!user) return; // verifyUser already handles the response on failure
|
||||
|
||||
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.`
|
||||
);
|
||||
}
|
||||
|
||||
const url = typeof fields.url === "string" ? fields.url : fields.url?.[0];
|
||||
|
||||
// Validate input against Zod schema
|
||||
const dataValidation = UploadFileSchema.safeParse({
|
||||
format,
|
||||
file: files.file,
|
||||
url,
|
||||
});
|
||||
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
|
||||
);
|
||||
|
||||
const collection = await setCollection({
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
if (!collection) {
|
||||
throw new Error("Collection not found.");
|
||||
}
|
||||
|
||||
const link = await prisma.link.create({
|
||||
data: {
|
||||
createdBy: {
|
||||
connect: {
|
||||
id: user.id,
|
||||
},
|
||||
},
|
||||
collection: {
|
||||
connect: {
|
||||
id: collection.id,
|
||||
},
|
||||
},
|
||||
url,
|
||||
},
|
||||
});
|
||||
|
||||
// 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 = collection.id;
|
||||
createFolder({ filePath: `archives/preview/${collectionId}` });
|
||||
await generatePreview(fileBuffer, collectionId, link.id);
|
||||
}
|
||||
|
||||
if (!isPreview) {
|
||||
// Store the file
|
||||
await createFile({
|
||||
filePath: `archives/${collection.id}/${link.id + suffix}`,
|
||||
data: fileBuffer,
|
||||
});
|
||||
}
|
||||
|
||||
// Update link in DB
|
||||
const updateLink = await prisma.link.update({
|
||||
where: { id: link.id },
|
||||
data: {
|
||||
preview: isPDF ? "unavailable" : undefined,
|
||||
image:
|
||||
isImage && !isPreview
|
||||
? `archives/${collection.id}/${link.id + suffix}`
|
||||
: undefined,
|
||||
pdf: isPDF
|
||||
? `archives/${collection.id}/${link.id + suffix}`
|
||||
: undefined,
|
||||
monolith:
|
||||
isHTML && !isPreview
|
||||
? `archives/${collection.id}/${link.id + 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 "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 });
|
||||
}
|
||||
}
|
||||
@@ -46,12 +46,12 @@ export const PostUserSchema = () => {
|
||||
username: emailEnabled
|
||||
? z.string().optional()
|
||||
: z
|
||||
.string()
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.min(3)
|
||||
.max(50)
|
||||
.regex(/^[a-z0-9_-]{3,50}$/),
|
||||
.string()
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.min(3)
|
||||
.max(50)
|
||||
.regex(/^[a-z0-9_-]{3,50}$/),
|
||||
invite: z.boolean().optional(),
|
||||
});
|
||||
};
|
||||
@@ -202,7 +202,8 @@ export const UploadFileSchema = z.object({
|
||||
(files) => ACCEPTED_TYPES.includes(files?.[0]?.mimetype),
|
||||
`Only ${ACCEPTED_TYPES.join(", ")} files are accepted.`
|
||||
),
|
||||
id: z.number(),
|
||||
id: z.number().optional(),
|
||||
url: z.string().trim().max(2048).url().optional(),
|
||||
format: z.nativeEnum(ArchivedFormat),
|
||||
});
|
||||
|
||||
@@ -294,6 +295,8 @@ export const UpdateDashboardLayoutSchema = z.array(
|
||||
enabled: z.boolean(),
|
||||
order: z.number().optional(),
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
export type UpdateDashboardLayoutSchemaType = z.infer<typeof UpdateDashboardLayoutSchema>;
|
||||
export type UpdateDashboardLayoutSchemaType = z.infer<
|
||||
typeof UpdateDashboardLayoutSchema
|
||||
>;
|
||||
|
||||
Reference in New Issue
Block a user