diff --git a/package.json b/package.json index b25bd8f..6822503 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "next": "^14.1.4", "next-themes": "^0.3.0", "nextjs-toploader": "^1.6.11", + "plyr-react": "^5.3.0", "react": "^18", "react-colorful": "^5.6.1", "react-day-picker": "^8.10.0", diff --git a/src/app/@preview.image.tsx b/src/app/@preview.image.tsx index 033a882..88a9eeb 100644 --- a/src/app/@preview.image.tsx +++ b/src/app/@preview.image.tsx @@ -25,28 +25,7 @@ export default function PreviewImage({ file }: Props) { return; } const token = await CreateDownloadToken(); - await fetch(`/api/download/${file.encryptedId}?token=${token}`) - .then((res) => { - if (!res.ok) throw new Error("Failed to fetch image"); - return res.blob(); - }) - .then((blob) => { - const reader = new FileReader(); - reader.onload = () => { - setImgSrc(reader.result as string); - }; - reader.onerror = (e) => { - console.error(e); - setError( - "Could not preview this image, try downloading the file", - ); - }; - reader.readAsDataURL(blob); - }) - .catch((e) => { - console.error(e.message); - setError(e.message); - }); + setImgSrc(`/api/download/${file.encryptedId}?token=${token}&media=1`); } catch (error) { const e = error as Error; console.error(e); @@ -87,6 +66,10 @@ export default function PreviewImage({ file }: Props) { src={imgSrc} alt={file.name} className='max-h-[70dvh] w-full rounded-[var(--radius)] bg-muted object-contain object-center' + onError={(e) => { + console.error(e); + setError("Could not preview this image, try downloading the file"); + }} /> )} diff --git a/src/app/@preview.layout.tsx b/src/app/@preview.layout.tsx index 287dfce..f34bf0d 100644 --- a/src/app/@preview.layout.tsx +++ b/src/app/@preview.layout.tsx @@ -26,12 +26,13 @@ export default function FilePreviewLayout({ data, fileType }: Props) { const [view, setView] = useState<"markdown" | "raw">("markdown"); return ( - <> +
@@ -70,6 +71,6 @@ export default function FilePreviewLayout({ data, fileType }: Props) { - +
); } diff --git a/src/app/@preview.video.tsx b/src/app/@preview.video.tsx index 7c4d8af..df6e10c 100644 --- a/src/app/@preview.video.tsx +++ b/src/app/@preview.video.tsx @@ -1,15 +1,21 @@ "use client"; +import dynamic from "next/dynamic"; import { useEffect, useState } from "react"; -import ReactPlayer from "react-player"; import { z } from "zod"; import { Schema_File } from "~/schema"; import { cn } from "~/utils"; import Icon from "~/components/Icon"; +import { decryptData } from "~/utils/encryptionHelper/hash"; + import { CreateDownloadToken } from "./actions"; +const Plyr = dynamic(() => import("plyr-react"), { + ssr: false, +}); + type Props = { file: z.infer; }; @@ -26,7 +32,11 @@ export default function PreviewVideo({ file }: Props) { return; } const token = await CreateDownloadToken(); - setVideoSrc(`/api/download/${file.encryptedId}?token=${token}`); + const id = await decryptData(file.encryptedId); + // setVideoSrc( + // `https://drive.usercontent.google.com/download?id=${id}&export=download&authuser=0`, + // ); + setVideoSrc(`/api/stream/${file.encryptedId}?token=${token}`); } catch (error) { const e = error as Error; console.error(e); @@ -63,30 +73,62 @@ export default function PreviewVideo({ file }: Props) { {error}
) : ( - ( -
- {children} -
- )} - style={{ - width: "100%", - height: "100%", - maxHeight: "60vh", - }} - onError={(error) => { - console.error(error.message); - if (error instanceof Error) { - setError(error.message); - } else { - setError("Failed to load video. (Probably not supported?)"); - } - }} - /> +
+ +
+ // ( + //
+ // {children} + //
+ // )} + // style={{ + // width: "100%", + // height: "100%", + // maxHeight: "60vh", + // }} + // onError={(error) => { + // console.error(error.message); + // if (error instanceof Error) { + // setError(error.message); + // } else { + // setError("Failed to load video. (Probably not supported?)"); + // } + // }} + // /> )} ); diff --git a/src/app/@rich-header.tsx b/src/app/@rich-header.tsx index e3a2179..3c984f5 100644 --- a/src/app/@rich-header.tsx +++ b/src/app/@rich-header.tsx @@ -1,37 +1,51 @@ "use client"; import { Button } from "~/components/ui/button"; -import { CardHeader, CardTitle } from "~/components/ui/card"; +import { CardHeader } from "~/components/ui/card"; import { Separator } from "~/components/ui/separator"; +import { getFileType } from "~/utils/previewHelper"; + type Props = { title: string; view: "markdown" | "raw"; onViewChange: (value: "markdown" | "raw") => void; + fileType: ReturnType | "unknown"; }; -export default function RichHeader({ title, view, onViewChange }: Props) { +export default function RichHeader({ + title, + view, + onViewChange, + fileType, +}: Props) { return ( -
- {title} -
- - -
+
+ {/* */} +

