Merge branch 'feat/handle-files' into dev

This commit is contained in:
Daniel 2023-12-02 21:58:23 +03:30 committed by GitHub
commit 104c79cd99
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 179 additions and 29 deletions

View File

@ -16,7 +16,7 @@ import useAccountStore from "@/store/account";
import { faCalendarDays } from "@fortawesome/free-regular-svg-icons"; import { faCalendarDays } from "@fortawesome/free-regular-svg-icons";
import usePermissions from "@/hooks/usePermissions"; import usePermissions from "@/hooks/usePermissions";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import isValidUrl from "@/lib/client/isValidUrl"; import isValidUrl from "@/lib/shared/isValidUrl";
import Link from "next/link"; import Link from "next/link";
import unescapeString from "@/lib/client/unescapeString"; import unescapeString from "@/lib/client/unescapeString";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
@ -43,7 +43,7 @@ export default function LinkCard({ link, count, className }: Props) {
let shortendURL; let shortendURL;
try { try {
shortendURL = new URL(link.url).host.toLowerCase(); shortendURL = new URL(link.url || "").host.toLowerCase();
} catch (error) { } catch (error) {
console.log(error); console.log(error);
} }
@ -108,7 +108,8 @@ export default function LinkCard({ link, count, className }: Props) {
response.ok && toast.success(`Link Deleted.`); 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( const formattedDate = new Date(link.createdAt as string).toLocaleString(
"en-US", "en-US",
@ -272,7 +273,7 @@ export default function LinkCard({ link, count, className }: Props) {
) : undefined} */} ) : undefined} */}
<Link <Link
href={link.url} href={link.url || ""}
target="_blank" target="_blank"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();

View File

@ -2,7 +2,7 @@ import { faFolder, faLink } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import Image from "next/image"; import Image from "next/image";
import { faCalendarDays } from "@fortawesome/free-regular-svg-icons"; 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 A from "next/link";
import unescapeString from "@/lib/client/unescapeString"; import unescapeString from "@/lib/client/unescapeString";
import { Link } from "@prisma/client"; import { Link } from "@prisma/client";

View File

