rearchive protection

This commit is contained in:
daniel31x13 2023-10-31 15:44:58 -04:00
parent ccafc997fc
commit 56a281ae3d
14 changed files with 246 additions and 132 deletions

View File

@ -9,6 +9,7 @@ STORAGE_FOLDER=
AUTOSCROLL_TIMEOUT= AUTOSCROLL_TIMEOUT=
NEXT_PUBLIC_DISABLE_REGISTRATION= NEXT_PUBLIC_DISABLE_REGISTRATION=
IMPORT_SIZE_LIMIT= IMPORT_SIZE_LIMIT=
RE_ARCHIVE_LIMIT=
# AWS S3 Settings # AWS S3 Settings
SPACES_KEY= SPACES_KEY=

View File

@ -21,7 +21,6 @@ type Props = {
items: MenuItem[]; items: MenuItem[];
points?: { x: number; y: number }; points?: { x: number; y: number };
style?: React.CSSProperties; style?: React.CSSProperties;
width?: number; // in rem
}; };
export default function Dropdown({ export default function Dropdown({
@ -29,7 +28,6 @@ export default function Dropdown({
onClickOutside, onClickOutside,
className, className,
items, items,
width,
}: Props) { }: Props) {
const [pos, setPos] = useState<{ x: number; y: number }>(); const [pos, setPos] = useState<{ x: number; y: number }>();
const [dropdownHeight, setDropdownHeight] = useState<number>(); const [dropdownHeight, setDropdownHeight] = useState<number>();
@ -60,7 +58,7 @@ export default function Dropdown({
setPos({ x: finalX, y: finalY }); setPos({ x: finalX, y: finalY });
} }
}, [points, width, dropdownHeight]); }, [points, dropdownHeight]);
return ( return (
(!points || pos) && ( (!points || pos) && (

View File

@ -254,7 +254,7 @@ export default function LinkCard({ link, count, className }: Props) {
: undefined, : undefined,
permissions === true permissions === true
? { ? {
name: "Update Archive", name: "Refresh Formats",
onClick: updateArchive, onClick: updateArchive,
} }
: undefined, : undefined,

View File

@ -55,7 +55,7 @@ export default function PreservedFormats() {
if (response.ok) { if (response.ok) {
toast.success(`Link is being archived...`); toast.success(`Link is being archived...`);
getLink(link?.id as number); getLink(link?.id as number);
} else toast.error(data); } else toast.error(data.response);
}; };
const handleDownload = (format: "png" | "pdf") => { const handleDownload = (format: "png" | "pdf") => {
@ -152,11 +152,18 @@ export default function PreservedFormats() {
<div className="flex flex-col-reverse sm:flex-row gap-5 items-center justify-center"> <div className="flex flex-col-reverse sm:flex-row gap-5 items-center justify-center">
{link?.collection.ownerId === session.data?.user.id ? ( {link?.collection.ownerId === session.data?.user.id ? (
<div <div
className="w-full text-center bg-sky-600 p-1 rounded-md cursor-pointer select-none mt-3" className={`w-full text-center bg-sky-700 p-1 rounded-md cursor-pointer select-none hover:bg-sky-600 duration-100 ${
link?.pdfPath &&
link?.screenshotPath &&
link?.pdfPath !== "pending" &&
link?.screenshotPath !== "pending"
? "mt-3"
: ""
}`}
onClick={() => updateArchive()} onClick={() => updateArchive()}
> >
<p>Update Preserved Formats</p> <p>Update Preserved Formats</p>
<p className="text-xs">(re-fetch)</p> <p className="text-xs">(Refresh Formats)</p>
</div> </div>
) : undefined} ) : undefined}
<Link <Link
@ -165,7 +172,14 @@ export default function PreservedFormats() {
"" ""
)}`} )}`}
target="_blank" target="_blank"
className="sm:mt-3 text-gray-500 dark:text-gray-300 duration-100 hover:opacity-60 flex gap-2 w-fit items-center text-sm" className={`text-gray-500 dark:text-gray-300 duration-100 hover:opacity-60 flex gap-2 w-fit items-center text-sm ${
link?.pdfPath &&
link?.screenshotPath &&
link?.pdfPath !== "pending" &&
link?.screenshotPath !== "pending"
? "sm:mt-3"
: ""
}`}
> >
<FontAwesomeIcon <FontAwesomeIcon
icon={faArrowUpRightFromSquare} icon={faArrowUpRightFromSquare}

View File

@ -30,11 +30,15 @@ export default function LinkModal({
}: Props) { }: Props) {
return ( return (
<div className={className}> <div className={className}>
{/* {method === "CREATE" && ( {method === "CREATE" ? (
<p className="text-xl text-black dark:text-white text-center"> <>
New Link <p className="ml-10 mt-[0.1rem] text-xl mb-3 font-thin">
Create a New Link
</p> </p>
)} */} <AddOrEditLink toggleLinkModal={toggleLinkModal} method="CREATE" />
</>
) : undefined}
{activeLink && method === "UPDATE" ? ( {activeLink && method === "UPDATE" ? (
<> <>
<p className="ml-10 mt-[0.1rem] text-xl mb-3 font-thin">Edit Link</p> <p className="ml-10 mt-[0.1rem] text-xl mb-3 font-thin">Edit Link</p>
@ -46,19 +50,10 @@ export default function LinkModal({
</> </>
) : undefined} ) : undefined}
{method === "CREATE" ? ( {method === "FORMATS" ? (
<> <>
<p className="ml-10 mt-[0.1rem] text-xl mb-3 font-thin"> <p className="ml-10 mt-[0.1rem] text-xl mb-3 font-thin">
Create a New Link Preserved Formats
</p>
<AddOrEditLink toggleLinkModal={toggleLinkModal} method="CREATE" />
</>
) : undefined}
{activeLink && method === "FORMATS" ? (
<>
<p className="ml-10 mt-[0.1rem] text-xl mb-3 font-thin">
Manage Preserved Formats
</p> </p>
<PreservedFormats /> <PreservedFormats />
</> </>

View File

@ -8,6 +8,18 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faBars, faChevronLeft } from "@fortawesome/free-solid-svg-icons"; import { faBars, faChevronLeft } from "@fortawesome/free-solid-svg-icons";
import Link from "next/link"; import Link from "next/link";
import useWindowDimensions from "@/hooks/useWindowDimensions"; import useWindowDimensions from "@/hooks/useWindowDimensions";
import {
faPen,
faBoxesStacked,
faTrashCan,
} from "@fortawesome/free-solid-svg-icons";
import useLinkStore from "@/store/links";
import {
CollectionIncludingMembersAndLinkCount,
LinkIncludingShortenedCollectionAndTags,
} from "@/types/global";
import { useSession } from "next-auth/react";
import useCollectionStore from "@/store/collections";
interface Props { interface Props {
children: ReactNode; children: ReactNode;
@ -40,6 +52,28 @@ export default function LinkLayout({ children }: Props) {
setSidebar(!sidebar); setSidebar(!sidebar);
}; };
const session = useSession();
const userId = session.data?.user.id;
const { setModal } = useModalStore();
const { links, removeLink } = useLinkStore();
const { collections } = useCollectionStore();
const [linkCollection, setLinkCollection] =
useState<CollectionIncludingMembersAndLinkCount>();
const [link, setLink] = useState<LinkIncludingShortenedCollectionAndTags>();
useEffect(() => {
if (links) setLink(links.find((e) => e.id === Number(router.query.id)));
}, [links]);
useEffect(() => {
if (link)
setLinkCollection(collections.find((e) => e.id === link?.collection.id));
}, [link]);
return ( return (
<> <>
<ModalManagement /> <ModalManagement />
@ -50,21 +84,91 @@ export default function LinkLayout({ children }: Props) {
</div> </div>
<div className="w-full flex flex-col min-h-screen max-w-screen-md mx-auto p-5"> <div className="w-full flex flex-col min-h-screen max-w-screen-md mx-auto p-5">
<div className="flex gap-3 mb-5 duration-100"> <div className="flex gap-3 mb-5 duration-100 items-center justify-between">
<div {/* <div
onClick={toggleSidebar} onClick={toggleSidebar}
className="inline-flex lg:hidden gap-1 items-center select-none cursor-pointer p-2 text-gray-500 dark:text-gray-300 rounded-md duration-100 hover:bg-slate-200 dark:hover:bg-neutral-700" className="inline-flex lg:hidden gap-1 items-center select-none cursor-pointer p-2 text-gray-500 dark:text-gray-300 rounded-md duration-100 hover:bg-slate-200 dark:hover:bg-neutral-700"
> >
<FontAwesomeIcon icon={faBars} className="w-5 h-5" /> <FontAwesomeIcon icon={faBars} className="w-5 h-5" />
</div> </div> */}
<div <div
onClick={() => router.back()} onClick={() => router.back()}
className="inline-flex gap-1 lg:hover:opacity-60 items-center select-none cursor-pointer p-2 lg:p-0 lg:px-1 lg:my-2 text-gray-500 dark:text-gray-300 rounded-md duration-100 hover:bg-slate-200 dark:hover:bg-neutral-700 lg:hover:bg-transparent lg:dark:hover:bg-transparent" className="inline-flex gap-1 hover:opacity-60 items-center select-none cursor-pointer p-2 lg:p-0 lg:px-1 lg:my-2 text-gray-500 dark:text-gray-300 rounded-md duration-100"
> >
<FontAwesomeIcon icon={faChevronLeft} className="w-4 h-4" /> <FontAwesomeIcon icon={faChevronLeft} className="w-4 h-4" />
Back Back
</div> </div>
<div className="lg:hidden">
<div className="flex gap-5">
{link?.collection.ownerId === userId ||
linkCollection?.members.some(
(e) => e.userId === userId && e.canUpdate
) ? (
<div
title="Edit"
onClick={() => {
link
? setModal({
modal: "LINK",
state: true,
active: link,
method: "UPDATE",
})
: undefined;
}}
className={`hover:opacity-60 duration-100 py-2 px-2 cursor-pointer flex items-center gap-4 w-full rounded-md h-8`}
>
<FontAwesomeIcon
icon={faPen}
className="w-6 h-6 text-gray-500 dark:text-gray-300"
/>
</div>
) : undefined}
<div
onClick={() => {
link
? setModal({
modal: "LINK",
state: true,
active: link,
method: "FORMATS",
})
: undefined;
}}
title="Preserved Formats"
className={`hover:opacity-60 duration-100 py-2 px-2 cursor-pointer flex items-center gap-4 w-full rounded-md h-8`}
>
<FontAwesomeIcon
icon={faBoxesStacked}
className="w-6 h-6 text-gray-500 dark:text-gray-300"
/>
</div>
{link?.collection.ownerId === userId ||
linkCollection?.members.some(
(e) => e.userId === userId && e.canDelete
) ? (
<div
onClick={() => {
if (link?.id) {
removeLink(link.id);
router.back();
}
}}
title="Delete"
className={`hover:opacity-60 duration-100 py-2 px-2 cursor-pointer flex items-center gap-4 w-full rounded-md h-8`}
>
<FontAwesomeIcon
icon={faTrashCan}
className="w-6 h-6 text-gray-500 dark:text-gray-300"
/>
</div>
) : undefined}
</div>
</div>
</div> </div>
{children} {children}

View File

@ -11,36 +11,15 @@ export default async function archive(
url: string, url: string,
userId: number userId: number
) { ) {
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({ where: { id: userId } });
where: {
id: userId,
},
});
// const checkExistingLink = await prisma.link.findFirst({
// where: {
// id: linkId,
// OR: [
// {
// screenshotPath: "pending",
// },
// {
// pdfPath: "pending",
// },
// ],
// },
// });
// if (checkExistingLink) return "A request has already been made.";
const targetLink = await prisma.link.update({ const targetLink = await prisma.link.update({
where: { where: { id: linkId },
id: linkId,
},
data: { data: {
screenshotPath: user?.archiveAsScreenshot ? "pending" : null, screenshotPath: user?.archiveAsScreenshot ? "pending" : null,
pdfPath: user?.archiveAsPDF ? "pending" : null, pdfPath: user?.archiveAsPDF ? "pending" : null,
readabilityPath: "pending", readabilityPath: "pending",
lastPreserved: new Date().toISOString(),
}, },
}); });
@ -56,7 +35,6 @@ export default async function archive(
try { try {
await page.goto(url, { waitUntil: "domcontentloaded" }); await page.goto(url, { waitUntil: "domcontentloaded" });
await page.goto(url);
const content = await page.content(); const content = await page.content();
// Readability // Readability
@ -64,46 +42,35 @@ export default async function archive(
const window = new JSDOM("").window; const window = new JSDOM("").window;
const purify = DOMPurify(window); const purify = DOMPurify(window);
const cleanedUpContent = purify.sanitize(content); const cleanedUpContent = purify.sanitize(content);
const dom = new JSDOM(cleanedUpContent, { url: url });
const dom = new JSDOM(cleanedUpContent, {
url: url,
});
const article = new Readability(dom.window.document).parse(); const article = new Readability(dom.window.document).parse();
await prisma.link.update({
where: {
id: linkId,
},
data: {
readabilityPath: `archives/${targetLink.collectionId}/${linkId}_readability.json`,
},
});
await createFile({ await createFile({
data: JSON.stringify(article), data: JSON.stringify(article),
filePath: `archives/${targetLink.collectionId}/${linkId}_readability.json`, filePath: `archives/${targetLink.collectionId}/${linkId}_readability.json`,
}); });
// Screenshot/PDF await prisma.link.update({
where: { id: linkId },
let faulty = true; data: {
await page readabilityPath: `archives/${targetLink.collectionId}/${linkId}_readability.json`,
.evaluate(autoScroll, Number(process.env.AUTOSCROLL_TIMEOUT) || 30)
.catch((e) => (faulty = false));
const linkExists = await prisma.link.findUnique({
where: {
id: linkId,
}, },
}); });
if (linkExists && faulty) { // Screenshot/PDF
if (user.archiveAsScreenshot) {
const screenshot = await page.screenshot({ let faulty = false;
fullPage: true, await page
.evaluate(autoScroll, Number(process.env.AUTOSCROLL_TIMEOUT) || 30)
.catch((e) => (faulty = true));
const linkExists = await prisma.link.findUnique({
where: { id: linkId },
}); });
if (linkExists && !faulty) {
if (user.archiveAsScreenshot) {
const screenshot = await page.screenshot({ fullPage: true });
await createFile({ await createFile({
data: screenshot, data: screenshot,
filePath: `archives/${linkExists.collectionId}/${linkId}.png`, filePath: `archives/${linkExists.collectionId}/${linkId}.png`,
@ -124,10 +91,8 @@ export default async function archive(
}); });
} }
const updateLink = await prisma.link.update({ await prisma.link.update({
where: { where: { id: linkId },
id: linkId,
},
data: { data: {
screenshotPath: user.archiveAsScreenshot screenshotPath: user.archiveAsScreenshot
? `archives/${linkExists.collectionId}/${linkId}.png` ? `archives/${linkExists.collectionId}/${linkId}.png`
@ -139,21 +104,18 @@ export default async function archive(
}); });
} else if (faulty) { } else if (faulty) {
await prisma.link.update({ await prisma.link.update({
where: { where: { id: linkId },
id: linkId,
},
data: { data: {
screenshotPath: null, screenshotPath: null,
pdfPath: null, pdfPath: null,
}, },
}); });
} }
await browser.close();
} catch (err) { } catch (err) {
console.log(err); console.log(err);
throw err;
} finally {
await browser.close(); await browser.close();
return err;
} }
} }
} }
@ -161,11 +123,7 @@ export default async function archive(
const autoScroll = async (AUTOSCROLL_TIMEOUT: number) => { const autoScroll = async (AUTOSCROLL_TIMEOUT: number) => {
const timeoutPromise = new Promise<void>((_, reject) => { const timeoutPromise = new Promise<void>((_, reject) => {
setTimeout(() => { setTimeout(() => {
reject( reject(new Error(`Webpage was too long to be archived.`));
new Error(
`Auto scroll took too long (more than ${AUTOSCROLL_TIMEOUT} seconds).`
)
);
}, AUTOSCROLL_TIMEOUT * 1000); }, AUTOSCROLL_TIMEOUT * 1000);
}); });

View File

@ -56,6 +56,7 @@ export default async function postLink(
url: link.url, url: link.url,
name: link.name, name: link.name,
description, description,
readabilityPath: "pending",
collection: { collection: {
connectOrCreate: { connectOrCreate: {
where: { where: {

View File

@ -4,6 +4,8 @@ import { authOptions } from "@/pages/api/v1/auth/[...nextauth]";
import archive from "@/lib/api/archive"; import archive from "@/lib/api/archive";
import { prisma } from "@/lib/api/db"; import { prisma } from "@/lib/api/db";
const RE_ARCHIVE_LIMIT = Number(process.env.RE_ARCHIVE_LIMIT) || 5;
export default async function links(req: NextApiRequest, res: NextApiResponse) { export default async function links(req: NextApiRequest, res: NextApiResponse) {
const session = await getServerSession(req, res, authOptions); const session = await getServerSession(req, res, authOptions);
@ -33,6 +35,20 @@ export default async function links(req: NextApiRequest, res: NextApiResponse) {
}); });
if (req.method === "PUT") { if (req.method === "PUT") {
if (
link?.lastPreserved &&
getTimezoneDifferenceInMinutes(new Date(), link?.lastPreserved) <
RE_ARCHIVE_LIMIT
)
return res.status(400).json({
response: `This link is currently being saved or has already been preserved. Please retry in ${
RE_ARCHIVE_LIMIT -
Math.floor(
getTimezoneDifferenceInMinutes(new Date(), link?.lastPreserved)
)
} minutes or create a new one.`,
});
archive(link.id, link.url, session.user.id); archive(link.id, link.url, session.user.id);
return res.status(200).json({ return res.status(200).json({
response: "Link is being archived.", response: "Link is being archived.",
@ -42,3 +58,14 @@ export default async function links(req: NextApiRequest, res: NextApiResponse) {
// Later? // Later?
// else if (req.method === "DELETE") {} // else if (req.method === "DELETE") {}
} }
const getTimezoneDifferenceInMinutes = (future: Date, past: Date) => {
const date1 = new Date(future);
const date2 = new Date(past);
const diffInMilliseconds = Math.abs(date1.getTime() - date2.getTime());
const diffInMinutes = diffInMilliseconds / (1000 * 60);
return diffInMinutes;
};

View File

@ -220,7 +220,7 @@ export default function Index() {
if (target.id !== "expand-dropdown") if (target.id !== "expand-dropdown")
setExpandDropdown(false); setExpandDropdown(false);
}} }}
className="absolute top-8 right-0 z-10 w-40" className="absolute top-8 right-0 z-10 w-44"
/> />
) : null} ) : null}
</div> </div>

View File

@ -76,7 +76,11 @@ export default function Index() {
useEffect(() => { useEffect(() => {
let interval: NodeJS.Timer | undefined; let interval: NodeJS.Timer | undefined;
if (link?.screenshotPath === "pending" || link?.pdfPath === "pending") { if (
link?.screenshotPath === "pending" ||
link?.pdfPath === "pending" ||
link?.readabilityPath === "pending"
) {
interval = setInterval(() => getLink(link.id as number), 5000); interval = setInterval(() => getLink(link.id as number), 5000);
} else { } else {
if (interval) { if (interval) {
@ -190,6 +194,7 @@ export default function Index() {
<p></p> <p></p>
<Link <Link
href={link?.url || ""} href={link?.url || ""}
title={link?.url}
target="_blank" target="_blank"
className="hover:opacity-60 duration-100 break-all" className="hover:opacity-60 duration-100 break-all"
> >
@ -240,17 +245,21 @@ export default function Index() {
</div> </div>
<div className="flex flex-col gap-5 h-full"> <div className="flex flex-col gap-5 h-full">
{link && {link?.readabilityPath?.startsWith("archives") ? (
link?.readabilityPath &&
link?.readabilityPath !== "pending" ? (
<div <div
className="line-break px-5" className="line-break px-3"
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(linkContent?.content || "") || "", __html: DOMPurify.sanitize(linkContent?.content || "") || "",
}} }}
></div> ></div>
) : ( ) : (
<div className="border border-solid border-sky-100 dark:border-neutral-700 w-full h-full flex flex-col justify-center p-10 rounded-2xl bg-gray-50 dark:bg-neutral-800"> <div className="border border-solid border-sky-100 dark:border-neutral-700 w-full h-full flex flex-col justify-center p-10 rounded-2xl bg-gray-50 dark:bg-neutral-800">
{link?.readabilityPath === "pending" ? (
<p className="text-center">
Generating readable format, please wait...
</p>
) : (
<>
<p className="text-center text-2xl text-black dark:text-white"> <p className="text-center text-2xl text-black dark:text-white">
There is no reader view for this webpage There is no reader view for this webpage
</p> </p>
@ -280,6 +289,8 @@ export default function Index() {
<p>Manage preserved formats</p> <p>Manage preserved formats</p>
</div> </div>
) : undefined} ) : undefined}
</>
)}
</div> </div>
)} )}
</div> </div>

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Link" ADD COLUMN "lastPreserved" TIMESTAMP(3);

View File

@ -107,6 +107,8 @@ model Link {
pdfPath String? pdfPath String?
readabilityPath String? readabilityPath String?
lastPreserved DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt @default(now()) updatedAt DateTime @updatedAt @default(now())
} }

View File

@ -9,6 +9,7 @@ declare global {
STORAGE_FOLDER?: string; STORAGE_FOLDER?: string;
AUTOSCROLL_TIMEOUT?: string; AUTOSCROLL_TIMEOUT?: string;
IMPORT_SIZE_LIMIT?: string; IMPORT_SIZE_LIMIT?: string;
RE_ARCHIVE_LIMIT?: string;
SPACES_KEY?: string; SPACES_KEY?: string;
SPACES_SECRET?: string; SPACES_SECRET?: string;