Merge pull request #17 from mbahArip/v2

Stream file and other fix
This commit is contained in:
Arief Rachmawan
2024-05-08 10:06:15 +07:00
committed by GitHub
27 changed files with 1129 additions and 460 deletions
+2 -1
View File
@@ -46,14 +46,15 @@
"cmdk": "^1.0.0",
"date-fns": "^3.6.0",
"embla-carousel-react": "^8.0.2",
"fflate": "^0.8.2",
"googleapis": "^118.0.0",
"input-otp": "^1.2.3",
"jsonwebtoken": "^9.0.0",
"jszip": "^3.10.1",
"lucide-react": "^0.363.0",
"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",
+15 -2
View File
@@ -51,6 +51,9 @@ export default function FileGrid({ data }: Props) {
return new URL(path, config.basePath).pathname;
}, [data, pathname]);
const [thumbnailURL, setThumbnailURL] = useState<string>(
`/api/thumb/${data.encryptedId}?size=2`,
);
const [actionOpen, setActionOpen] = useState<boolean>(false);
const isDesktop = useMediaQuery("(min-width: 768px)");
@@ -220,13 +223,23 @@ export default function FileGrid({ data }: Props) {
data.mimeType.startsWith("image")) ? (
<>
<img
src={`/api/thumb/${data.encryptedId}`}
src={thumbnailURL}
alt={data.name}
onLoad={(e) => {
if (thumbnailURL.includes("size=2")) {
setThumbnailURL(`/api/thumb/${data.encryptedId}`);
}
}}
className='rounded-top-[var(--radius)] absolute -z-0 h-32 w-full flex-shrink-0 flex-grow-0 object-cover opacity-50'
/>
<img
src={`/api/thumb/${data.encryptedId}`}
src={thumbnailURL}
alt={data.name}
onLoad={(e) => {
if (thumbnailURL.includes("size=2")) {
setThumbnailURL(`/api/thumb/${data.encryptedId}`);
}
}}
className='relative z-0 h-32 w-full flex-shrink-0 flex-grow-0 object-contain backdrop-blur'
/>
+19 -6
View File
@@ -29,6 +29,7 @@ import {
import useMediaQuery from "~/hooks/useMediaQuery";
import bytesToReadable from "~/utils/bytesFormat";
import { durationToReadable } from "~/utils/durationFormat";
import { getPreviewIcon } from "~/utils/previewHelper";
import config from "~/config/gIndex.config";
@@ -42,14 +43,16 @@ export default function FileList({ data }: Props) {
const pathname = usePathname();
const filePath = useMemo<string>(() => {
// const currentPath = pathname.startsWith("/e") ? pathname : `/e${pathname}`;
// Set to pathname to remove the /e prefix
const path = [pathname, encodeURIComponent(data.name)]
.join("/")
.replace(/\/+/g, "/");
return new URL(path, config.basePath).pathname;
}, [data, pathname]);
const [thumbnailURL, setThumbnailURL] = useState<string>(
`/api/thumb/${data.encryptedId}?size=2`,
);
const [actionOpen, setActionOpen] = useState<boolean>(false);
const isDesktop = useMediaQuery("(min-width: 768px)");
@@ -218,9 +221,14 @@ export default function FileList({ data }: Props) {
data.mimeType.startsWith("image")) ? (
<>
<img
src={`/api/thumb/${data.encryptedId}`}
src={thumbnailURL}
alt={data.name}
className='size-16 flex-shrink-0 flex-grow-0 rounded-[var(--radius)] object-cover tablet:size-12'
onLoad={(e) => {
if (thumbnailURL.includes("size=2")) {
setThumbnailURL(`/api/thumb/${data.encryptedId}`);
}
}}
className='size-16 flex-shrink-0 flex-grow-0 rounded-[var(--radius)] object-cover tablet:size-20'
/>
{data.mimeType.startsWith("video") && (
@@ -230,6 +238,11 @@ export default function FileList({ data }: Props) {
className='absolute left-1/2 top-1/2 z-10 -translate-x-1/2 -translate-y-1/2 rounded-full bg-muted-foreground fill-muted p-1.5 text-muted opacity-75'
size={24}
/>
<div className='absolute bottom-0 right-0 z-10 bg-background px-1 py-0.5 text-[10px] text-foreground tablet:text-xs'>
{durationToReadable(
data.videoMediaMetadata?.durationMillis || 0,
)}
</div>
</>
)}
</>
@@ -240,13 +253,13 @@ export default function FileList({ data }: Props) {
? "Folder"
: getPreviewIcon(data.fileExtension || "", data.mimeType)
}
className='size-16 flex-shrink-0 flex-grow-0 p-3 tablet:size-12'
className='size-16 flex-shrink-0 flex-grow-0 p-3 tablet:size-20'
/>
)}
</div>
{/* File data */}
<div className='flex w-full flex-col'>
<div className='flex flex-grow flex-col'>
<span className='line-clamp-1 whitespace-pre-wrap break-all'>
{config.siteConfig.showFileExtension
? data.name
+11 -3
View File
@@ -503,6 +503,9 @@ export default function HeaderButton({ children }: PropsWithChildren) {
}
function SearchResultItem({ data }: { data: z.infer<typeof Schema_File> }) {
const [thumbnailURL, setThumbnailURL] = useState<string>(
`/api/thumb/${data.encryptedId}?size=2`,
);
const router = useRouter();
return (
<div
@@ -542,9 +545,14 @@ function SearchResultItem({ data }: { data: z.infer<typeof Schema_File> }) {
data.mimeType.startsWith("image")) ? (
<>
<img
src={`/api/thumb/${data.encryptedId}`}
src={thumbnailURL}
alt={data.name}
className='size-16 flex-shrink-0 flex-grow-0 rounded-[var(--radius)] object-cover tablet:size-12'
onLoad={(e) => {
if (thumbnailURL.includes("size=2")) {
setThumbnailURL(`/api/thumb/${data.encryptedId}`);
}
}}
className='size-16 flex-shrink-0 flex-grow-0 rounded-[var(--radius)] object-cover tablet:size-20'
/>
{data.mimeType.startsWith("video") && (
@@ -564,7 +572,7 @@ function SearchResultItem({ data }: { data: z.infer<typeof Schema_File> }) {
? "Folder"
: getPreviewIcon(data.fileExtension || "", data.mimeType)
}
className='size-16 flex-shrink-0 flex-grow-0 p-3 tablet:size-12'
className='size-16 flex-shrink-0 flex-grow-0 p-3 tablet:size-20'
/>
)}
</div>
+3 -3
View File
@@ -105,14 +105,14 @@ export default function Password({ path, checkPaths, errorMessage }: Props) {
className={cn("h-48 w-64 object-contain")}
/>
<div className='flex flex-col items-center justify-center'>
<h3 className='text-pretty text-center'>
<h3 className='text-balance text-center'>
{path === "global"
? "This site are password protected"
: "The folder or file you are trying to access is password protected"}
</h3>
<span className='muted text-pretty text-center'>
{/* <span className='muted text-pretty text-center'>
Please enter the password to access the content
</span>
</span> */}
</div>
<form
+55
View File
@@ -25,6 +25,7 @@ import { Separator } from "~/components/ui/separator";
import useMediaQuery from "~/hooks/useMediaQuery";
import bytesToReadable from "~/utils/bytesFormat";
import { durationToReadable } from "~/utils/durationFormat";
import { getFileType } from "~/utils/previewHelper";
import config from "~/config/gIndex.config";
@@ -42,6 +43,14 @@ export default function PreviewAction({ file }: Props) {
file.mimeType.startsWith("audio")
);
}, [file]);
const showViewDoc = useMemo<boolean>(() => {
const fileExtensionFallback = file.name.split(".").pop();
const fileExt = file.fileExtension ?? fileExtensionFallback;
if (!fileExt) return false;
const fileType = getFileType(fileExt, file.mimeType);
return fileType === "document" || fileType === "pdf";
}, [file]);
const fileInfo = useMemo<{ label: string; value: string }[]>(() => {
const value = [
{
@@ -154,6 +163,38 @@ export default function PreviewAction({ file }: Props) {
setDownloading(false);
}
};
const onOpenViewer = async (e: React.MouseEvent) => {
e.stopPropagation();
toast.loading("Creating view token...", {
id: `view-${file.encryptedId}`,
});
try {
const token = await CreateDownloadToken();
if (!token) throw new Error("Failed to create view token");
const streamURL = new URL(
`/api/stream/${file.encryptedId}`,
config.basePath,
);
streamURL.searchParams.set("token", token);
toast.success("Opening viewer link...", {
id: `view-${file.encryptedId}`,
});
const timeout = setTimeout(() => {
clearTimeout(timeout);
const viewerUrl = new URL(`/gview`, "https://docs.google.com");
viewerUrl.searchParams.set("url", streamURL.toString());
viewerUrl.searchParams.set("embedded", "true");
window.open(viewerUrl.toString(), "_blank");
}, 150);
} catch (error) {
const e = error as Error;
console.error(e.message);
toast.error(e.message, {
id: `view-${file.encryptedId}`,
});
}
};
return (
<div className='grid grid-cols-1 gap-3'>
@@ -279,6 +320,20 @@ export default function PreviewAction({ file }: Props) {
Raw Link
</Button>
) : null}
{showViewDoc ? (
<Button
size={"sm"}
variant={"outline"}
className='gap-3'
onClick={onOpenViewer}
>
<Icon
name='ExternalLink'
size={16}
/>
Open in Viewer
</Button>
) : null}
<Button
size={"sm"}
variant={"outline"}
+59 -16
View File
@@ -1,7 +1,7 @@
"use client";
import dynamic from "next/dynamic";
import { useEffect, useState } from "react";
import AudioPlayer from "react-h5-audio-player";
import "react-h5-audio-player/lib/styles.css";
import { z } from "zod";
import { Schema_File } from "~/schema";
@@ -10,7 +10,27 @@ import { cn } from "~/utils";
import Icon from "~/components/Icon";
import { CreateDownloadToken } from "./actions";
import "./r5-style.css";
// import "./r5-style.css";
const Plyr = dynamic(() => import("plyr-react"), {
ssr: false,
loading: () => (
<div
className={cn(
"h-auto min-h-[50dvh] w-full",
"flex flex-grow flex-col items-center justify-center gap-3",
)}
>
<Icon
name='LoaderCircle'
size={32}
className='animate-spin text-foreground'
/>
<p>Loading player...</p>
</div>
),
});
type Props = {
file: z.infer<typeof Schema_File>;
@@ -28,7 +48,7 @@ export default function PreviewAudio({ file }: Props) {
return;
}
const token = await CreateDownloadToken();
setAudioSrc(`/api/download/${file.encryptedId}?token=${token}`);
setAudioSrc(`/api/stream/${file.encryptedId}?token=${token}`);
} catch (error) {
const e = error as Error;
console.error(e);
@@ -66,20 +86,43 @@ export default function PreviewAudio({ file }: Props) {
</div>
) : (
<div className='w-full'>
<AudioPlayer
autoPlay={false}
layout='stacked-reverse'
src={audioSrc}
showJumpControls={false}
showFilledVolume
style={{
borderRadius: "var(--radius)",
<Plyr
source={{
type: "audio",
sources: [
{
src: audioSrc,
type: file.mimeType,
size: file.size,
},
],
title: file.name,
}}
onError={(e) => {
console.error(e);
setError(
"Could not preview this audio, try downloading the file",
);
options={{
controls: [
"play-large",
"play",
"progress",
"current-time",
"duration",
"mute",
"volume",
"settings",
"fullscreen",
],
volume: 0.5,
muted: false,
loop: {
active: false,
},
speed: {
selected: 1,
options: [0.5, 1, 1.5, 2],
},
keyboard: {
focused: true,
global: false,
},
}}
/>
</div>
+134 -62
View File
@@ -1,22 +1,75 @@
"use client";
import DocViewer, { DocViewerRenderers } from "@cyntler/react-doc-viewer";
import DocViewer, { DocRenderer } from "@cyntler/react-doc-viewer";
import { useEffect, useState } from "react";
import { z } from "zod";
import { Schema_File } from "~/schema";
import { cn } from "~/utils";
import Icon from "~/components/Icon";
import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert";
import config from "~/config/gIndex.config";
import { CreateDownloadToken } from "./actions";
const GoogleDocsViewerRenderer: DocRenderer = ({
mainState: { currentDocument, documentLoading },
}) => {
if (!currentDocument || !currentDocument.uri) return null;
const viewerUrl = new URL(`/gview`, "https://docs.google.com");
viewerUrl.searchParams.set("url", currentDocument.uri);
viewerUrl.searchParams.set("embedded", "true");
if (documentLoading) {
return (
<div
className={cn(
"h-auto min-h-[50dvh] w-full",
"flex flex-grow flex-col items-center justify-center gap-3",
)}
>
<Icon
name='LoaderCircle'
size={32}
className='animate-spin text-foreground'
/>
<p>Loading document...</p>
</div>
);
}
return (
<iframe
src={viewerUrl.toString()}
className='m-0 h-full max-h-[70dvh] min-h-[70dvh] w-full overflow-hidden rounded-[var(--radius)] border border-border p-0 !text-black'
frameBorder={0}
/>
);
};
GoogleDocsViewerRenderer.fileTypes = [
"application/vnd.google-apps.document",
"application/vnd.google-apps.presentation",
"application/vnd.google-apps.spreadsheet",
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.ms-powerpoint",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"text/csv",
"application/pdf",
"application/vnd.oasis.opendocument.text",
];
GoogleDocsViewerRenderer.weight = 100;
type Props = {
file: z.infer<typeof Schema_File>;
};
export default function PreviewDoc({ file }: Props) {
const [docSrc, setDocSrc] = useState<string>("");
const [docBuffer, setDocBuffer] = useState<ArrayBuffer>();
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string>("");
@@ -28,11 +81,12 @@ export default function PreviewDoc({ file }: Props) {
return;
}
const token = await CreateDownloadToken();
const buffer = await fetch(
`/api/download/${file.encryptedId}?token=${token}`,
).then((res) => res.arrayBuffer());
setDocBuffer(buffer);
setDocSrc(`/api/download/${file.encryptedId}?token=${token}`);
const streamURL = new URL(
`/api/stream/${file.encryptedId}`,
config.basePath,
);
streamURL.searchParams.set("token", token);
setDocSrc(streamURL.toString());
} catch (error) {
const e = error as Error;
console.error(e);
@@ -69,61 +123,79 @@ export default function PreviewDoc({ file }: Props) {
<span className='text-center text-destructive'>{error}</span>
</div>
) : (
<DocViewer
key={file.encryptedId}
documents={[
{
uri: docSrc,
fileData: docBuffer,
fileName: file.name,
fileType: file.mimeType,
},
]}
pluginRenderers={DocViewerRenderers}
config={{
header: {
disableHeader: true,
},
loadingRenderer: {
overrideComponent: () => (
<div
className={cn(
"h-auto min-h-[50dvh] w-full",
"flex flex-grow flex-col items-center justify-center gap-3",
)}
>
<Icon
name='LoaderCircle'
size={32}
className='animate-spin text-foreground'
/>
<p>Loading document...</p>
</div>
),
showLoadingTimeout: 10000,
},
noRenderer: {
overrideComponent: () => (
<div className='flex h-full flex-col items-center justify-center gap-3'>
<Icon
name='CircleX'
size={24}
className='text-destructive'
/>
<span className='text-center text-destructive'>
Error loading document
</span>
</div>
),
},
}}
className={cn(
"h-full max-h-[70dvh] min-h-[70dvh] w-full rounded-[var(--radius)] border border-border !text-black",
)}
theme={{
disableThemeScrollbar: true,
}}
/>
<div className='h-fit w-full space-y-3 overflow-hidden rounded-[var(--radius)]'>
<DocViewer
key={file.encryptedId}
documents={[
{
uri: docSrc,
fileName: file.name,
fileType: file.mimeType,
},
]}
pluginRenderers={[GoogleDocsViewerRenderer]}
config={{
header: {
disableHeader: true,
},
loadingRenderer: {
overrideComponent: () => (
<div
className={cn(
"h-auto min-h-[50dvh] w-full",
"flex flex-grow flex-col items-center justify-center gap-3",
)}
>
<Icon
name='LoaderCircle'
size={32}
className='animate-spin text-foreground'
/>
<p>Loading document...</p>
</div>
),
showLoadingTimeout: 10000,
},
noRenderer: {
overrideComponent: () => (
<div className='flex h-full flex-col items-center justify-center gap-3'>
<Icon
name='CircleX'
size={24}
className='text-destructive'
/>
<span className='text-center text-destructive'>
Error loading document
</span>
</div>
),
},
}}
className={cn(
"h-full max-h-[70dvh] min-h-[70dvh] w-full overflow-hidden rounded-[var(--radius)] !text-black",
"rounded-[var(--radius)] [&>div#proxy-renderer]:overflow-hidden ",
)}
theme={{
disableThemeScrollbar: true,
}}
/>
<Alert className='bg-yellow-50 text-yellow-600 dark:bg-yellow-950 dark:text-yellow-500'>
<div className='flex items-start gap-3'>
<Icon
name='TriangleAlert'
className='size-5'
/>
<div className='flex flex-col'>
<AlertTitle>Preview Only</AlertTitle>
<AlertDescription>
It might failed to load the document, you can try to refresh
the page or download the document to view it.
</AlertDescription>
</div>
</div>
</Alert>
</div>
)}
</div>
);
+59 -23
View File
@@ -6,6 +6,9 @@ import { Schema_File } from "~/schema";
import { cn } from "~/utils";
import Icon from "~/components/Icon";
import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert";
import config from "~/config/gIndex.config";
import { CreateDownloadToken } from "./actions";
@@ -13,7 +16,10 @@ type Props = {
file: z.infer<typeof Schema_File>;
};
export default function PreviewImage({ file }: Props) {
const [imgSrc, setImgSrc] = useState<string>("");
const [imgSrc, setImgSrc] = useState<string>(
`/api/thumb/${file.encryptedId}?size=4`,
);
const [imgLoaded, setImgLoaded] = useState<boolean>(false);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string>("");
@@ -25,27 +31,30 @@ export default function PreviewImage({ file }: Props) {
return;
}
const token = await CreateDownloadToken();
await fetch(`/api/download/${file.encryptedId}?token=${token}`)
const streamURL = new URL(
`/api/thumb/${file.encryptedId}?size=1000`,
config.basePath,
);
streamURL.searchParams.set("token", token);
fetch(streamURL, {
headers: {
Range: `bytes=0-${(file.size || 1) - 1}`,
},
})
.then((res) => {
if (!res.ok) throw new Error("Failed to fetch image");
if (!res.ok) {
throw new Error("Could not load 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);
const urlobject = URL.createObjectURL(blob);
setImgSrc(urlobject);
const timeout = setTimeout(() => {
setImgLoaded(true);
clearTimeout(timeout);
}, 150); // Add a delay to show the image
});
} catch (error) {
const e = error as Error;
@@ -83,11 +92,38 @@ export default function PreviewImage({ file }: Props) {
<span className='text-center text-destructive'>{error}</span>
</div>
) : (
<img
src={imgSrc}
alt={file.name}
className='max-h-[70dvh] w-full rounded-[var(--radius)] bg-muted object-contain object-center'
/>
<div className='h-fit w-full space-y-3 overflow-hidden rounded-[var(--radius)]'>
<img
src={imgSrc}
alt={file.name}
className={cn(
"h-full max-h-[70dvh] w-full bg-muted object-contain object-center transition",
imgLoaded ? "blur-none" : "animate-pulse blur",
)}
onError={(e) => {
console.error(e);
setError(
"Could not preview this image, try downloading the file",
);
}}
/>
<Alert className='bg-yellow-50 text-yellow-600 dark:bg-yellow-950 dark:text-yellow-500'>
<div className='flex items-start gap-3'>
<Icon
name='TriangleAlert'
className='size-5'
/>
<div className='flex flex-col'>
<AlertTitle>Preview Only</AlertTitle>
<AlertDescription>
This image is a preview and may not be the full resolution.
Please download the file for the full resolution.
</AlertDescription>
</div>
</div>
</Alert>
</div>
)}
</div>
);
+66 -34
View File
@@ -3,11 +3,15 @@
import { useState } from "react";
import { z } from "zod";
import { Schema_File } from "~/schema";
import { cn } from "~/utils";
import Icon from "~/components/Icon";
import { Card, CardContent } from "~/components/ui/card";
import { getFileType } from "~/utils/previewHelper";
import config from "~/config/gIndex.config";
import PreviewAction from "./@preview.action";
import PreviewAudio from "./@preview.audio";
import PreviewDoc from "./@preview.doc";
@@ -26,50 +30,78 @@ export default function FilePreviewLayout({ data, fileType }: Props) {
const [view, setView] = useState<"markdown" | "raw">("markdown");
return (
<>
<div className='flex flex-col gap-3'>
<Card>
<RichHeader
title={data.name}
view={view}
onViewChange={setView}
fileType={fileType}
/>
<CardContent className='p-1.5 pt-0 tablet:p-3 tablet:pt-0'>
<div className='px-3'>
{fileType === "image" ? (
<PreviewImage file={data} />
) : fileType === "audio" ? (
<PreviewAudio file={data} />
) : fileType === "video" ? (
<PreviewVideo file={data} />
) : fileType === "code" ? (
<PreviewRich
file={data}
code
view={view}
{config.apiConfig.streamMaxSize &&
Number(data.size || 0) > config.apiConfig.streamMaxSize ? (
<div
className={cn(
"h-auto min-h-[33dvh] w-full",
"flex flex-grow flex-col items-center justify-center gap-3",
)}
>
<Icon
name='Frown'
size={32}
className='text-muted-foreground'
/>
) : fileType === "text" ? (
<PreviewRich
file={data}
view={view}
/>
) : fileType === "markdown" ? (
<PreviewRich
file={data}
view={view}
/>
) : fileType === "document" ? (
<PreviewDoc file={data} />
) : fileType === "pdf" ? (
<PreviewDoc file={data} />
) : fileType === "manga" ? (
<PreviewManga file={data} />
) : (
<PreviewUnknown />
)}
</div>
<h4 className='text-muted-foreground'>Preview not available</h4>
<p className='text-center text-muted-foreground tablet:text-sm'>
Looks like this file size exceed the preview size limit
</p>
</div>
) : (
/**
* TODO: Might need a better way to handle large files preview
* like manga, pdf, etc
*
* For now it's downloading the whole file and then previewing it
* which is not a good implementation
*/
<div className='px-3'>
{fileType === "image" ? (
<PreviewImage file={data} />
) : fileType === "audio" ? (
<PreviewAudio file={data} />
) : fileType === "video" ? (
<PreviewVideo file={data} />
) : fileType === "code" ? (
<PreviewRich
file={data}
code
view={view}
/>
) : fileType === "text" ? (
<PreviewRich
file={data}
view={view}
/>
) : fileType === "markdown" ? (
<PreviewRich
file={data}
view={view}
/>
) : fileType === "document" ? (
<PreviewDoc file={data} />
) : fileType === "pdf" ? (
<PreviewDoc file={data} />
) : fileType === "manga" ? (
<PreviewManga file={data} />
) : (
<PreviewUnknown />
)}
</div>
)}
</CardContent>
</Card>
<PreviewAction file={data} />
</>
</div>
);
}
+180 -49
View File
@@ -1,12 +1,14 @@
"use client";
import JSZip from "jszip";
import { useEffect, useState } from "react";
import { AsyncUnzipInflate, Unzip } from "fflate";
import { useEffect, useRef, useState } from "react";
import { z } from "zod";
import { Schema_File } from "~/schema";
import { cn } from "~/utils";
import Icon from "~/components/Icon";
import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert";
import { Button } from "~/components/ui/button";
import {
Carousel,
CarouselApi,
@@ -15,9 +17,12 @@ import {
CarouselNext,
CarouselPrevious,
} from "~/components/ui/carousel";
import { Progress } from "~/components/ui/progress";
import useMediaQuery from "~/hooks/useMediaQuery";
import config from "~/config/gIndex.config";
import { CreateDownloadToken } from "./actions";
type Props = {
@@ -31,40 +36,101 @@ export default function PreviewManga({ file }: Props) {
const [currentImage, setCurrentImage] = useState<number>(1);
const [viewSize, setViewSize] = useState<"fit" | "full">("fit");
const [api, setApi] = useState<CarouselApi>();
const [loadedPercent, setLoadedPercent] = useState<number>(0);
const [loadFirstX, setLoadFirstX] = useState<number>(5); // 5MB
const abortController = useRef<AbortController>(new AbortController());
const isDesktop = useMediaQuery("(min-width: 768px)");
useEffect(() => {
(async () => {
try {
if (!abortController.current) {
abortController.current = new AbortController();
}
if (!file.encryptedWebContentLink) {
setError("No video to preview");
return;
}
const token = await CreateDownloadToken();
const manga = await fetch(
`/api/download/${file.encryptedId}?token=${token}`,
const streamURL = new URL(
`/api/stream/${file.encryptedId}`,
config.basePath,
);
const archiveBlob = await manga.blob();
const zipData = await JSZip.loadAsync(archiveBlob);
streamURL.searchParams.set("token", token);
const tempArray: { name: string; blob: string }[] = [];
const files = Object.values(zipData.files);
for (const file of files) {
const f = zipData.file(file.name);
if (!f) continue;
const blob = await f.async("blob");
const reader = new FileReader();
reader.onload = () => {
setImages((prev) => {
const exist = prev.find((p) => p.name === file.name);
if (exist) return prev;
return [
...prev,
{ name: file.name, blob: reader.result as string },
];
});
};
reader.readAsDataURL(blob);
const bufferStream = await fetch(streamURL, {
signal: abortController.current.signal,
headers: {
Range: `bytes=0-${
Math.min(Number(file.size || 1), loadFirstX * 1024 * 1024) - 1
}`,
},
});
const contentLength = bufferStream.headers.get("Content-Length");
const totalBytes = parseInt(contentLength || "0", 10);
const reader = bufferStream.body?.getReader();
if (reader) {
const chunks: Uint8Array[] = [];
let receivedLength = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (value) {
chunks.push(value);
receivedLength += value.length;
const percent = (receivedLength / totalBytes) * 100;
setLoadedPercent((prev) => {
if (percent > prev) return percent;
return prev;
});
}
}
const buffer = new Uint8Array(receivedLength);
let position = 0;
for (const chunk of chunks) {
buffer.set(chunk, position);
position += chunk.length;
}
const unzip = new Unzip((file) => {
file.ondata = (err, data, final) => {
if (err) throw err;
// Check if the file is an image
if (
!file.name
.toLowerCase()
.includes(".jpg" || ".jpeg" || ".png" || ".gif" || ".webp")
)
return;
if (!final) return; // Skip if not fully loaded
const blob = new Blob([data]);
const reader = new FileReader();
reader.onload = () => {
setImages((prev) => {
const exist = prev.find((p) => p.name === file.name);
if (exist) return prev;
return [
...prev,
{ name: file.name, blob: reader.result as string },
];
});
};
reader.readAsDataURL(blob);
};
file.start();
});
unzip.register(AsyncUnzipInflate);
unzip.push(buffer);
}
} catch (error) {
const e = error as Error;
@@ -74,7 +140,12 @@ export default function PreviewManga({ file }: Props) {
setLoading(false);
}
})();
}, [file]);
// return () => {
// abortController.current.abort("Cancelled by user");
// };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (!api) return;
@@ -99,7 +170,31 @@ export default function PreviewManga({ file }: Props) {
size={32}
className='animate-spin text-foreground'
/>
<p>Loading manga content...</p>
<p>Please wait while we downloading the content for preview</p>
<div className='flex w-full max-w-sm flex-col gap-1'>
<Progress
className='h-2 w-full'
value={loadedPercent}
/>
<span className='text-center text-xs text-muted-foreground'>
{Math.round(loadedPercent)}% loaded
</span>
</div>
<Button
size={"sm"}
variant={"secondary"}
onClick={async () => {
abortController.current.abort("Cancelled by user");
const timeout = setTimeout(() => {
setLoading(false);
setError("Preview cancelled by user");
clearTimeout(timeout);
}, 100);
}}
>
Cancel Preview
</Button>
</div>
) : error ? (
<div className='flex h-full flex-col items-center justify-center gap-3'>
@@ -111,7 +206,7 @@ export default function PreviewManga({ file }: Props) {
<span className='text-center text-destructive'>{error}</span>
</div>
) : (
<div className='flex flex-col gap-3'>
<div className='flex w-full flex-col items-center justify-center gap-3'>
<div className='h-full w-full tablet:px-8'>
<Carousel
className={cn(
@@ -124,24 +219,28 @@ export default function PreviewManga({ file }: Props) {
setApi={setApi}
>
<CarouselContent className='h-full w-full'>
{images.map((image, index) => (
<CarouselItem
key={index}
className='flex h-full flex-col items-center justify-center'
>
<img
src={image.blob}
alt={`${image.name} - Page ${index + 1}`}
className={cn(
"w-full object-contain",
viewSize === "fit" ? "h-full max-h-[70dvh]" : "h-full",
)}
/>
<span className='muted text-center text-xs'>
{image.name}
</span>
</CarouselItem>
))}
{images
.sort((a, b) => a.name.localeCompare(b.name))
.map((image, index) => (
<CarouselItem
key={index}
className='flex h-full flex-col items-center justify-center'
>
<img
src={image.blob}
alt={`${image.name} - Page ${index + 1}`}
className={cn(
"w-full object-contain",
viewSize === "fit"
? "h-full max-h-[70dvh]"
: "h-full",
)}
/>
<span className='muted text-center text-xs'>
{image.name}
</span>
</CarouselItem>
))}
</CarouselContent>
{isDesktop ? (
<>
@@ -151,11 +250,27 @@ export default function PreviewManga({ file }: Props) {
) : null}
</Carousel>
</div>
<div className='flex w-full items-center justify-end gap-1.5'>
<span className='muted text-sm'>
<div className='flex w-full items-center justify-end gap-3'>
<span className='text-primary-foreground'>
{currentImage}/{images.length}
</span>
<div
<Button
size='icon'
variant={"ghost"}
className='aspect-square size-8 p-0.5'
onClick={() =>
setViewSize((prev) => {
if (prev === "fit") return "full";
return "fit";
})
}
>
<Icon
name={viewSize === "fit" ? "Maximize" : "Minimize"}
size={16}
/>
</Button>
{/* <div
className='relative z-10 cursor-pointer p-0.5 text-foreground/80 transition hover:text-foreground'
onClick={() => setViewSize(viewSize === "fit" ? "full" : "fit")}
>
@@ -163,8 +278,24 @@ export default function PreviewManga({ file }: Props) {
name={viewSize === "fit" ? "Maximize" : "Minimize"}
size={14}
/>
</div>
</div> */}
</div>
<Alert className='bg-yellow-50 text-yellow-600 dark:bg-yellow-950 dark:text-yellow-500'>
<div className='flex items-start gap-3'>
<Icon
name='TriangleAlert'
className='size-5'
/>
<div className='flex flex-col'>
<AlertTitle>Preview Only</AlertTitle>
<AlertDescription>
We only load the first {loadFirstX}MB of the file for preview.
Please download the file for full content.
</AlertDescription>
</div>
</div>
</Alert>
</div>
)}
</div>
+12 -9
View File
@@ -36,7 +36,7 @@ export default function PreviewRich({ file, code, view }: Props) {
// if (code) {
// setContent(`\`\`\`${file.fileExtension}\n${text}\`\`\``);
// } else {
setContent(text);
setContent(text.trim());
// }
} catch (error) {
const e = error as Error;
@@ -91,7 +91,7 @@ export default function PreviewRich({ file, code, view }: Props) {
<Markdown
content={
code && view === "markdown"
? `\`\`\`${file.fileExtension}\n${content}\`\`\``
? `\`\`\`${file.fileExtension}\n${content}`
: content
}
view={view}
@@ -99,23 +99,26 @@ export default function PreviewRich({ file, code, view }: Props) {
</div>
<div
className={cn(
"absolute bottom-0 z-10 flex w-full items-center justify-center py-3 transition",
expand
? "pointer-events-none opacity-0"
: "pointer-events-auto opacity-100",
"bottom-0 z-10 flex w-full items-center justify-center py-3 transition",
expand ? "relative" : "absolute",
// expand
// ? "pointer-events-none opacity-0"
// : "pointer-events-auto opacity-100",
)}
>
<Button
size={"sm"}
variant={"secondary"}
className='gap-1.5'
onClick={() => {
setExpand(true);
setExpand((prev) => !prev);
}}
>
<Icon
name='ChevronDown'
name={expand ? "ChevronUp" : "ChevronDown"}
size={16}
/>
Expand
{expand ? "Collapse" : "Expand"}
</Button>
</div>
</div>
+63 -26
View File
@@ -1,7 +1,7 @@
"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";
@@ -10,6 +10,25 @@ import Icon from "~/components/Icon";
import { CreateDownloadToken } from "./actions";
const Plyr = dynamic(() => import("plyr-react"), {
ssr: false,
loading: () => (
<div
className={cn(
"h-auto min-h-[50dvh] w-full",
"flex flex-grow flex-col items-center justify-center gap-3",
)}
>
<Icon
name='LoaderCircle'
size={32}
className='animate-spin text-foreground'
/>
<p>Loading player...</p>
</div>
),
});
type Props = {
file: z.infer<typeof Schema_File>;
};
@@ -26,7 +45,7 @@ export default function PreviewVideo({ file }: Props) {
return;
}
const token = await CreateDownloadToken();
setVideoSrc(`/api/download/${file.encryptedId}?token=${token}`);
setVideoSrc(`/api/stream/${file.encryptedId}?token=${token}`);
} catch (error) {
const e = error as Error;
console.error(e);
@@ -63,30 +82,48 @@ export default function PreviewVideo({ file }: Props) {
<span className='text-center text-destructive'>{error}</span>
</div>
) : (
<ReactPlayer
key={file.encryptedId}
url={videoSrc}
controls
pip
wrapper={({ children }) => (
<div className='h-[60dvh] w-full overflow-hidden rounded-[var(--radius)] bg-muted'>
{children}
</div>
)}
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?)");
}
}}
/>
<div className='h-full w-full'>
<Plyr
source={{
type: "video",
sources: [
{
src: videoSrc,
type: file.mimeType,
size: file.size,
},
],
poster: `/api/thumb/${file.encryptedId}?size=1000`,
}}
// crossOrigin='anonymous'
options={{
toggleInvert: true,
settings: ["quality", "speed"],
ratio: "16:9",
controls: [
"play-large",
"play",
"progress",
"current-time",
"duration",
"mute",
"volume",
"settings",
"pip",
"download",
"fullscreen",
],
volume: 0.5,
muted: false,
loop: { active: false },
speed: { selected: 1, options: [0.5, 1, 1.5, 2] },
keyboard: {
focused: true,
global: true,
},
}}
/>
</div>
)}
</div>
);
+1
View File
@@ -24,6 +24,7 @@ export default function Readme({ content, title }: Props) {
title={title}
view={view}
onViewChange={setView}
fileType={"markdown"}
/>
<CardContent className='p-1.5 pt-0 tablet:p-3 tablet:pt-0'>
<Markdown
+36 -22
View File
@@ -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<typeof getFileType> | "unknown";
};
export default function RichHeader({ title, view, onViewChange }: Props) {
export default function RichHeader({
title,
view,
onViewChange,
fileType,
}: Props) {
return (
<CardHeader className='pb-0'>
<div className='flex flex-col gap-3 mobile:flex-row mobile:items-center mobile:justify-between'>
<CardTitle>{title}</CardTitle>
<div className='flex w-full items-center mobile:w-fit'>
<Button
size={"sm"}
variant={view === "markdown" ? "default" : "secondary"}
onClick={() => onViewChange("markdown")}
className='w-full rounded-r-none mobile:w-fit'
>
Markdown
</Button>
<Button
size={"sm"}
variant={view === "raw" ? "default" : "secondary"}
onClick={() => onViewChange("raw")}
className='w-full rounded-l-none mobile:w-fit'
>
Raw
</Button>
</div>
<div className='flex flex-col gap-3 overflow-hidden mobile:flex-row mobile:items-center mobile:justify-between'>
{/* <CardTitle> */}
<h3 className='line-clamp-1 flex-grow whitespace-pre-wrap break-all'>
{title}
</h3>
{/* </CardTitle> */}
{["markdown", "code", "text"].includes(fileType) && (
<div className='flex w-full items-center mobile:w-fit'>
<Button
size={"sm"}
variant={view === "markdown" ? "default" : "secondary"}
onClick={() => onViewChange("markdown")}
className='w-full rounded-r-none mobile:w-fit'
>
Markdown
</Button>
<Button
size={"sm"}
variant={view === "raw" ? "default" : "secondary"}
onClick={() => onViewChange("raw")}
className='w-full rounded-l-none mobile:w-fit'
>
Raw
</Button>
</div>
)}
</div>
<Separator />
</CardHeader>
+18 -2
View File
@@ -306,6 +306,7 @@ export function Configuration() {
const configContent: string = `import { z } from "zod";
import { Schema_Config } from "~/schema";
import isDev from "~/utils/isDev";
const config: z.input<typeof Schema_Config> = {
/**
@@ -322,8 +323,7 @@ const config: z.input<typeof Schema_Config> = {
* @default process.env.NEXT_PUBLIC_DOMAIN
* @fallback process.env.NEXT_PUBLIC_VERCEL_URL
*/
basePath:
process.env.NODE_ENV === "development"
basePath: isDev
? "http://localhost:3000"
: \`https://\${process.env.NEXT_PUBLIC_DOMAIN || process.env.NEXT_PUBLIC_VERCEL_URL}\`,
@@ -412,6 +412,21 @@ const config: z.input<typeof Schema_Config> = {
* Default: true
*/
proxyThumbnail: ${configuration.api.proxyThumbnail ? "true" : "false"},
/**
* Only show preview for files that are smaller than this size
* If the file is larger than this size, it will show "can't preview" message instead
*
* Why?
* Since the stream endpoint are counted as a bandwidth usage
* I want to limit the preview to only small files
* It also to prevent abuse from the user
*
* You can also set this to 0 to disable the limit
*
* Default: 100MB
*/
streamMaxSize: ${100 * 1024 * 1024},
/**
* Special file name that will be used for certain purposes
@@ -540,6 +555,7 @@ const config: z.input<typeof Schema_Config> = {
footer: [
"{{ siteName }} *v{{ version }}* @ {{ repository }}",
"{{ year }} - Made with ❤️ by **{{ author }}**",
isDev ? "Development Mode" : "",
],
/**
+2 -1
View File
@@ -268,7 +268,8 @@ export async function CheckPassword(
const currentFolder = paths[folderIndex];
if (!cookiesValue[currentFolder.id])
throw {
message: `Password for '${currentFolder.path}' is not set, please enter the password`,
// message: `Password for '${currentFolder.path}' is not set, please enter the password`,
message: `Please enter password for '${currentFolder.path}'`,
path: currentFolder.id,
};
+15 -22
View File
@@ -57,22 +57,8 @@ If you've already entered the password, please make sure your browser is not blo
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, filePaths] = await Promise.all([
_fileMeta,
_fileContent,
_filePaths,
]);
const [fileMeta, filePaths] = await Promise.all([_fileMeta, _filePaths]);
if (!config.apiConfig.allowDownloadProtectedFile) {
const checkPath = await CheckPaths(filePaths.split("/"));
@@ -112,16 +98,24 @@ If you've already entered the password, please make sure your browser is not blo
config.apiConfig.maxFileSize &&
fileSize > config.apiConfig.maxFileSize
) {
console.log("File size is too large, redirecting to webContentLink");
return NextResponse.redirect(fileMeta.data.webContentLink, {
const contentUrl = new URL(fileMeta.data.webContentLink);
contentUrl.searchParams.set("confirm", "1");
return NextResponse.redirect(contentUrl, {
status: 302,
headers: {
...request.headers,
"Cache-Control": config.cacheControl,
},
});
}
const fileContent = await gdrive.files.get(
{
fileId: decryptedId,
alt: "media",
supportsAllDrives: config.apiConfig.isTeamDrive,
},
{
responseType: "stream",
},
);
const fileBuffer = await new Promise<Buffer>((resolve, reject) => {
const chunks: Buffer[] = [];
fileContent.data.on("data", (chunk) => {
@@ -138,7 +132,6 @@ If you've already entered the password, please make sure your browser is not blo
return new NextResponse(fileBuffer, {
status: 200,
headers: {
...request.headers,
"Content-Type": fileMeta.data.mimeType || "application/octet-stream",
"Content-Length": fileBuffer.length.toString(),
"Content-Disposition": `attachment; filename="${encodeURIComponent(
@@ -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<any>) {
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;
}
+174
View File
@@ -0,0 +1,174 @@
import { NextRequest, NextResponse } from "next/server";
import {
CheckDownloadToken,
CheckPassword,
CheckPaths,
RedirectSearchFile,
} from "~/app/actions";
import { decryptData } from "~/utils/encryptionHelper/hash";
import { gdriveNoCache as gdrive } from "~/utils/gdriveInstance";
import isDev from "~/utils/isDev";
import config from "~/config/gIndex.config";
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 the request is from the same domain or the referer is the same domain
if (!isDev && !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 _filePaths = RedirectSearchFile(encryptedId);
const _fileMeta = gdrive.files.get(
{
fileId: decryptedId,
fields: "id, name, mimeType, size, fileExtension, webContentLink",
supportsAllDrives: config.apiConfig.isTeamDrive,
},
{
headers: {
"Accept-Ranges": "bytes",
"Range": request.headers.get("Range") || `bytes=0-${1024 * 1024 - 1}`,
},
},
);
const [filePaths, fileMeta] = await Promise.all([_filePaths, _fileMeta]);
const isFull =
Number(request.headers.get("Range")?.split("-")[1] || 0) ===
Number(fileMeta.data.size || "1") - 1;
const fileSize = Number(fileMeta.data.size || 0);
if (!fileMeta.data.webContentLink)
throw new Error("No download link found");
if (
config.apiConfig.streamMaxSize &&
fileSize > config.apiConfig.streamMaxSize
) {
throw new Error("File is too large to stream");
}
if (!config.apiConfig.allowDownloadProtectedFile) {
const checkPath = await CheckPaths(filePaths.split("/"));
if (!checkPath.success) throw new Error("File not found");
const unlocked = await CheckPassword(checkPath.data);
if (!unlocked.success) {
if (!unlocked.path)
throw new Error("No path returned from password checking");
const lockedIndex = checkPath.data.findIndex(
(path) => path.id === unlocked.path,
);
// Get all path until the locked index, then join them
const path = checkPath.data
.slice(0, lockedIndex + 1)
.map((path) => path.path)
.join("/");
return new NextResponse(
`The file you're trying to access is protected by password.
Please open the file link and enter the password to access the file, then try to download the file again.
Protected Path: ${new URL(path, config.basePath).toString()}
If you've already entered the password, please make sure your browser is not blocking cookies from this site.`,
{
status: 401,
},
);
}
}
const ranges = request.headers.get("Range") || "bytes=0-";
const chunkSize = 5 * 1024 * 1024; // Load 5MB at a time
let rangeStart = 0;
let rangeEnd = Math.min(chunkSize, fileSize - 1);
const rangeRegex = /bytes=(\d+)-(\d+)?/;
const rangeSize = ranges.match(rangeRegex);
if (rangeSize) {
rangeStart = parseInt(rangeSize[1], 10);
if (isFull) {
rangeEnd = fileSize - 1;
} else {
rangeEnd = Math.min(rangeStart + chunkSize, fileSize - 1);
}
}
const contentRange = `bytes=${rangeStart}-${rangeEnd}/${fileSize}`;
const contentLength = rangeEnd ? rangeEnd - rangeStart + 1 : fileSize;
const fileContent = await gdrive.files.get(
{
fileId: decryptedId,
alt: "media",
supportsAllDrives: config.apiConfig.isTeamDrive,
acknowledgeAbuse: true,
},
{
responseType: "stream",
headers: {
"Accept-Ranges": "bytes",
"Range": `bytes=${rangeStart}-${rangeEnd}`,
},
},
);
const stream = fileContent.data as NodeJS.ReadableStream;
const fileRange = fileContent.headers["content-range"];
const fileLength = fileContent.headers["content-length"];
const readable = new ReadableStream({
start(controller) {
stream.on("data", (chunk) => {
controller.enqueue(chunk);
});
stream.on("end", () => {
controller.close();
});
stream.on("error", (error) => {
controller.error(error);
});
},
});
return new NextResponse(readable, {
status: 206,
headers: {
"Content-Range": fileRange || contentRange,
"Content-Length": fileLength || contentLength.toString(),
"Content-Type": fileMeta.data.mimeType || "application/octet-stream",
"Accept-Ranges": "bytes",
},
});
} catch (error) {
const e = error as Error;
console.error(e.message);
return new NextResponse(e.message, {
status: 500,
});
}
}
+32 -79
View File
@@ -1,7 +1,8 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { decryptData } from "~/utils/encryptionHelper/hash";
import gdrive from "~/utils/gdriveInstance";
import isDev from "~/utils/isDev";
import config from "~/config/gIndex.config";
@@ -16,6 +17,19 @@ export async function GET(
{ params: { encryptedId } }: Props,
) {
try {
const searchParams = new URL(request.nextUrl).searchParams;
const size = searchParams.get("size") || "512";
// Only allow if the request is from the same domain or the referer is the same domain
if (!isDev && !request.headers.get("Referer")?.includes(config.basePath)) {
throw new Error("Invalid request");
}
const validSize = z.coerce.number().safeParse(size);
if (!validSize.success) {
throw new Error("Invalid size");
}
const defaultImage = NextResponse.redirect(
new URL("/og.png", config.basePath),
{
@@ -23,86 +37,25 @@ export async function GET(
},
);
const decryptedId = await decryptData(encryptedId);
const _fileMeta = gdrive.files.get({
fileId: decryptedId,
fields:
"id, name, mimeType, fileExtension, webContentLink, thumbnailLink",
supportsAllDrives: config.apiConfig.isTeamDrive,
const url = `https://drive.google.com/thumbnail?id=${decryptedId}&sz=w${size}`;
if (!config.apiConfig.proxyThumbnail) {
return NextResponse.redirect(url);
}
const downloadThumb = await fetch(url, {
cache: "force-cache",
});
const _fileContent = gdrive.files.get(
{
fileId: decryptedId,
alt: "media",
supportsAllDrives: config.apiConfig.isTeamDrive,
const buffer = await downloadThumb.arrayBuffer();
return new NextResponse(buffer, {
headers: {
"Cache-Control": "public, max-age=31536000, immutable",
"Content-Type": "image/jpeg",
"Content-Length": buffer.byteLength.toString(),
},
{
responseType: "stream",
},
);
const [fileMeta, fileContent] = await Promise.all([
_fileMeta,
_fileContent,
]);
const fileSize = Number(fileMeta.data.size || 0);
if (!fileMeta.data.webContentLink) return defaultImage;
if (!fileMeta.data.thumbnailLink) return defaultImage;
if (
!fileMeta.data.mimeType?.startsWith("image") &&
!fileMeta.data.mimeType?.startsWith("video")
)
return defaultImage;
// If svg, return actual image since there is no thumbnail for svg
if (
fileMeta.data.mimeType?.includes("svg") &&
fileSize <= config.apiConfig.maxFileSize
) {
const fileBuffer = await new Promise<Buffer>((resolve, reject) => {
const chunks: Buffer[] = [];
fileContent.data.on("data", (chunk) => {
chunks.push(chunk);
});
fileContent.data.on("end", () => {
resolve(Buffer.concat(chunks));
});
fileContent.data.on("error", (err) => {
reject(err);
});
});
return new NextResponse(fileBuffer, {
headers: {
"Cache-Control": "public, max-age=31536000, immutable",
"Content-Type": fileMeta.data.mimeType || "application/octet-stream",
"Content-Length": fileBuffer.length.toString(),
"Content-Disposition": `attachment; filename="${encodeURIComponent(
fileMeta.data.name || `Untitled.${fileMeta.data.fileExtension}`,
)}"`,
},
});
}
if (config.apiConfig.proxyThumbnail) {
const downloadThumb = await fetch(fileMeta.data.thumbnailLink, {
cache: "force-cache",
});
const buffer = await downloadThumb.arrayBuffer();
return new NextResponse(buffer, {
headers: {
"Cache-Control": "public, max-age=31536000, immutable",
"Content-Type": fileMeta.data.mimeType || "application/octet-stream",
"Content-Length": buffer.byteLength.toString(),
"Content-Disposition": `attachment; filename="${encodeURIComponent(
fileMeta.data.name || `Untitled.${fileMeta.data.fileExtension}`,
)}"`,
},
});
}
return NextResponse.redirect(fileMeta.data.thumbnailLink);
});
} catch (error) {
const e = error as Error;
console.error(e.message);
+1
View File
@@ -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";
+28
View File
@@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "~/utils"
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }
+23 -6
View File
@@ -1,6 +1,8 @@
import { z } from "zod";
import { Schema_Config } from "~/schema";
import isDev from "~/utils/isDev";
const config: z.input<typeof Schema_Config> = {
/**
* If possible, please don't change this value
@@ -15,12 +17,11 @@ const config: z.input<typeof Schema_Config> = {
* @default process.env.NEXT_PUBLIC_DOMAIN
* @fallback process.env.NEXT_PUBLIC_VERCEL_URL
*/
basePath:
process.env.NODE_ENV === "development"
? "http://localhost:3000"
: `https://${
process.env.NEXT_PUBLIC_DOMAIN || process.env.NEXT_PUBLIC_VERCEL_URL
}`,
basePath: isDev
? "http://localhost:3000"
: `https://${
process.env.NEXT_PUBLIC_DOMAIN || process.env.NEXT_PUBLIC_VERCEL_URL
}`,
/**
* Allow access to the deploy guide
@@ -108,6 +109,21 @@ const config: z.input<typeof Schema_Config> = {
*/
proxyThumbnail: true,
/**
* Only show preview for files that are smaller than this size
* If the file is larger than this size, it will show "can't preview" message instead
*
* Why?
* Since the stream endpoint are counted as a bandwidth usage
* I want to limit the preview to only small files
* It also to prevent abuse from the user
*
* You can also set this to 0 to disable the limit
*
* Default: 100MB
*/
streamMaxSize: 100 * 1024 * 1024,
/**
* Special file name that will be used for certain purposes
* These files will be ignored when searching for files
@@ -227,6 +243,7 @@ const config: z.input<typeof Schema_Config> = {
footer: [
"{{ siteName }} *v{{ version }}* @ {{ repository }}",
"{{ year }} - Made with ❤️ by **{{ author }}**",
isDev ? "Development Mode" : "",
],
/**
+1
View File
@@ -95,6 +95,7 @@ export const Schema_Config_API = z
itemsPerPage: z.number().positive(),
searchResult: z.number().positive(),
proxyThumbnail: z.boolean(),
streamMaxSize: z.number(),
specialFile: z.object({
password: z.string(),
+10
View File
@@ -0,0 +1,10 @@
/**
* It is a helper to check if it's not in production environment.
* If you're developing, and not using vercel, you can add your own condition.
*/
const isDev =
process.env.NODE_ENV === "development" ||
process.env.VERCEL_ENV === "development" ||
process.env.VERCEL_ENV === "preview";
export default isDev;
+86 -94
View File
@@ -2859,10 +2859,10 @@ __metadata:
languageName: node
linkType: hard
"core-util-is@npm:~1.0.0":
version: 1.0.3
resolution: "core-util-is@npm:1.0.3"
checksum: 10c0/90a0e40abbddfd7618f8ccd63a74d88deea94e77d0e8dbbea059fa7ebebb8fbb4e2909667fe26f3a467073de1a542ebe6ae4c73a73745ac5833786759cd906c9
"core-js@npm:^3.26.1":
version: 3.37.0
resolution: "core-js@npm:3.37.0"
checksum: 10c0/7e00331f346318ca3f595c08ce9e74ddae744715aef137486c1399163afd79792fb94c3161280863adfdc3e30f8026912d56bd3036f93cacfc689d33e185f2ee
languageName: node
linkType: hard
@@ -2918,6 +2918,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"
@@ -3725,6 +3732,13 @@ __metadata:
languageName: node
linkType: hard
"fflate@npm:^0.8.2":
version: 0.8.2
resolution: "fflate@npm:0.8.2"
checksum: 10c0/03448d630c0a583abea594835a9fdb2aaf7d67787055a761515bf4ed862913cfd693b4c4ffd5c3f3b355a70cf1e19033e9ae5aedcca103188aaff91b8bd6e293
languageName: node
linkType: hard
"fflate@npm:~0.6.9":
version: 0.6.10
resolution: "fflate@npm:0.6.10"
@@ -4456,13 +4470,6 @@ __metadata:
languageName: node
linkType: hard
"immediate@npm:~3.0.5":
version: 3.0.6
resolution: "immediate@npm:3.0.6"
checksum: 10c0/f8ba7ede69bee9260241ad078d2d535848745ff5f6995c7c7cb41cfdc9ccc213f66e10fa5afb881f90298b24a3f7344b637b592beb4f54e582770cdce3f1f039
languageName: node
linkType: hard
"import-fresh@npm:^3.0.0, import-fresh@npm:^3.2.1":
version: 3.3.0
resolution: "import-fresh@npm:3.3.0"
@@ -4497,7 +4504,7 @@ __metadata:
languageName: node
linkType: hard
"inherits@npm:2, inherits@npm:^2.0.3, inherits@npm:~2.0.3":
"inherits@npm:2, inherits@npm:^2.0.3":
version: 2.0.4
resolution: "inherits@npm:2.0.4"
checksum: 10c0/4e531f648b29039fb7426fb94075e6545faa1eb9fe83c29f0b6d9e7263aceb4289d2d4557db0d428188eeb449cc7c5e77b0a0b2c4e248ff2a65933a0dee49ef2
@@ -4855,13 +4862,6 @@ __metadata:
languageName: node
linkType: hard
"isarray@npm:~1.0.0":
version: 1.0.0
resolution: "isarray@npm:1.0.0"
checksum: 10c0/18b5be6669be53425f0b84098732670ed4e727e3af33bc7f948aac01782110eb9a18b3b329c5323bcdd3acdaae547ee077d3951317e7f133bff7105264b3003d
languageName: node
linkType: hard
"isexe@npm:^2.0.0":
version: 2.0.0
resolution: "isexe@npm:2.0.0"
@@ -5030,18 +5030,6 @@ __metadata:
languageName: node
linkType: hard
"jszip@npm:^3.10.1":
version: 3.10.1
resolution: "jszip@npm:3.10.1"
dependencies:
lie: "npm:~3.3.0"
pako: "npm:~1.0.2"
readable-stream: "npm:~2.3.6"
setimmediate: "npm:^1.0.5"
checksum: 10c0/58e01ec9c4960383fb8b38dd5f67b83ccc1ec215bf74c8a5b32f42b6e5fb79fada5176842a11409c4051b5b94275044851814a31076bf49e1be218d3ef57c863
languageName: node
linkType: hard
"jwa@npm:^1.4.1":
version: 1.4.1
resolution: "jwa@npm:1.4.1"
@@ -5137,15 +5125,6 @@ __metadata:
languageName: node
linkType: hard
"lie@npm:~3.3.0":
version: 3.3.0
resolution: "lie@npm:3.3.0"
dependencies:
immediate: "npm:~3.0.5"
checksum: 10c0/56dd113091978f82f9dc5081769c6f3b947852ecf9feccaf83e14a123bc630c2301439ce6182521e5fbafbde88e88ac38314327a4e0493a1bea7e0699a7af808
languageName: node
linkType: hard
"lil-gui@npm:~0.17.0":
version: 0.17.0
resolution: "lil-gui@npm:0.17.0"
@@ -5181,6 +5160,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"
@@ -6221,14 +6207,15 @@ __metadata:
encoding: "npm:^0.1.13"
eslint: "npm:8.38.0"
eslint-config-next: "npm:^14.1.4"
fflate: "npm:^0.8.2"
googleapis: "npm:^118.0.0"
input-otp: "npm:^1.2.3"
jsonwebtoken: "npm:^9.0.0"
jszip: "npm:^3.10.1"
lucide-react: "npm:^0.363.0"
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"
@@ -6593,13 +6580,6 @@ __metadata:
languageName: node
linkType: hard
"pako@npm:~1.0.2":
version: 1.0.11
resolution: "pako@npm:1.0.11"
checksum: 10c0/86dd99d8b34c3930345b8bbeb5e1cd8a05f608eeb40967b293f72fe469d0e9c88b783a8777e4cc7dc7c91ce54c5e93d88ff4b4f060e6ff18408fd21030d9ffbe
languageName: node
linkType: hard
"papaparse@npm:^5.4.1":
version: 5.4.1
resolution: "papaparse@npm:5.4.1"
@@ -6750,6 +6730,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"
@@ -6936,13 +6947,6 @@ __metadata:
languageName: node
linkType: hard
"process-nextick-args@npm:~2.0.0":
version: 2.0.1
resolution: "process-nextick-args@npm:2.0.1"
checksum: 10c0/bec089239487833d46b59d80327a1605e1c5287eaad770a291add7f45fda1bb5e28b38e0e061add0a1d0ee0984788ce74fa394d345eed1c420cacf392c554367
languageName: node
linkType: hard
"promise-retry@npm:^2.0.1":
version: 2.0.1
resolution: "promise-retry@npm:2.0.1"
@@ -7001,6 +7005,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"
@@ -7269,21 +7292,6 @@ __metadata:
languageName: node
linkType: hard
"readable-stream@npm:~2.3.6":
version: 2.3.8
resolution: "readable-stream@npm:2.3.8"
dependencies:
core-util-is: "npm:~1.0.0"
inherits: "npm:~2.0.3"
isarray: "npm:~1.0.0"
process-nextick-args: "npm:~2.0.0"
safe-buffer: "npm:~5.1.1"
string_decoder: "npm:~1.1.1"
util-deprecate: "npm:~1.0.1"
checksum: 10c0/7efdb01f3853bc35ac62ea25493567bf588773213f5f4a79f9c365e1ad13bab845ac0dae7bc946270dc40c3929483228415e92a3fc600cc7e4548992f41ee3fa
languageName: node
linkType: hard
"readdirp@npm:~3.6.0":
version: 3.6.0
resolution: "readdirp@npm:3.6.0"
@@ -7601,13 +7609,6 @@ __metadata:
languageName: node
linkType: hard
"safe-buffer@npm:~5.1.0, safe-buffer@npm:~5.1.1":
version: 5.1.2
resolution: "safe-buffer@npm:5.1.2"
checksum: 10c0/780ba6b5d99cc9a40f7b951d47152297d0e260f0df01472a1b99d4889679a4b94a13d644f7dbc4f022572f09ae9005fa2fbb93bbbd83643316f365a3e9a45b21
languageName: node
linkType: hard
"safe-regex-test@npm:^1.0.3":
version: 1.0.3
resolution: "safe-regex-test@npm:1.0.3"
@@ -7688,13 +7689,6 @@ __metadata:
languageName: node
linkType: hard
"setimmediate@npm:^1.0.5":
version: 1.0.5
resolution: "setimmediate@npm:1.0.5"
checksum: 10c0/5bae81bfdbfbd0ce992893286d49c9693c82b1bcc00dcaaf3a09c8f428fdeacf4190c013598b81875dfac2b08a572422db7df779a99332d0fce186d15a3e4d49
languageName: node
linkType: hard
"shallowequal@npm:1.1.0":
version: 1.1.0
resolution: "shallowequal@npm:1.1.0"
@@ -7936,15 +7930,6 @@ __metadata:
languageName: node
linkType: hard
"string_decoder@npm:~1.1.1":
version: 1.1.1
resolution: "string_decoder@npm:1.1.1"
dependencies:
safe-buffer: "npm:~5.1.0"
checksum: 10c0/b4f89f3a92fd101b5653ca3c99550e07bdf9e13b35037e9e2a1c7b47cec4e55e06ff3fc468e314a0b5e80bfbaf65c1ca5a84978764884ae9413bec1fc6ca924e
languageName: node
linkType: hard
"strip-ansi-cjs@npm:strip-ansi@^6.0.1, strip-ansi@npm:^6.0.0, strip-ansi@npm:^6.0.1":
version: 6.0.1
resolution: "strip-ansi@npm:6.0.1"
@@ -8554,6 +8539,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"
@@ -8601,7 +8593,7 @@ __metadata:
languageName: node
linkType: hard
"util-deprecate@npm:^1.0.1, util-deprecate@npm:^1.0.2, util-deprecate@npm:~1.0.1":
"util-deprecate@npm:^1.0.1, util-deprecate@npm:^1.0.2":
version: 1.0.2
resolution: "util-deprecate@npm:1.0.2"
checksum: 10c0/41a5bdd214df2f6c3ecf8622745e4a366c4adced864bc3c833739791aeeeb1838119af7daed4ba36428114b5c67dcda034a79c882e97e43c03e66a4dd7389942