@ -44,6 +44,7 @@ export default function AddOrEditLink({
activeLink || { activeLink || {
name: "", name: "",
url: "", url: "",
type: "",
description: "", description: "",
tags: [], tags: [],
screenshotPath: "", screenshotPath: "",
@ -139,10 +140,10 @@ export default function AddOrEditLink({
{method === "UPDATE" ? ( {method === "UPDATE" ? (
<div <div
className="text-neutral break-all w-full flex gap-2" className="text-neutral break-all w-full flex gap-2"
title={link.url} title={link.url || ""}
> >
<FontAwesomeIcon icon={faLink} className="w-6 h-6" /> <FontAwesomeIcon icon={faLink} className="w-6 h-6" />
<Link href={link.url} target="_blank" className="w-full"> <Link href={link.url || ""} target="_blank" className="w-full">
{link.url} {link.url}
</Link> </Link>
</div> </div>
@ -153,7 +154,7 @@ export default function AddOrEditLink({
<div className="sm:col-span-3 col-span-5"> <div className="sm:col-span-3 col-span-5">
<p className="mb-2">Address (URL)</p> <p className="mb-2">Address (URL)</p>
<TextInput <TextInput
value={link.url} value={link.url || ""}
onChange={(e) => setLink({ ...link, url: e.target.value })} onChange={(e) => setLink({ ...link, url: e.target.value })}
placeholder="e.g. http://example.com/" placeholder="e.g. http://example.com/"
className="bg-base-200" className="bg-base-200"

View File

@ -76,8 +76,7 @@ export default function PreservedFormats() {
// Create a temporary link and click it to trigger the download // Create a temporary link and click it to trigger the download
const link = document.createElement("a"); const link = document.createElement("a");
link.href = path; link.href = path;
link.download = link.download = format === ArchivedFormat.png ? "Screenshot" : "PDF";
format === ArchivedFormat.screenshot ? "Screenshot" : "PDF";
link.click(); link.click();
} else { } else {
console.error("Failed to download file"); console.error("Failed to download file");
@ -102,7 +101,7 @@ export default function PreservedFormats() {
<div className="flex gap-1"> <div className="flex gap-1">
<div <div
onClick={() => handleDownload(ArchivedFormat.screenshot)} onClick={() => handleDownload(ArchivedFormat.png)}
className="cursor-pointer hover:opacity-60 duration-100 p-2 rounded-md" className="cursor-pointer hover:opacity-60 duration-100 p-2 rounded-md"
> >
<FontAwesomeIcon <FontAwesomeIcon
@ -112,7 +111,11 @@ export default function PreservedFormats() {
</div> </div>
<Link <Link
href={`/api/v1/archives/${link?.id}?format=${ArchivedFormat.screenshot}`} href={`/api/v1/archives/${link?.id}?format=${
link.screenshotPath.endsWith("png")
? ArchivedFormat.png
: ArchivedFormat.jpeg
}`}
target="_blank" target="_blank"
className="cursor-pointer hover:opacity-60 duration-100 p-2 rounded-md" className="cursor-pointer hover:opacity-60 duration-100 p-2 rounded-md"
> >

View File

@ -2,7 +2,7 @@ import { faChevronRight } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import Image from "next/image"; import Image from "next/image";
import { Link as LinkType, Tag } from "@prisma/client"; import { Link as LinkType, Tag } from "@prisma/client";
import isValidUrl from "@/lib/client/isValidUrl"; import isValidUrl from "@/lib/shared/isValidUrl";
import unescapeString from "@/lib/client/unescapeString"; import unescapeString from "@/lib/client/unescapeString";
import { TagIncludingLinkCount } from "@/types/global"; import { TagIncludingLinkCount } from "@/types/global";
import Link from "next/link"; import Link from "next/link";
@ -17,7 +17,7 @@ type Props = {
}; };
export default function LinkCard({ link, count }: Props) { export default function LinkCard({ link, count }: Props) {
const url = isValidUrl(link.url) ? new URL(link.url) : undefined; const url = link.url && isValidUrl(link.url) ? new URL(link.url) : undefined;
const formattedDate = new Date( const formattedDate = new Date(
link.createdAt as unknown as string link.createdAt as unknown as string
@ -68,10 +68,10 @@ export default function LinkCard({ link, count }: Props) {
<p>{formattedDate}</p> <p>{formattedDate}</p>
<p>·</p> <p>·</p>
<Link <Link
href={url ? url.href : link.url} href={url ? url.href : link.url || ""}
target="_blank" target="_blank"
className="hover:opacity-50 duration-100 truncate w-52 sm:w-fit" className="hover:opacity-50 duration-100 truncate w-52 sm:w-fit"
title={url ? url.href : link.url} title={url ? url.href : link.url || ""}
> >
{url ? url.host : link.url} {url ? url.host : link.url}
</Link> </Link>

View File

@ -1,17 +1,20 @@
import { prisma } from "@/lib/api/db"; import { prisma } from "@/lib/api/db";
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global"; import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import getTitle from "@/lib/api/getTitle"; import getTitle from "@/lib/api/getTitle";
import archive from "@/lib/api/archive"; import urlHandler from "@/lib/api/urlHandler";
import { UsersAndCollections } from "@prisma/client"; import { UsersAndCollections } from "@prisma/client";
import getPermission from "@/lib/api/getPermission"; import getPermission from "@/lib/api/getPermission";
import createFolder from "@/lib/api/storage/createFolder"; import createFolder from "@/lib/api/storage/createFolder";
import pdfHandler from "../../pdfHandler";
import validateUrlSize from "../../validateUrlSize";
import imageHandler from "../../imageHandler";
export default async function postLink( export default async function postLink(
link: LinkIncludingShortenedCollectionAndTags, link: LinkIncludingShortenedCollectionAndTags,
userId: number userId: number
) { ) {
try { try {
new URL(link.url); if (link.url) new URL(link.url);
} catch (error) { } catch (error) {
return { return {
response: response:
@ -45,13 +48,33 @@ export default async function postLink(
const description = const description =
link.description && link.description !== "" link.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({ const newLink = await prisma.link.create({
data: { data: {
url: link.url, url: link.url,
name: link.name, name: link.name,
description, description,
type: linkType,
readabilityPath: "pending", readabilityPath: "pending",
collection: { collection: {
connectOrCreate: { connectOrCreate: {
@ -91,7 +114,15 @@ export default async function postLink(
createFolder({ filePath: `archives/${newLink.collectionId}` }); 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 }; return { response: newLink, status: 200 };
} }

37
lib/api/imageHandler.ts Normal file
View File

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

44
lib/api/pdfHandler.ts Normal file
View File

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

View File

@ -97,7 +97,7 @@ export default async function readFile(filePath: string) {
return { return {
file: "File not found.", file: "File not found.",
contentType: "text/plain", contentType: "text/plain",
status: 400, status: 404,
}; };
else { else {
const file = fs.readFileSync(creationPath); const file = fs.readFileSync(creationPath);

View File

@ -6,7 +6,7 @@ import { Readability } from "@mozilla/readability";
import { JSDOM } from "jsdom"; import { JSDOM } from "jsdom";
import DOMPurify from "dompurify"; import DOMPurify from "dompurify";
export default async function archive( export default async function urlHandler(
linkId: number, linkId: number,
url: string, url: string,
userId: number userId: number

View File

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

View File

@ -10,7 +10,8 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) {
let suffix; 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.pdf) suffix = ".pdf";
else if (format === ArchivedFormat.readability) suffix = "_readability.json"; 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( const { file, contentType, status } = await readFile(
`archives/${collectionIsAccessible.id}/${linkId + suffix}` `archives/${collectionIsAccessible.id}/${linkId + suffix}`
); );
res.setHeader("Content-Type", contentType).status(status as number); res.setHeader("Content-Type", contentType).status(status as number);
return res.send(file); return res.send(file);

View File

@ -1,7 +1,8 @@
import type { NextApiRequest, NextApiResponse } from "next"; 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 { prisma } from "@/lib/api/db";
import verifyUser from "@/lib/api/verifyUser"; import verifyUser from "@/lib/api/verifyUser";
import isValidUrl from "@/lib/shared/isValidUrl";
const RE_ARCHIVE_LIMIT = Number(process.env.RE_ARCHIVE_LIMIT) || 5; 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.`, } 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({ return res.status(200).json({
response: "Link is being archived.", response: "Link is being archived.",
}); });

View File

@ -10,7 +10,7 @@ import {
import Image from "next/image"; import Image from "next/image";
import ColorThief, { RGBColor } from "colorthief"; import ColorThief, { RGBColor } from "colorthief";
import unescapeString from "@/lib/client/unescapeString"; import unescapeString from "@/lib/client/unescapeString";
import isValidUrl from "@/lib/client/isValidUrl"; import isValidUrl from "@/lib/shared/isValidUrl";
import DOMPurify from "dompurify"; import DOMPurify from "dompurify";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faBoxesStacked, faFolder } from "@fortawesome/free-solid-svg-icons"; import { faBoxesStacked, faFolder } from "@fortawesome/free-solid-svg-icons";

View File

@ -10,7 +10,7 @@ import {
import Image from "next/image"; import Image from "next/image";
import ColorThief, { RGBColor } from "colorthief"; import ColorThief, { RGBColor } from "colorthief";
import unescapeString from "@/lib/client/unescapeString"; import unescapeString from "@/lib/client/unescapeString";
import isValidUrl from "@/lib/client/isValidUrl"; import isValidUrl from "@/lib/shared/isValidUrl";
import DOMPurify from "dompurify"; import DOMPurify from "dompurify";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faBoxesStacked, faFolder } from "@fortawesome/free-solid-svg-icons"; import { faBoxesStacked, faFolder } from "@fortawesome/free-solid-svg-icons";

View File

@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "Link" ADD COLUMN "type" TEXT NOT NULL DEFAULT 'url',
ALTER COLUMN "url" DROP NOT NULL;

View File

@ -103,12 +103,13 @@ model UsersAndCollections {
model Link { model Link {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String name String
url String type String @default("url")
description String @default("") description String @default("")
pinnedBy User[] pinnedBy User[]
collection Collection @relation(fields: [collectionId], references: [id]) collection Collection @relation(fields: [collectionId], references: [id])
collectionId Int collectionId Int
tags Tag[] tags Tag[]
url String?
textContent String? textContent String?
screenshotPath String? screenshotPath String?
pdfPath String? pdfPath String?

View File

@ -117,7 +117,14 @@ export type DeleteUserBody = {
}; };
export enum ArchivedFormat { export enum ArchivedFormat {
screenshot, png,
jpeg,
pdf, pdf,
readability, readability,
} }
export enum LinkType {
url,
pdf,
image,
}