+ {title} +

+ {/*
*/} + {["markdown", "code", "text"].includes(fileType) && ( +
+ + +
+ )}
diff --git a/src/app/api/stream/[encryptedId]/helper.ts b/src/app/api/stream/[encryptedId]/helper.ts new file mode 100644 index 0000000..d12e947 --- /dev/null +++ b/src/app/api/stream/[encryptedId]/helper.ts @@ -0,0 +1,24 @@ +import { Readable } from "stream"; + +async function* nodeStreamToIterator(stream: any) { + for await (const chunk of stream) { + yield chunk; + } +} + +function iteratorToStream(iterator: AsyncGenerator) { + return new ReadableStream({ + async pull(controller) { + const { done, value } = await iterator.next(); + if (done) { + controller.close(); + } else { + controller.enqueue(value); + } + }, + }); +} +export function streamFile(body: Readable): ReadableStream { + const data: ReadableStream = iteratorToStream(nodeStreamToIterator(body)); + return data; +} diff --git a/src/app/api/stream/[encryptedId]/route.ts b/src/app/api/stream/[encryptedId]/route.ts new file mode 100644 index 0000000..f720c12 --- /dev/null +++ b/src/app/api/stream/[encryptedId]/route.ts @@ -0,0 +1,101 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { CheckDownloadToken } from "~/app/actions"; + +import { decryptData } from "~/utils/encryptionHelper/hash"; +import { gdriveNoCache as gdrive } from "~/utils/gdriveInstance"; + +import config from "~/config/gIndex.config"; + +import { streamFile } from "./helper"; + +export const dynamic = "force-dynamic"; + +export async function GET( + request: NextRequest, + { + params: { encryptedId }, + }: { + params: { + encryptedId: string; + }; + }, +) { + try { + const sp = new URL(request.nextUrl).searchParams; + const token = sp.get("token"); + if (!token) throw new Error("Token not found"); + + // Only allow if referrer is from the same site + if (!request.headers.get("Referer")?.includes(config.basePath)) { + throw new Error("Invalid request"); + } + + const tokenValidity = await CheckDownloadToken(token); + if (!tokenValidity.success) throw new Error(tokenValidity.message); + + const decryptedId = await decryptData(encryptedId); + const _fileMeta = gdrive.files.get({ + fileId: decryptedId, + fields: "id, name, mimeType, size, fileExtension, webContentLink", + supportsAllDrives: config.apiConfig.isTeamDrive, + }); + const _fileContent = gdrive.files.get( + { + fileId: decryptedId, + alt: "media", + supportsAllDrives: config.apiConfig.isTeamDrive, + }, + { + responseType: "stream", + }, + ); + + const [fileMeta, fileContent] = await Promise.all([ + _fileMeta, + _fileContent, + ]); + + const fileSize = Number(fileMeta.data.size || 0); + if (!fileMeta.data.webContentLink) + throw new Error("No download link found"); + + const stream: ReadableStream = streamFile(fileContent.data); + const ranges = request.headers.get("Range"); + if (ranges) { + const [start, end] = ranges.replace(/bytes=/, "").split("-"); + const startByte = parseInt(start, 10); + const endByte = end ? parseInt(end, 10) : fileSize - 1; + return new NextResponse(stream, { + status: 206, + headers: { + "Content-Type": fileMeta.data.mimeType || "application/octet-stream", + "Content-Disposition": `attachment; filename="${encodeURIComponent( + fileMeta.data.name || `Untitled.${fileMeta.data.fileExtension}`, + )}"`, + "Content-Length": (endByte - startByte + 1).toString(), + "Accept-Ranges": "bytes", + "Content-Range": `bytes ${startByte}-${endByte}/${fileSize}`, + }, + }); + } + + return new NextResponse(stream, { + status: 200, + headers: { + "Content-Type": fileMeta.data.mimeType || "application/octet-stream", + "Content-Disposition": `attachment; filename="${encodeURIComponent( + fileMeta.data.name || `Untitled.${fileMeta.data.fileExtension}`, + )}"`, + "Content-Length": fileSize.toString(), + "Accept-Ranges": "bytes", + }, + }); + } catch (error) { + const e = error as Error; + console.error(e.message); + return new NextResponse(e.message, { + status: 500, + }); + } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 79c57fa..8050acd 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,5 +1,6 @@ import { Metadata } from "next"; import { JetBrains_Mono, Outfit, Source_Sans_3 } from "next/font/google"; +import "plyr-react/plyr.css"; import { cn } from "~/utils"; import { formatFooter } from "~/utils/footerFormatter"; diff --git a/yarn.lock b/yarn.lock index de412a0..eeb60d9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2859,6 +2859,13 @@ __metadata: languageName: node linkType: hard +"core-js@npm:^3.26.1": + version: 3.37.0 + resolution: "core-js@npm:3.37.0" + checksum: 10c0/7e00331f346318ca3f595c08ce9e74ddae744715aef137486c1399163afd79792fb94c3161280863adfdc3e30f8026912d56bd3036f93cacfc689d33e185f2ee + languageName: node + linkType: hard + "core-util-is@npm:~1.0.0": version: 1.0.3 resolution: "core-util-is@npm:1.0.3" @@ -2918,6 +2925,13 @@ __metadata: languageName: node linkType: hard +"custom-event-polyfill@npm:^1.0.7": + version: 1.0.7 + resolution: "custom-event-polyfill@npm:1.0.7" + checksum: 10c0/b73c90d646d78f4acdff5453fa0f165f6d5506e32d074ca57d27f2bb7d9412356dab8cec7c00a0956b069e4987a8546a2c2c4b866d155e9fc4c27d223a225b78 + languageName: node + linkType: hard + "damerau-levenshtein@npm:^1.0.8": version: 1.0.8 resolution: "damerau-levenshtein@npm:1.0.8" @@ -5181,6 +5195,13 @@ __metadata: languageName: node linkType: hard +"loadjs@npm:^4.2.0": + version: 4.3.0 + resolution: "loadjs@npm:4.3.0" + checksum: 10c0/8884520a7c5f3b0f6e4d3bc01d200c73b9c468986bea26acb54939d4a3f5da08f40f712812fadc1ff6030fca936e8c9eeb842aaafd287e32ca0ce6ae9f10e759 + languageName: node + linkType: hard + "locate-path@npm:^6.0.0": version: 6.0.0 resolution: "locate-path@npm:6.0.0" @@ -6229,6 +6250,7 @@ __metadata: next: "npm:^14.1.4" next-themes: "npm:^0.3.0" nextjs-toploader: "npm:^1.6.11" + plyr-react: "npm:^5.3.0" postcss: "npm:^8.4.38" prettier: "npm:3.0.0" prettier-plugin-tailwindcss: "npm:0.5.12" @@ -6750,6 +6772,37 @@ __metadata: languageName: node linkType: hard +"plyr-react@npm:^5.3.0": + version: 5.3.0 + resolution: "plyr-react@npm:5.3.0" + dependencies: + plyr: "npm:^3.7.7" + react-aptor: "npm:^2.0.0" + peerDependencies: + plyr: ^3.7.7 + react: ">=16.8" + peerDependenciesMeta: + plyr: + optional: false + react: + optional: true + checksum: 10c0/b338c5f07277c124663aa4f820dffb0700a67ac6eab0015b77985bb70c308da96bdffff6d103033544de6516cade63027f4c077871a0fa0feba996dfd3b6f2c0 + languageName: node + linkType: hard + +"plyr@npm:^3.7.7": + version: 3.7.8 + resolution: "plyr@npm:3.7.8" + dependencies: + core-js: "npm:^3.26.1" + custom-event-polyfill: "npm:^1.0.7" + loadjs: "npm:^4.2.0" + rangetouch: "npm:^2.0.1" + url-polyfill: "npm:^1.1.12" + checksum: 10c0/75c3e070f7829f76409e0d34784bf8070b827ad99c4713a36338af7ce8dff7cf38998403f34a4a0b4d6e99efd1856ee056443c7e838db1dbb98ed38828110a97 + languageName: node + linkType: hard + "possible-typed-array-names@npm:^1.0.0": version: 1.0.0 resolution: "possible-typed-array-names@npm:1.0.0" @@ -7001,6 +7054,25 @@ __metadata: languageName: node linkType: hard +"rangetouch@npm:^2.0.1": + version: 2.0.1 + resolution: "rangetouch@npm:2.0.1" + checksum: 10c0/5f7947d1c5e95f50630ed1e0cbaeb4c32a6be37b66d864a68f7e70a4e86f782eb869df9fa2357b981b1301d2158eff50002a199b0fbbbf6e1746bc9d213914d9 + languageName: node + linkType: hard + +"react-aptor@npm:^2.0.0": + version: 2.0.0 + resolution: "react-aptor@npm:2.0.0" + peerDependencies: + react: ">=16.8" + peerDependenciesMeta: + react: + optional: true + checksum: 10c0/f2f00b494a2cb93b0ee73949d2031c2f5a8233d169c3d16792e7c6a5177a75310155e85282209a6f74883b3564d1f3a46d5d4d03ee1c16224fbdb5d5665504f8 + languageName: node + linkType: hard + "react-colorful@npm:^5.6.1": version: 5.6.1 resolution: "react-colorful@npm:5.6.1" @@ -8554,6 +8626,13 @@ __metadata: languageName: node linkType: hard +"url-polyfill@npm:^1.1.12": + version: 1.1.12 + resolution: "url-polyfill@npm:1.1.12" + checksum: 10c0/69633c42e3182271d01d2f2f4acd889a9f54b88fd7b2f45fd84fed19eca7cfa98fb6bfbd43d9cd9fad222a11a2dffb562b8435cdaa67fca5e25e3da638741679 + languageName: node + linkType: hard + "url-template@npm:^2.0.8": version: 2.0.8 resolution: "url-template@npm:2.0.8"