feat!: added PDF and screenshot archive support

This commit is contained in:
Daniel 2023-03-09 01:01:24 +03:30
parent bd3b2f50f2
commit 0d5579b56d
17 changed files with 186 additions and 47 deletions

5
.gitignore vendored
View File

@ -1,5 +1,3 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies # dependencies
/node_modules /node_modules
/.pnp /.pnp
@ -35,3 +33,6 @@ yarn-error.log*
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
# generated files and folders
/data

View File

@ -7,14 +7,14 @@ import { useRouter } from "next/router";
import { NewLink } from "@/types/global"; import { NewLink } from "@/types/global";
import useLinkSlice from "@/store/links"; import useLinkSlice from "@/store/links";
export default function () { export default function ({ toggleLinkModal }: { toggleLinkModal: Function }) {
const router = useRouter(); const router = useRouter();
const [newLink, setNewLink] = useState<NewLink>({ const [newLink, setNewLink] = useState<NewLink>({
name: "", name: "",
url: "", url: "",
tags: [], tags: [],
collectionId: { id: Number(router.query.id) }, collection: { id: Number(router.query.id) },
}); });
const { addLink } = useLinkSlice(); const { addLink } = useLinkSlice();
@ -30,23 +30,13 @@ export default function () {
const setCollection = (e: any) => { const setCollection = (e: any) => {
const collection = { id: e?.value, isNew: e?.__isNew__ }; const collection = { id: e?.value, isNew: e?.__isNew__ };
setNewLink({ ...newLink, collectionId: collection }); setNewLink({ ...newLink, collection: collection });
}; };
const postLink = async () => { const submitLink = async () => {
const response = await fetch("/api/routes/links", { const response = await addLink(newLink);
body: JSON.stringify(newLink),
headers: {
"Content-Type": "application/json",
},
method: "POST",
});
const data = await response.json(); if (response) toggleLinkModal();
console.log(newLink);
console.log(data);
}; };
return ( return (
@ -87,7 +77,7 @@ export default function () {
<div <div
className="mx-auto mt-2 bg-sky-500 text-white flex items-center gap-2 py-2 px-5 rounded select-none font-bold cursor-pointer duration-100 hover:bg-sky-400" className="mx-auto mt-2 bg-sky-500 text-white flex items-center gap-2 py-2 px-5 rounded select-none font-bold cursor-pointer duration-100 hover:bg-sky-400"
onClick={() => addLink(newLink)} onClick={submitLink}
> >
<FontAwesomeIcon icon={faPlus} className="h-5" /> <FontAwesomeIcon icon={faPlus} className="h-5" />
Add Link Add Link

View File

@ -9,7 +9,7 @@ export default function ({
}) { }) {
return ( return (
<div className="border border-sky-100 mb-5 bg-gray-100 p-5 rounded"> <div className="border border-sky-100 mb-5 bg-gray-100 p-5 rounded">
<div className="flex items-baseline gap-1"> {/* <div className="flex items-baseline gap-1">
<p className="text-sm text-sky-600">{count + 1}.</p> <p className="text-sm text-sky-600">{count + 1}.</p>
<p className="text-lg text-sky-500">{link.name}</p> <p className="text-lg text-sky-500">{link.name}</p>
</div> </div>
@ -17,7 +17,8 @@ export default function ({
{link.tags.map((e, i) => ( {link.tags.map((e, i) => (
<p key={i}>{e.name}</p> <p key={i}>{e.name}</p>
))} ))}
</div> </div> */}
{JSON.stringify(link)}
</div> </div>
); );
} }

View File

@ -59,10 +59,10 @@ export default function () {
} }
}, [router, collections, tags]); }, [router, collections, tags]);
const [collectionInput, setCollectionInput] = useState(false); const [linkModal, setLinkModal] = useState(false);
const toggleCollectionInput = () => { const toggleLinkModal = () => {
setCollectionInput(!collectionInput); setLinkModal(!linkModal);
}; };
return ( return (
@ -76,7 +76,7 @@ export default function () {
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<FontAwesomeIcon <FontAwesomeIcon
icon={faPlus} icon={faPlus}
onClick={toggleCollectionInput} onClick={toggleLinkModal}
className="select-none cursor-pointer w-5 h-5 text-white bg-sky-500 p-2 rounded hover:bg-sky-400 duration-100" className="select-none cursor-pointer w-5 h-5 text-white bg-sky-500 p-2 rounded hover:bg-sky-400 duration-100"
/> />
<FontAwesomeIcon <FontAwesomeIcon
@ -90,13 +90,13 @@ export default function () {
Sign Out Sign Out
</div> </div>
{collectionInput ? ( {linkModal ? (
<div className="fixed top-0 bottom-0 right-0 left-0 bg-gray-500 bg-opacity-10 flex items-center fade-in"> <div className="fixed top-0 bottom-0 right-0 left-0 bg-gray-500 bg-opacity-10 flex items-center fade-in">
<ClickAwayHandler <ClickAwayHandler
onClickOutside={toggleCollectionInput} onClickOutside={toggleLinkModal}
className="w-fit mx-auto" className="w-fit mx-auto"
> >
<AddLinkModal /> <AddLinkModal toggleLinkModal={toggleLinkModal} />
</ClickAwayHandler> </ClickAwayHandler>
</div> </div>
) : null} ) : null}

21
lib/api/archive.ts Normal file
View File

@ -0,0 +1,21 @@
import { chromium, devices } from "playwright";
export default async (url: string, collectionId: number, linkId: number) => {
const archivePath = `data/archives/${collectionId}/${linkId}`;
const browser = await chromium.launch();
const context = await browser.newContext(devices["Desktop Chrome"]);
const page = await context.newPage();
// const contexts = browser.contexts();
// console.log(contexts.length);
await page.goto(url);
await page.pdf({ path: archivePath + ".pdf" });
await page.screenshot({ fullPage: true, path: archivePath + ".png" });
await context.close();
await browser.close();
};

View File

@ -1,6 +1,7 @@
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import { prisma } from "@/lib/api/db"; import { prisma } from "@/lib/api/db";
import { Session } from "next-auth"; import { Session } from "next-auth";
import { existsSync, mkdirSync } from "fs";
export default async function ( export default async function (
req: NextApiRequest, req: NextApiRequest,
@ -39,7 +40,7 @@ export default async function (
return res.status(400).json({ response: "Collection already exists." }); return res.status(400).json({ response: "Collection already exists." });
} }
const createCollection = await prisma.collection.create({ const newCollection = await prisma.collection.create({
data: { data: {
owner: { owner: {
connect: { connect: {
@ -50,7 +51,11 @@ export default async function (
}, },
}); });
const collectionPath = `data/archives/${newCollection.id}`;
if (!existsSync(collectionPath))
mkdirSync(collectionPath, { recursive: true });
return res.status(200).json({ return res.status(200).json({
response: createCollection, response: newCollection,
}); });
} }

View File

@ -2,6 +2,11 @@ import type { NextApiRequest, NextApiResponse } from "next";
import { prisma } from "@/lib/api/db"; import { prisma } from "@/lib/api/db";
import { Session } from "next-auth"; import { Session } from "next-auth";
import { LinkAndTags, NewLink } from "@/types/global"; import { LinkAndTags, NewLink } from "@/types/global";
import { existsSync, mkdirSync } from "fs";
import getTitle from "../../getTitle";
import archive from "../../archive";
import { Link } from "@prisma/client";
import AES from "crypto-js/aes";
export default async function ( export default async function (
req: NextApiRequest, req: NextApiRequest,
@ -21,8 +26,8 @@ export default async function (
.json({ response: "Please enter a valid name for the link." }); .json({ response: "Please enter a valid name for the link." });
} }
if (link.collectionId.isNew) { if (link.collection.isNew) {
const collectionId = link.collectionId.id as string; const collectionId = link.collection.id as string;
const findCollection = await prisma.user.findFirst({ const findCollection = await prisma.user.findFirst({
where: { where: {
@ -43,7 +48,7 @@ export default async function (
return res.status(400).json({ response: "Collection already exists." }); return res.status(400).json({ response: "Collection already exists." });
} }
const createCollection = await prisma.collection.create({ const newCollection = await prisma.collection.create({
data: { data: {
owner: { owner: {
connect: { connect: {
@ -54,12 +59,18 @@ export default async function (
}, },
}); });
link.collectionId.id = createCollection.id; const collectionPath = `data/archives/${newCollection.id}`;
if (!existsSync(collectionPath))
mkdirSync(collectionPath, { recursive: true });
link.collection.id = newCollection.id;
} }
const collectionId = link.collectionId.id as number; const collectionId = link.collection.id as number;
const createLink: LinkAndTags = await prisma.link.create({ const title = await getTitle(link.url);
const newLink: Link = await prisma.link.create({
data: { data: {
name: link.name, name: link.name,
url: link.url, url: link.url,
@ -86,11 +97,34 @@ export default async function (
}, },
})), })),
}, },
title,
isFavorites: false,
screenshotPath: "",
pdfPath: "",
}, },
});
const AES_SECRET = process.env.AES_SECRET as string;
const screenShotHashedPath = AES.encrypt(
`data/archives/${newLink.collectionId}/${newLink.id}.png`,
AES_SECRET
).toString();
const pdfHashedPath = AES.encrypt(
`data/archives/${newLink.collectionId}/${newLink.id}.pdf`,
AES_SECRET
).toString();
const updatedLink: LinkAndTags = await prisma.link.update({
where: { id: newLink.id },
data: { screenshotPath: screenShotHashedPath, pdfPath: pdfHashedPath },
include: { tags: true }, include: { tags: true },
}); });
archive(updatedLink.url, updatedLink.collectionId, updatedLink.id);
return res.status(200).json({ return res.status(200).json({
response: createLink, response: updatedLink,
}); });
} }

9
lib/api/getTitle.ts Normal file
View File

@ -0,0 +1,9 @@
export default async (url: string) => {
const response = await fetch(url);
const text = await response.text();
// regular expression to find the <title> tag
let match = text.match(/<title.*>([^<]*)<\/title>/);
if (match) return match[1];
else return "";
};

View File

@ -0,0 +1,15 @@
import { prisma } from "@/lib/api/db";
export default async (userId: number, collectionId: number) => {
const check: any = await prisma.collection.findFirst({
where: {
AND: {
id: collectionId,
OR: [{ ownerId: userId }, { members: { some: { userId } } }],
},
},
include: { members: true },
});
return check;
};

View File

@ -19,14 +19,17 @@
"@fortawesome/react-fontawesome": "^0.2.0", "@fortawesome/react-fontawesome": "^0.2.0",
"@next/font": "13.1.6", "@next/font": "13.1.6",
"@prisma/client": "^4.9.0", "@prisma/client": "^4.9.0",
"@types/crypto-js": "^4.1.1",
"@types/node": "18.11.18", "@types/node": "18.11.18",
"@types/react": "18.0.27", "@types/react": "18.0.27",
"@types/react-dom": "18.0.10", "@types/react-dom": "18.0.10",
"bcrypt": "^5.1.0", "bcrypt": "^5.1.0",
"crypto-js": "^4.1.1",
"eslint": "8.33.0", "eslint": "8.33.0",
"eslint-config-next": "13.1.6", "eslint-config-next": "13.1.6",
"next": "13.1.6", "next": "13.1.6",
"next-auth": "^4.19.1", "next-auth": "^4.19.1",
"playwright": "^1.31.2",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-select": "^5.7.0", "react-select": "^5.7.0",

View File

@ -0,0 +1,44 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { getServerSession } from "next-auth/next";
import { authOptions } from "pages/api/auth/[...nextauth]";
import AES from "crypto-js/aes";
import enc from "crypto-js/enc-utf8";
import path from "path";
import fs from "fs";
import hasAccessToCollection from "@/lib/api/hasAccessToCollection";
export default async function (req: NextApiRequest, res: NextApiResponse) {
if (!req.query.params)
return res.status(401).json({ response: "Invalid parameters." });
const collectionId = req.query.params[0];
const session = await getServerSession(req, res, authOptions);
if (!session?.user?.email)
return res.status(401).json({ response: "You must be logged in." });
const collectionIsAccessible = await hasAccessToCollection(
session.user.id,
Number(collectionId)
);
if (!collectionIsAccessible)
return res
.status(401)
.json({ response: "You don't have access to this collection." });
const AES_SECRET = process.env.AES_SECRET as string;
const encryptedPath = decodeURIComponent(req.query.params[1]) as string;
const decryptedPath = AES.decrypt(encryptedPath, AES_SECRET).toString(enc);
const filePath = path.join(process.cwd(), decryptedPath);
const file = fs.readFileSync(filePath);
if (filePath.endsWith(".pdf"))
res.setHeader("Content-Type", "application/pdf");
if (filePath.endsWith(".png")) res.setHeader("Content-Type", "image/png");
return res.status(200).send(file);
}

View File

@ -37,17 +37,11 @@ export default async function (
name: body.name, name: body.name,
email: body.email, email: body.email,
password: hashedPassword, password: hashedPassword,
collections: {
create: {
name: "First Collection",
},
},
}, },
}); });
res.status(201).json({ message: "User successfully created." }); res.status(201).json({ message: "User successfully created." });
} else if (checkIfUserExists) { } else if (checkIfUserExists) {
console.log(checkIfUserExists);
res.status(400).json({ message: "User already exists." }); res.status(400).json({ message: "User already exists." });
} }
} }

