diff --git a/components/LinkCard.tsx b/components/LinkCard.tsx index c61487c..2f734f9 100644 --- a/components/LinkCard.tsx +++ b/components/LinkCard.tsx @@ -16,7 +16,7 @@ import useAccountStore from "@/store/account"; import { faCalendarDays } from "@fortawesome/free-regular-svg-icons"; import usePermissions from "@/hooks/usePermissions"; import { toast } from "react-hot-toast"; -import isValidUrl from "@/lib/client/isValidUrl"; +import isValidUrl from "@/lib/shared/isValidUrl"; import Link from "next/link"; import unescapeString from "@/lib/client/unescapeString"; import { useRouter } from "next/router"; @@ -43,7 +43,7 @@ export default function LinkCard({ link, count, className }: Props) { let shortendURL; try { - shortendURL = new URL(link.url).host.toLowerCase(); + shortendURL = new URL(link.url || "").host.toLowerCase(); } catch (error) { console.log(error); } @@ -108,7 +108,8 @@ export default function LinkCard({ link, count, className }: Props) { response.ok && toast.success(`Link Deleted.`); }; - const url = isValidUrl(link.url) ? new URL(link.url) : undefined; + const url = + isValidUrl(link.url || "") && link.url ? new URL(link.url) : undefined; const formattedDate = new Date(link.createdAt as string).toLocaleString( "en-US", @@ -272,7 +273,7 @@ export default function LinkCard({ link, count, className }: Props) { ) : undefined} */} { e.stopPropagation(); diff --git a/components/LinkPreview.tsx b/components/LinkPreview.tsx index d774d93..891a69f 100644 --- a/components/LinkPreview.tsx +++ b/components/LinkPreview.tsx @@ -2,7 +2,7 @@ import { faFolder, faLink } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import Image from "next/image"; import { faCalendarDays } from "@fortawesome/free-regular-svg-icons"; -import isValidUrl from "@/lib/client/isValidUrl"; +import isValidUrl from "@/lib/shared/isValidUrl"; import A from "next/link"; import unescapeString from "@/lib/client/unescapeString"; import { Link } from "@prisma/client"; diff --git a/components/Modal/Link/AddOrEditLink.tsx b/components/Modal/Link/AddOrEditLink.tsx index 7e8827a..23a6986 100644 --- a/components/Modal/Link/AddOrEditLink.tsx +++ b/components/Modal/Link/AddOrEditLink.tsx @@ -44,6 +44,7 @@ export default function AddOrEditLink({ activeLink || { name: "", url: "", + type: "", description: "", tags: [], screenshotPath: "", @@ -139,10 +140,10 @@ export default function AddOrEditLink({ {method === "UPDATE" ? (
Address (URL)
{formattedDate}
ยท
{url ? url.host : link.url} diff --git a/lib/api/controllers/links/postLink.ts b/lib/api/controllers/links/postLink.ts index b93eeef..c6755b4 100644 --- a/lib/api/controllers/links/postLink.ts +++ b/lib/api/controllers/links/postLink.ts @@ -1,17 +1,20 @@ import { prisma } from "@/lib/api/db"; import { LinkIncludingShortenedCollectionAndTags } from "@/types/global"; import getTitle from "@/lib/api/getTitle"; -import archive from "@/lib/api/archive"; +import urlHandler from "@/lib/api/urlHandler"; import { UsersAndCollections } from "@prisma/client"; import getPermission from "@/lib/api/getPermission"; import createFolder from "@/lib/api/storage/createFolder"; +import pdfHandler from "../../pdfHandler"; +import validateUrlSize from "../../validateUrlSize"; +import imageHandler from "../../imageHandler"; export default async function postLink( link: LinkIncludingShortenedCollectionAndTags, userId: number ) { try { - new URL(link.url); + if (link.url) new URL(link.url); } catch (error) { return { response: @@ -45,13 +48,33 @@ export default async function postLink( const description = link.description && link.description !== "" ? link.description - : await getTitle(link.url); + : link.url + ? await getTitle(link.url) + : undefined; + + const validatedUrl = link.url ? await validateUrlSize(link.url) : undefined; + + if (validatedUrl === null) + return { response: "File is too large to be stored.", status: 400 }; + + const contentType = validatedUrl?.get("content-type"); + let linkType = "url"; + let imageExtension = "png"; + + if (!link.url) linkType = link.type; + else if (contentType === "application/pdf") linkType = "pdf"; + else if (contentType?.startsWith("image")) { + linkType = "image"; + if (contentType === "image/jpeg") imageExtension = "jpeg"; + else if (contentType === "image/png") imageExtension = "png"; + } const newLink = await prisma.link.create({ data: { url: link.url, name: link.name, description, + type: linkType, readabilityPath: "pending", collection: { connectOrCreate: { @@ -91,7 +114,15 @@ export default async function postLink( createFolder({ filePath: `archives/${newLink.collectionId}` }); - archive(newLink.id, newLink.url, userId); + newLink.url && linkType === "url" + ? urlHandler(newLink.id, newLink.url, userId) + : undefined; + + linkType === "pdf" ? pdfHandler(newLink.id, newLink.url) : undefined; + + linkType === "image" + ? imageHandler(newLink.id, newLink.url, imageExtension) + : undefined; return { response: newLink, status: 200 }; } diff --git a/lib/api/imageHandler.ts b/lib/api/imageHandler.ts new file mode 100644 index 0000000..b9437dd --- /dev/null +++ b/lib/api/imageHandler.ts @@ -0,0 +1,37 @@ +import { prisma } from "@/lib/api/db"; +import createFile from "@/lib/api/storage/createFile"; +import fs from "fs"; +import path from "path"; + +export default async function imageHandler( + linkId: number, + url: string | null, + extension: string, + file?: string +) { + const pdf = await fetch(url as string).then((res) => res.blob()); + + const buffer = Buffer.from(await pdf.arrayBuffer()); + + const linkExists = await prisma.link.findUnique({ + where: { id: linkId }, + }); + + linkExists + ? await createFile({ + data: buffer, + filePath: `archives/${linkExists.collectionId}/${linkId}.${extension}`, + }) + : undefined; + + await prisma.link.update({ + where: { id: linkId }, + data: { + screenshotPath: linkExists + ? `archives/${linkExists.collectionId}/${linkId}.${extension}` + : null, + pdfPath: null, + readabilityPath: null, + }, + }); +} diff --git a/lib/api/pdfHandler.ts b/lib/api/pdfHandler.ts new file mode 100644 index 0000000..37dbef7 --- /dev/null +++ b/lib/api/pdfHandler.ts @@ -0,0 +1,44 @@ +import { prisma } from "@/lib/api/db"; +import createFile from "@/lib/api/storage/createFile"; +import fs from "fs"; +import path from "path"; + +export default async function pdfHandler( + linkId: number, + url: string | null, + file?: string +) { + const targetLink = await prisma.link.update({ + where: { id: linkId }, + data: { + pdfPath: "pending", + lastPreserved: new Date().toISOString(), + }, + }); + + const pdf = await fetch(url as string).then((res) => res.blob()); + + const buffer = Buffer.from(await pdf.arrayBuffer()); + + const linkExists = await prisma.link.findUnique({ + where: { id: linkId }, + }); + + linkExists + ? await createFile({ + data: buffer, + filePath: `archives/${linkExists.collectionId}/${linkId}.pdf`, + }) + : undefined; + + await prisma.link.update({ + where: { id: linkId }, + data: { + pdfPath: linkExists + ? `archives/${linkExists.collectionId}/${linkId}.pdf` + : null, + readabilityPath: null, + screenshotPath: null, + }, + }); +} diff --git a/lib/api/storage/readFile.ts b/lib/api/storage/readFile.ts index 64ff8d7..350726d 100644 --- a/lib/api/storage/readFile.ts +++ b/lib/api/storage/readFile.ts @@ -97,7 +97,7 @@ export default async function readFile(filePath: string) { return { file: "File not found.", contentType: "text/plain", - status: 400, + status: 404, }; else { const file = fs.readFileSync(creationPath); diff --git a/lib/api/archive.ts b/lib/api/urlHandler.ts similarity index 99% rename from lib/api/archive.ts rename to lib/api/urlHandler.ts index e55d86d..8795d76 100644 --- a/lib/api/archive.ts +++ b/lib/api/urlHandler.ts @@ -6,7 +6,7 @@ import { Readability } from "@mozilla/readability"; import { JSDOM } from "jsdom"; import DOMPurify from "dompurify"; -export default async function archive( +export default async function urlHandler( linkId: number, url: string, userId: number diff --git a/lib/api/validateUrlSize.ts b/lib/api/validateUrlSize.ts new file mode 100644 index 0000000..cde7e33 --- /dev/null +++ b/lib/api/validateUrlSize.ts @@ -0,0 +1,13 @@ +export default async function validateUrlSize(url: string) { + try { + const response = await fetch(url, { method: "HEAD" }); + + const totalSizeMB = + Number(response.headers.get("content-length")) / Math.pow(1024, 2); + if (totalSizeMB > 50) return null; + else return response.headers; + } catch (err) { + console.log(err); + return null; + } +} diff --git a/lib/client/isValidUrl.ts b/lib/shared/isValidUrl.ts similarity index 100% rename from lib/client/isValidUrl.ts rename to lib/shared/isValidUrl.ts diff --git a/pages/api/v1/archives/[linkId].ts b/pages/api/v1/archives/[linkId].ts index 74f7738..faade60 100644 --- a/pages/api/v1/archives/[linkId].ts +++ b/pages/api/v1/archives/[linkId].ts @@ -10,7 +10,8 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) { let suffix; - if (format === ArchivedFormat.screenshot) suffix = ".png"; + if (format === ArchivedFormat.png) suffix = ".png"; + else if (format === ArchivedFormat.jpeg) suffix = ".jpeg"; else if (format === ArchivedFormat.pdf) suffix = ".pdf"; else if (format === ArchivedFormat.readability) suffix = "_readability.json"; @@ -43,6 +44,7 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) { const { file, contentType, status } = await readFile( `archives/${collectionIsAccessible.id}/${linkId + suffix}` ); + res.setHeader("Content-Type", contentType).status(status as number); return res.send(file); diff --git a/pages/api/v1/links/[id]/archive/index.ts b/pages/api/v1/links/[id]/archive/index.ts index 65da756..6c82aed 100644 --- a/pages/api/v1/links/[id]/archive/index.ts +++ b/pages/api/v1/links/[id]/archive/index.ts @@ -1,7 +1,8 @@ import type { NextApiRequest, NextApiResponse } from "next"; -import archive from "@/lib/api/archive"; +import urlHandler from "@/lib/api/urlHandler"; import { prisma } from "@/lib/api/db"; import verifyUser from "@/lib/api/verifyUser"; +import isValidUrl from "@/lib/shared/isValidUrl"; const RE_ARCHIVE_LIMIT = Number(process.env.RE_ARCHIVE_LIMIT) || 5; @@ -41,7 +42,13 @@ export default async function links(req: NextApiRequest, res: NextApiResponse) { } minutes or create a new one.`, }); - archive(link.id, link.url, user.id); + if (link.url && isValidUrl(link.url)) { + urlHandler(link.id, link.url, user.id); + return res.status(200).json({ + response: "Link is not a webpage to be archived.", + }); + } + return res.status(200).json({ response: "Link is being archived.", }); diff --git a/pages/links/[id].tsx b/pages/links/[id].tsx index e12f6df..b7200a0 100644 --- a/pages/links/[id].tsx +++ b/pages/links/[id].tsx @@ -10,7 +10,7 @@ import { import Image from "next/image"; import ColorThief, { RGBColor } from "colorthief"; import unescapeString from "@/lib/client/unescapeString"; -import isValidUrl from "@/lib/client/isValidUrl"; +import isValidUrl from "@/lib/shared/isValidUrl"; import DOMPurify from "dompurify"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faBoxesStacked, faFolder } from "@fortawesome/free-solid-svg-icons"; diff --git a/pages/public/links/[id].tsx b/pages/public/links/[id].tsx index 5185467..2dc6268 100644 --- a/pages/public/links/[id].tsx +++ b/pages/public/links/[id].tsx @@ -10,7 +10,7 @@ import { import Image from "next/image"; import ColorThief, { RGBColor } from "colorthief"; import unescapeString from "@/lib/client/unescapeString"; -import isValidUrl from "@/lib/client/isValidUrl"; +import isValidUrl from "@/lib/shared/isValidUrl"; import DOMPurify from "dompurify"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faBoxesStacked, faFolder } from "@fortawesome/free-solid-svg-icons"; diff --git a/prisma/migrations/20231125043215_add_link_type_field/migration.sql b/prisma/migrations/20231125043215_add_link_type_field/migration.sql new file mode 100644 index 0000000..7a90c1c --- /dev/null +++ b/prisma/migrations/20231125043215_add_link_type_field/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "Link" ADD COLUMN "type" TEXT NOT NULL DEFAULT 'url', +ALTER COLUMN "url" DROP NOT NULL; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 70ef764..dd91892 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -103,12 +103,13 @@ model UsersAndCollections { model Link { id Int @id @default(autoincrement()) name String - url String + type String @default("url") description String @default("") pinnedBy User[] collection Collection @relation(fields: [collectionId], references: [id]) collectionId Int tags Tag[] + url String? textContent String? screenshotPath String? pdfPath String? diff --git a/types/global.ts b/types/global.ts index a423d45..c1d1893 100644 --- a/types/global.ts +++ b/types/global.ts @@ -117,7 +117,14 @@ export type DeleteUserBody = { }; export enum ArchivedFormat { - screenshot, + png, + jpeg, pdf, readability, } + +export enum LinkType { + url, + pdf, + image, +}