Merge pull request #556 from linkwarden/feat/file-uploads

Feat/file uploads
This commit is contained in:
Daniel 2024-04-09 03:08:11 +03:30 committed by GitHub
commit 68d8d403cf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 468 additions and 324 deletions

View File

@ -19,6 +19,7 @@ import { generateLinkHref } from "@/lib/client/generateLinkHref";
import useAccountStore from "@/store/account"; import useAccountStore from "@/store/account";
import usePermissions from "@/hooks/usePermissions"; import usePermissions from "@/hooks/usePermissions";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import LinkTypeBadge from "./LinkComponents/LinkTypeBadge";
type Props = { type Props = {
link: LinkIncludingShortenedCollectionAndTags; link: LinkIncludingShortenedCollectionAndTags;
@ -53,7 +54,9 @@ export default function LinkCard({ link, flipDropdown, editMode }: Props) {
let shortendURL; let shortendURL;
try { try {
shortendURL = new URL(link.url || "").host.toLowerCase(); if (link.url) {
shortendURL = new URL(link.url).host.toLowerCase();
}
} catch (error) { } catch (error) {
console.log(error); console.log(error);
} }
@ -109,7 +112,6 @@ export default function LinkCard({ link, flipDropdown, editMode }: Props) {
editMode && editMode &&
(permissions === true || permissions?.canCreate || permissions?.canDelete); (permissions === true || permissions?.canCreate || permissions?.canDelete);
// window.open ('www.yourdomain.com', '_ blank');
return ( return (
<div <div
ref={ref} ref={ref}
@ -162,18 +164,7 @@ export default function LinkCard({ link, flipDropdown, editMode }: Props) {
{unescapeString(link.name || link.description) || link.url} {unescapeString(link.name || link.description) || link.url}
</p> </p>
<Link <LinkTypeBadge link={link} />
href={link.url || ""}
target="_blank"
title={link.url || ""}
onClick={(e) => {
e.stopPropagation();
}}
className="flex gap-1 item-center select-none text-neutral mt-1 hover:opacity-70 duration-100"
>
<i className="bi-link-45deg text-lg mt-[0.10rem] leading-none"></i>
<p className="text-sm truncate">{shortendURL}</p>
</Link>
</div> </div>
<hr className="divider mt-2 mb-1 last:hidden border-t border-neutral-content h-[1px]" /> <hr className="divider mt-2 mb-1 last:hidden border-t border-neutral-content h-[1px]" />

View File

@ -122,6 +122,7 @@ export default function LinkActions({
</div> </div>
</li> </li>
) : undefined} ) : undefined}
{link.type === "url" && (
<li> <li>
<div <div
role="button" role="button"
@ -134,6 +135,7 @@ export default function LinkActions({
Preserved Formats Preserved Formats
</div> </div>
</li> </li>
)}
{permissions === true || permissions?.canDelete ? ( {permissions === true || permissions?.canDelete ? (
<li> <li>
<div <div

View File

@ -6,9 +6,11 @@ import React from "react";
export default function LinkIcon({ export default function LinkIcon({
link, link,
width, width,
className,
}: { }: {
link: LinkIncludingShortenedCollectionAndTags; link: LinkIncludingShortenedCollectionAndTags;
width?: string; width?: string;
className?: string;
}) { }) {
const url = const url =
isValidUrl(link.url || "") && link.url ? new URL(link.url) : undefined; isValidUrl(link.url || "") && link.url ? new URL(link.url) : undefined;
@ -16,13 +18,16 @@ export default function LinkIcon({
const iconClasses: string = const iconClasses: string =
"bg-white shadow rounded-md border-[2px] flex item-center justify-center border-white select-none z-10" + "bg-white shadow rounded-md border-[2px] flex item-center justify-center border-white select-none z-10" +
" " + " " +
(width || "w-12"); (width || "w-12") +
" " +
(className || "");
const [showFavicon, setShowFavicon] = React.useState<boolean>(true); const [showFavicon, setShowFavicon] = React.useState<boolean>(true);
return ( return (
<> <>
{link.url && url && showFavicon ? ( {link.type === "url" && url ? (
showFavicon ? (
<Image <Image
src={`https://t2.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${link.url}&size=32`} src={`https://t2.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${link.url}&size=32`}
width={64} width={64}
@ -34,15 +39,34 @@ export default function LinkIcon({
setShowFavicon(false); setShowFavicon(false);
}} }}
/> />
) : showFavicon === false ? ( ) : (
<div className={iconClasses}> <LinkPlaceholderIcon iconClasses={iconClasses} icon="bi-link-45deg" />
<i className="bi-link-45deg text-4xl text-black"></i> )
</div>
) : link.type === "pdf" ? ( ) : link.type === "pdf" ? (
<i className={`bi-file-earmark-pdf ${iconClasses}`}></i> <LinkPlaceholderIcon
iconClasses={iconClasses}
icon="bi-file-earmark-pdf"
/>
) : link.type === "image" ? ( ) : link.type === "image" ? (
<i className={`bi-file-earmark-image ${iconClasses}`}></i> <LinkPlaceholderIcon
iconClasses={iconClasses}
icon="bi-file-earmark-image"
/>
) : undefined} ) : undefined}
</> </>
); );
} }
const LinkPlaceholderIcon = ({
iconClasses,
icon,
}: {
iconClasses: string;
icon: string;
}) => {
return (
<div className={`text-4xl text-black aspect-square ${iconClasses}`}>
<i className={`${icon} m-auto`}></i>
</div>
);
};

View File

@ -0,0 +1,38 @@
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import Link from "next/link";
import React from "react";
export default function LinkTypeBadge({
link,
}: {
link: LinkIncludingShortenedCollectionAndTags;
}) {
let shortendURL;
if (link.type === "url" && link.url) {
try {
shortendURL = new URL(link.url).host.toLowerCase();
} catch (error) {
console.log(error);
}
}
return link.url && shortendURL ? (
<Link
href={link.url || ""}
target="_blank"
title={link.url || ""}
onClick={(e) => {
e.stopPropagation();
}}
className="flex gap-1 item-center select-none text-neutral mt-1 hover:opacity-70 duration-100"
>
<i className="bi-link-45deg text-lg mt-[0.1rem] leading-none"></i>
<p className="text-sm truncate">{shortendURL}</p>
</Link>
) : (
<div className="badge badge-primary badge-sm my-1 select-none">
{link.type}
</div>
);
}

View File

@ -16,6 +16,7 @@ import { generateLinkHref } from "@/lib/client/generateLinkHref";
import useAccountStore from "@/store/account"; import useAccountStore from "@/store/account";
import usePermissions from "@/hooks/usePermissions"; import usePermissions from "@/hooks/usePermissions";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import LinkTypeBadge from "./LinkComponents/LinkTypeBadge";
type Props = { type Props = {
link: LinkIncludingShortenedCollectionAndTags; link: LinkIncludingShortenedCollectionAndTags;
@ -56,14 +57,6 @@ export default function LinkCardCompact({
} }
}; };
let shortendURL;
try {
shortendURL = new URL(link.url || "").host.toLowerCase();
} catch (error) {
console.log(error);
}
const [collection, setCollection] = const [collection, setCollection] =
useState<CollectionIncludingMembersAndLinkCount>( useState<CollectionIncludingMembersAndLinkCount>(
collections.find( collections.find(
@ -130,7 +123,11 @@ export default function LinkCardCompact({
} }
> >
<div className="shrink-0"> <div className="shrink-0">
<LinkIcon link={link} width="sm:w-12 w-8 mt-1 sm:mt-0" /> <LinkIcon
link={link}
width="sm:w-12 w-8"
className="mt-1 sm:mt-0"
/>
</div> </div>
<div className="w-[calc(100%-56px)] ml-2"> <div className="w-[calc(100%-56px)] ml-2">
@ -143,24 +140,7 @@ export default function LinkCardCompact({
{collection ? ( {collection ? (
<LinkCollection link={link} collection={collection} /> <LinkCollection link={link} collection={collection} />
) : undefined} ) : undefined}
{link.url ? ( <LinkTypeBadge link={link} />
<Link
href={link.url || ""}
target="_blank"
title={link.url || ""}
onClick={(e) => {
e.stopPropagation();
}}
className="flex gap-1 item-center select-none text-neutral mt-1 hover:opacity-70 duration-100"
>
<i className="bi-link-45deg text-lg mt-[0.1rem] leading-none"></i>
<p className="text-sm truncate">{shortendURL}</p>
</Link>
) : (
<div className="badge badge-primary badge-sm my-1 select-none">
{link.type}
</div>
)}
<LinkDate link={link} /> <LinkDate link={link} />
</div> </div>
</div> </div>

View File

@ -43,7 +43,7 @@ export default function UploadFileModal({ onClose }: Props) {
const [file, setFile] = useState<File>(); const [file, setFile] = useState<File>();
const { addLink } = useLinkStore(); const { uploadFile } = useLinkStore();
const [submitLoader, setSubmitLoader] = useState(false); const [submitLoader, setSubmitLoader] = useState(false);
const [optionsExpanded, setOptionsExpanded] = useState(false); const [optionsExpanded, setOptionsExpanded] = useState(false);
@ -100,48 +100,15 @@ export default function UploadFileModal({ onClose }: Props) {
const submit = async () => { const submit = async () => {
if (!submitLoader && file) { if (!submitLoader && file) {
let fileType: ArchivedFormat | null = null;
let linkType: "url" | "image" | "pdf" | null = null;
if (file?.type === "image/jpg" || file.type === "image/jpeg") {
fileType = ArchivedFormat.jpeg;
linkType = "image";
} else if (file.type === "image/png") {
fileType = ArchivedFormat.png;
linkType = "image";
} else if (file.type === "application/pdf") {
fileType = ArchivedFormat.pdf;
linkType = "pdf";
}
if (fileType !== null && linkType !== null) {
setSubmitLoader(true); setSubmitLoader(true);
let response;
const load = toast.loading("Creating..."); const load = toast.loading("Creating...");
response = await addLink({ const response = await uploadFile(link, file);
...link,
type: linkType,
name: link.name ? link.name : file.name.replace(/\.[^/.]+$/, ""),
});
toast.dismiss(load); toast.dismiss(load);
if (response.ok) { if (response.ok) {
const formBody = new FormData();
file && formBody.append("file", file);
await fetch(
`/api/v1/archives/${
(response.data as LinkIncludingShortenedCollectionAndTags).id
}?format=${fileType}`,
{
body: formBody,
method: "POST",
}
);
toast.success(`Created!`); toast.success(`Created!`);
onClose(); onClose();
} else toast.error(response.data as string); } else toast.error(response.data as string);
@ -150,7 +117,6 @@ export default function UploadFileModal({ onClose }: Props) {
return response; return response;
} }
}
}; };
return ( return (
@ -238,7 +204,7 @@ export default function UploadFileModal({ onClose }: Props) {
className="btn btn-accent dark:border-violet-400 text-white" className="btn btn-accent dark:border-violet-400 text-white"
onClick={submit} onClick={submit}
> >
Create Link Upload File
</button> </button>
</div> </div>
</Modal> </Modal>

View File

@ -93,7 +93,7 @@ export default function Navbar() {
New Link New Link
</div> </div>
</li> </li>
{/* <li> <li>
<div <div
onClick={() => { onClick={() => {
(document?.activeElement as HTMLElement)?.blur(); (document?.activeElement as HTMLElement)?.blur();
@ -104,7 +104,7 @@ export default function Navbar() {
> >
Upload File Upload File
</div> </div>
</li> */} </li>
<li> <li>
<div <div
onClick={() => { onClick={() => {

View File

@ -7,9 +7,9 @@ import { JSDOM } from "jsdom";
import DOMPurify from "dompurify"; import DOMPurify from "dompurify";
import { Collection, Link, User } from "@prisma/client"; import { Collection, Link, User } from "@prisma/client";
import validateUrlSize from "./validateUrlSize"; import validateUrlSize from "./validateUrlSize";
import removeFile from "./storage/removeFile";
import Jimp from "jimp";
import createFolder from "./storage/createFolder"; import createFolder from "./storage/createFolder";
import generatePreview from "./generatePreview";
import { removeFiles } from "./manageLinkFiles";
type LinksAndCollectionAndOwner = Link & { type LinksAndCollectionAndOwner = Link & {
collection: Collection & { collection: Collection & {
@ -51,6 +51,14 @@ export default async function archiveHandler(link: LinksAndCollectionAndOwner) {
); );
}); });
createFolder({
filePath: `archives/preview/${link.collectionId}`,
});
createFolder({
filePath: `archives/${link.collectionId}`,
});
try { try {
await Promise.race([ await Promise.race([
(async () => { (async () => {
@ -162,10 +170,6 @@ export default async function archiveHandler(link: LinksAndCollectionAndOwner) {
return metaTag ? (metaTag as any).content : null; return metaTag ? (metaTag as any).content : null;
}); });
createFolder({
filePath: `archives/preview/${link.collectionId}`,
});
if (ogImageUrl) { if (ogImageUrl) {
console.log("Found og:image URL:", ogImageUrl); console.log("Found og:image URL:", ogImageUrl);
@ -175,35 +179,7 @@ export default async function archiveHandler(link: LinksAndCollectionAndOwner) {
// Check if imageResponse is not null // Check if imageResponse is not null
if (imageResponse && !link.preview?.startsWith("archive")) { if (imageResponse && !link.preview?.startsWith("archive")) {
const buffer = await imageResponse.body(); 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
);
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.");
}
} }
await page.goBack(); await page.goBack();
@ -323,14 +299,7 @@ export default async function archiveHandler(link: LinksAndCollectionAndOwner) {
}, },
}); });
else { else {
removeFile({ filePath: `archives/${link.collectionId}/${link.id}.png` }); await removeFiles(link.id, link.collectionId);
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 browser.close(); await browser.close();

View File

@ -2,6 +2,7 @@ import { prisma } from "@/lib/api/db";
import { UsersAndCollections } from "@prisma/client"; import { UsersAndCollections } from "@prisma/client";
import getPermission from "@/lib/api/getPermission"; import getPermission from "@/lib/api/getPermission";
import removeFile from "@/lib/api/storage/removeFile"; import removeFile from "@/lib/api/storage/removeFile";
import { removeFiles } from "@/lib/api/manageLinkFiles";
export default async function deleteLinksById( export default async function deleteLinksById(
userId: number, userId: number,
@ -43,15 +44,7 @@ export default async function deleteLinksById(
const linkId = linkIds[i]; const linkId = linkIds[i];
const collectionIsAccessible = collectionIsAccessibleArray[i]; const collectionIsAccessible = collectionIsAccessibleArray[i];
removeFile({ if (collectionIsAccessible) removeFiles(linkId, collectionIsAccessible.id);
filePath: `archives/${collectionIsAccessible?.id}/${linkId}.pdf`,
});
removeFile({
filePath: `archives/${collectionIsAccessible?.id}/${linkId}.png`,
});
removeFile({
filePath: `archives/${collectionIsAccessible?.id}/${linkId}_readability.json`,
});
} }
return { response: deletedLinks, status: 200 }; return { response: deletedLinks, status: 200 };

View File

@ -2,6 +2,7 @@ import { prisma } from "@/lib/api/db";
import { Link, UsersAndCollections } from "@prisma/client"; import { Link, UsersAndCollections } from "@prisma/client";
import getPermission from "@/lib/api/getPermission"; import getPermission from "@/lib/api/getPermission";
import removeFile from "@/lib/api/storage/removeFile"; import removeFile from "@/lib/api/storage/removeFile";
import { removeFiles } from "@/lib/api/manageLinkFiles";
export default async function deleteLink(userId: number, linkId: number) { export default async function deleteLink(userId: number, linkId: number) {
if (!linkId) return { response: "Please choose a valid link.", status: 401 }; 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 (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 }; return { response: "Collection is not accessible.", status: 401 };
const deleteLink: Link = await prisma.link.delete({ const deleteLink: Link = await prisma.link.delete({
@ -21,15 +25,7 @@ export default async function deleteLink(userId: number, linkId: number) {
}, },
}); });
removeFile({ removeFiles(linkId, collectionIsAccessible.id);
filePath: `archives/${collectionIsAccessible?.id}/${linkId}.pdf`,
});
removeFile({
filePath: `archives/${collectionIsAccessible?.id}/${linkId}.png`,
});
removeFile({
filePath: `archives/${collectionIsAccessible?.id}/${linkId}_readability.json`,
});
return { response: deleteLink, status: 200 }; return { response: deleteLink, status: 200 };
} }

View File

@ -2,7 +2,7 @@ import { prisma } from "@/lib/api/db";
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global"; import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import { UsersAndCollections } from "@prisma/client"; import { UsersAndCollections } from "@prisma/client";
import getPermission from "@/lib/api/getPermission"; import getPermission from "@/lib/api/getPermission";
import moveFile from "@/lib/api/storage/moveFile"; import { moveFiles } from "@/lib/api/manageLinkFiles";
export default async function updateLinkById( export default async function updateLinkById(
userId: number, userId: number,
@ -146,20 +146,7 @@ export default async function updateLinkById(
}); });
if (collectionIsAccessible?.id !== data.collection.id) { if (collectionIsAccessible?.id !== data.collection.id) {
await moveFile( await moveFiles(linkId, collectionIsAccessible?.id, data.collection.id);
`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`
);
} }
return { response: updatedLink, status: 200 }; return { response: updatedLink, status: 200 };

View File

@ -12,6 +12,7 @@ export default async function postLink(
link: LinkIncludingShortenedCollectionAndTags, link: LinkIncludingShortenedCollectionAndTags,
userId: number userId: number
) { ) {
if (link.url || link.type === "url") {
try { try {
new URL(link.url || ""); new URL(link.url || "");
} catch (error) { } catch (error) {
@ -21,6 +22,7 @@ export default async function postLink(
status: 400, status: 400,
}; };
} }
}
if (!link.collection.id && link.collection.name) { if (!link.collection.id && link.collection.name) {
link.collection.name = link.collection.name.trim(); link.collection.name = link.collection.name.trim();
@ -172,7 +174,7 @@ export default async function postLink(
const newLink = await prisma.link.create({ const newLink = await prisma.link.create({
data: { data: {
url: link.url?.trim(), url: link.url?.trim() || null,
name: link.name, name: link.name,
description, description,
type: linkType, type: linkType,

View File

@ -71,6 +71,10 @@ export default async function deleteUserById(
// Delete archive folders // Delete archive folders
removeFolder({ filePath: `archives/${collection.id}` }); removeFolder({ filePath: `archives/${collection.id}` });
await removeFolder({
filePath: `archives/preview/${collection.id}`,
});
} }
// Delete collections after cleaning up related data // Delete collections after cleaning up related data

View File

@ -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;

View File

@ -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 };

View File

@ -16,24 +16,30 @@ export const generateLinkHref = (
): string => { ): string => {
// Return the links href based on the account's preference // Return the links href based on the account's preference
// If the user's preference is not available, return the original link // If the user's preference is not available, return the original link
switch (account.linksRouteTo) { if (account.linksRouteTo === LinksRouteTo.ORIGINAL && link.type === "url") {
case LinksRouteTo.ORIGINAL:
return link.url || ""; return link.url || "";
case LinksRouteTo.PDF: } else if (account.linksRouteTo === LinksRouteTo.PDF || link.type === "pdf") {
if (!pdfAvailable(link)) return link.url || ""; if (!pdfAvailable(link)) return link.url || "";
return `/preserved/${link?.id}?format=${ArchivedFormat.pdf}`; return `/preserved/${link?.id}?format=${ArchivedFormat.pdf}`;
case LinksRouteTo.READABLE: } else if (
account.linksRouteTo === LinksRouteTo.READABLE &&
link.type === "url"
) {
if (!readabilityAvailable(link)) return link.url || ""; if (!readabilityAvailable(link)) return link.url || "";
return `/preserved/${link?.id}?format=${ArchivedFormat.readability}`; return `/preserved/${link?.id}?format=${ArchivedFormat.readability}`;
case LinksRouteTo.SCREENSHOT: } else if (
account.linksRouteTo === LinksRouteTo.SCREENSHOT ||
link.type === "image"
) {
console.log(link);
if (!screenshotAvailable(link)) return link.url || ""; if (!screenshotAvailable(link)) return link.url || "";
return `/preserved/${link?.id}?format=${ return `/preserved/${link?.id}?format=${
link?.image?.endsWith("png") ? ArchivedFormat.png : ArchivedFormat.jpeg link?.image?.endsWith("png") ? ArchivedFormat.png : ArchivedFormat.jpeg
}`; }`;
default: } else {
return link.url || ""; return link.url || "";
} }
}; };

View File

@ -79,7 +79,7 @@
"nodemon": "^3.0.2", "nodemon": "^3.0.2",
"postcss": "^8.4.26", "postcss": "^8.4.26",
"prettier": "3.1.1", "prettier": "3.1.1",
"prisma": "^5.1.0", "prisma": "^4.16.2",
"tailwindcss": "^3.3.3", "tailwindcss": "^3.3.3",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"typescript": "4.9.4" "typescript": "4.9.4"

View File

@ -9,6 +9,9 @@ import formidable from "formidable";
import createFile from "@/lib/api/storage/createFile"; import createFile from "@/lib/api/storage/createFile";
import fs from "fs"; import fs from "fs";
import verifyToken from "@/lib/api/verifyToken"; import verifyToken from "@/lib/api/verifyToken";
import Jimp from "jimp";
import generatePreview from "@/lib/api/generatePreview";
import createFolder from "@/lib/api/storage/createFolder";
export const config = { export const config = {
api: { api: {
@ -73,83 +76,97 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) {
return res.send(file); 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);
} }
// else if (req.method === "POST") {
// const user = await verifyUser({ req, res });
// if (!user) return;
// const collectionPermissions = await getPermission({ if (linkStillExists) {
// userId: user.id, await createFile({
// linkId, filePath: `archives/${collectionPermissions?.id}/${
// }); linkId + suffix
}`,
data: fileBuffer,
});
// const memberHasAccess = collectionPermissions?.members.some( await prisma.link.update({
// (e: UsersAndCollections) => e.userId === user.id && e.canCreate 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(),
},
});
}
// if (!(collectionPermissions?.ownerId === user.id || memberHasAccess)) fs.unlinkSync(files.file[0].filepath);
// return { response: "Collection is not accessible.", status: 401 }; }
// // await uploadHandler(linkId, ) return res.status(200).json({
response: files,
// 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,
// });
// });
// }
} }

View File

@ -2,8 +2,8 @@ import type { NextApiRequest, NextApiResponse } from "next";
import { prisma } from "@/lib/api/db"; import { prisma } from "@/lib/api/db";
import verifyUser from "@/lib/api/verifyUser"; import verifyUser from "@/lib/api/verifyUser";
import isValidUrl from "@/lib/shared/isValidUrl"; import isValidUrl from "@/lib/shared/isValidUrl";
import removeFile from "@/lib/api/storage/removeFile";
import { Collection, Link } from "@prisma/client"; import { Collection, Link } from "@prisma/client";
import { removeFiles } from "@/lib/api/manageLinkFiles";
const RE_ARCHIVE_LIMIT = Number(process.env.RE_ARCHIVE_LIMIT) || 5; const RE_ARCHIVE_LIMIT = Number(process.env.RE_ARCHIVE_LIMIT) || 5;
@ -80,16 +80,5 @@ const deleteArchivedFiles = async (link: Link & { collection: Collection }) => {
}, },
}); });
await removeFile({ await removeFiles(link.id, link.collection.id);
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`,
});
}; };

View File

@ -1,5 +1,8 @@
import { create } from "zustand"; import { create } from "zustand";
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global"; import {
ArchivedFormat,
LinkIncludingShortenedCollectionAndTags,
} from "@/types/global";
import useTagStore from "./tags"; import useTagStore from "./tags";
import useCollectionStore from "./collections"; import useCollectionStore from "./collections";
@ -19,6 +22,10 @@ type LinkStore = {
addLink: ( addLink: (
body: LinkIncludingShortenedCollectionAndTags body: LinkIncludingShortenedCollectionAndTags
) => Promise<ResponseObject>; ) => Promise<ResponseObject>;
uploadFile: (
link: LinkIncludingShortenedCollectionAndTags,
file: File
) => Promise<ResponseObject>;
getLink: (linkId: number, publicRoute?: boolean) => Promise<ResponseObject>; getLink: (linkId: number, publicRoute?: boolean) => Promise<ResponseObject>;
updateLink: ( updateLink: (
link: LinkIncludingShortenedCollectionAndTags link: LinkIncludingShortenedCollectionAndTags
@ -79,6 +86,82 @@ const useLinkStore = create<LinkStore>()((set) => ({
return { ok: response.ok, data: data.response }; return { ok: response.ok, data: data.response };
}, },
uploadFile: async (link, file) => {
let fileType: ArchivedFormat | null = null;
let linkType: "url" | "image" | "pdf" | null = null;
if (file?.type === "image/jpg" || file.type === "image/jpeg") {
fileType = ArchivedFormat.jpeg;
linkType = "image";
} else if (file.type === "image/png") {
fileType = ArchivedFormat.png;
linkType = "image";
} else if (file.type === "application/pdf") {
fileType = ArchivedFormat.pdf;
linkType = "pdf";
} else {
return { ok: false, data: "Invalid file type." };
}
const response = await fetch("/api/v1/links", {
body: JSON.stringify({
...link,
type: linkType,
name: link.name ? link.name : file.name,
}),
headers: {
"Content-Type": "application/json",
},
method: "POST",
});
const data = await response.json();
const createdLink: LinkIncludingShortenedCollectionAndTags = data.response;
console.log(data);
if (response.ok) {
const formBody = new FormData();
file && formBody.append("file", file);
await fetch(
`/api/v1/archives/${(data as any).response.id}?format=${fileType}`,
{
body: formBody,
method: "POST",
}
);
// get file extension
const extension = file.name.split(".").pop() || "";
set((state) => ({
links: [
{
...createdLink,
image:
linkType === "image"
? `archives/${createdLink.collectionId}/${
createdLink.id + extension
}`
: null,
pdf:
linkType === "pdf"
? `archives/${createdLink.collectionId}/${
createdLink.id + ".pdf"
}`
: null,
},
...state.links,
],
}));
useTagStore.getState().setTags();
useCollectionStore.getState().setCollections();
}
return { ok: response.ok, data: data.response };
},
getLink: async (linkId, publicRoute) => { getLink: async (linkId, publicRoute) => {
const path = publicRoute const path = publicRoute
? `/api/v1/public/links/${linkId}` ? `/api/v1/public/links/${linkId}`

View File

@ -1301,10 +1301,10 @@
resolved "https://registry.yarnpkg.com/@prisma/engines-version/-/engines-version-4.16.1-1.4bc8b6e1b66cb932731fb1bdbbc550d1e010de81.tgz#d3b5dcf95b6d220e258cbf6ae19b06d30a7e9f14" resolved "https://registry.yarnpkg.com/@prisma/engines-version/-/engines-version-4.16.1-1.4bc8b6e1b66cb932731fb1bdbbc550d1e010de81.tgz#d3b5dcf95b6d220e258cbf6ae19b06d30a7e9f14"
integrity sha512-q617EUWfRIDTriWADZ4YiWRZXCa/WuhNgLTVd+HqWLffjMSPzyM5uOWoauX91wvQClSKZU4pzI4JJLQ9Kl62Qg== integrity sha512-q617EUWfRIDTriWADZ4YiWRZXCa/WuhNgLTVd+HqWLffjMSPzyM5uOWoauX91wvQClSKZU4pzI4JJLQ9Kl62Qg==
"@prisma/engines@5.1.0": "@prisma/engines@4.16.2":
version "5.1.0" version "4.16.2"
resolved "https://registry.yarnpkg.com/@prisma/engines/-/engines-5.1.0.tgz#4ccf7f344eaeee08ca1e4a1bb2dc14e36ff1d5ec" resolved "https://registry.yarnpkg.com/@prisma/engines/-/engines-4.16.2.tgz#5ec8dd672c2173d597e469194916ad4826ce2e5f"
integrity sha512-HqaFsnPmZOdMWkPq6tT2eTVTQyaAXEDdKszcZ4yc7DGMBIYRP6j/zAJTtZUG9SsMV8FaucdL5vRyxY/p5Ni28g== integrity sha512-vx1nxVvN4QeT/cepQce68deh/Turxy5Mr+4L4zClFuK1GlxN3+ivxfuv+ej/gvidWn1cE1uAhW7ALLNlYbRUAw==
"@radix-ui/primitive@1.0.1": "@radix-ui/primitive@1.0.1":
version "1.0.1" version "1.0.1"
@ -5038,12 +5038,12 @@ pretty-format@^3.8.0:
resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-3.8.0.tgz#bfbed56d5e9a776645f4b1ff7aa1a3ac4fa3c385" resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-3.8.0.tgz#bfbed56d5e9a776645f4b1ff7aa1a3ac4fa3c385"
integrity sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew== integrity sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==
prisma@^5.1.0: prisma@^4.16.2:
version "5.1.0" version "4.16.2"
resolved "https://registry.yarnpkg.com/prisma/-/prisma-5.1.0.tgz#29e316b54844f5694a83017a9781a6d6f7cb99ea" resolved "https://registry.yarnpkg.com/prisma/-/prisma-4.16.2.tgz#469e0a0991c6ae5bcde289401726bb012253339e"
integrity sha512-wkXvh+6wxk03G8qwpZMOed4Y3j+EQ+bMTlvbDZHeal6k1E8QuGKzRO7DRXlE1NV0WNgOAas8kwZqcLETQ2+BiQ== integrity sha512-SYCsBvDf0/7XSJyf2cHTLjLeTLVXYfqp7pG5eEVafFLeT0u/hLFz/9W196nDRGUOo1JfPatAEb+uEnTQImQC1g==
dependencies: dependencies:
"@prisma/engines" "5.1.0" "@prisma/engines" "4.16.2"
process@^0.11.10: process@^0.11.10:
version "0.11.10" version "0.11.10"