View File

@ -24,7 +24,6 @@ CREATE TABLE "UsersAndCollections" (
"userId" INTEGER NOT NULL, "userId" INTEGER NOT NULL,
"collectionId" INTEGER NOT NULL, "collectionId" INTEGER NOT NULL,
"canCreate" BOOLEAN NOT NULL, "canCreate" BOOLEAN NOT NULL,
"canRead" BOOLEAN NOT NULL,
"canUpdate" BOOLEAN NOT NULL, "canUpdate" BOOLEAN NOT NULL,
"canDelete" BOOLEAN NOT NULL, "canDelete" BOOLEAN NOT NULL,

View File

@ -36,7 +36,6 @@ model UsersAndCollections {
collectionId Int collectionId Int
canCreate Boolean canCreate Boolean
canRead Boolean
canUpdate Boolean canUpdate Boolean
canDelete Boolean canDelete Boolean

View File

@ -4,7 +4,7 @@ import { LinkAndTags, NewLink } from "@/types/global";
type LinkSlice = { type LinkSlice = {
links: LinkAndTags[]; links: LinkAndTags[];
setLinks: () => void; setLinks: () => void;
addLink: (linkName: NewLink) => void; addLink: (linkName: NewLink) => Promise<boolean>;
updateLink: (link: LinkAndTags) => void; updateLink: (link: LinkAndTags) => void;
removeLink: (linkId: number) => void; removeLink: (linkId: number) => void;
}; };
@ -33,6 +33,8 @@ const useLinkSlice = create<LinkSlice>()((set) => ({
set((state) => ({ set((state) => ({
links: [...state.links, data.response], links: [...state.links, data.response],
})); }));
return response.ok;
}, },
updateLink: (link) => updateLink: (link) =>
set((state) => ({ set((state) => ({

View File

@ -8,7 +8,7 @@ export interface NewLink {
name: string; name: string;
url: string; url: string;
tags: string[]; tags: string[];
collectionId: { collection: {
id: string | number; id: string | number;
isNew?: boolean; isNew?: boolean;
}; };

View File

@ -396,6 +396,11 @@
dependencies: dependencies:
"@types/node" "*" "@types/node" "*"
"@types/crypto-js@^4.1.1":
version "4.1.1"
resolved "https://registry.yarnpkg.com/@types/crypto-js/-/crypto-js-4.1.1.tgz#602859584cecc91894eb23a4892f38cfa927890d"
integrity sha512-BG7fQKZ689HIoc5h+6D2Dgq1fABRa0RbBWKBd9SP/MVRVXROflpm5fhwyATX5duFmbStzyzyycPB8qUYKDH3NA==
"@types/json5@^0.0.29": "@types/json5@^0.0.29":
version "0.0.29" version "0.0.29"
resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee" resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee"
@ -868,6 +873,11 @@ cross-spawn@^7.0.2, cross-spawn@^7.0.3:
shebang-command "^2.0.0" shebang-command "^2.0.0"
which "^2.0.1" which "^2.0.1"
crypto-js@^4.1.1:
version "4.1.1"
resolved "https://registry.yarnpkg.com/crypto-js/-/crypto-js-4.1.1.tgz#9e485bcf03521041bd85844786b83fb7619736cf"
integrity sha512-o2JlM7ydqd3Qk9CA0L4NL6mTzU2sdx96a+oOfPu8Mkl/PK51vSyoi8/rQ8NknZtk44vq15lmhAj9CIAGwgeWKw==
cssesc@^3.0.0: cssesc@^3.0.0:
version "3.0.0" version "3.0.0"
resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee" resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee"
@ -2373,6 +2383,18 @@ pify@^2.3.0:
resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c" resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c"
integrity sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog== integrity sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==
playwright-core@1.31.2:
version "1.31.2"
resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.31.2.tgz#debf4b215d14cb619adb7e511c164d068075b2ed"
integrity sha512-a1dFgCNQw4vCsG7bnojZjDnPewZcw7tZUNFN0ZkcLYKj+mPmXvg4MpaaKZ5SgqPsOmqIf2YsVRkgqiRDxD+fDQ==
playwright@^1.31.2:
version "1.31.2"
resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.31.2.tgz#4252280586c596746122cd1fdf9f8ff6a63fa852"
integrity sha512-jpC47n2PKQNtzB7clmBuWh6ftBRS/Bt5EGLigJ9k2QAKcNeYXZkEaDH5gmvb6+AbcE0DO6GnXdbl9ogG6Eh+og==
dependencies:
playwright-core "1.31.2"
postcss-import@^14.1.0: postcss-import@^14.1.0:
version "14.1.0" version "14.1.0"
resolved "https://registry.yarnpkg.com/postcss-import/-/postcss-import-14.1.0.tgz#a7333ffe32f0b8795303ee9e40215dac922781f0" resolved "https://registry.yarnpkg.com/postcss-import/-/postcss-import-14.1.0.tgz#a7333ffe32f0b8795303ee9e40215dac922781f0"