Merge branch 'trunk' into feature/oauth-authentik

This commit is contained in:
Jayvin Hernandez 2023-06-20 11:32:15 -07:00 committed by GitHub
commit 106c0418b7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 557 additions and 125 deletions

View file

@ -1,7 +1,7 @@
# every field in here is optional except, CORE_SECRET and CORE_DATABASE_URL. # every field in here is optional except, CORE_SECRET and CORE_DATABASE_URL.
# if CORE_SECRET is still "changethis" then zipline will exit and tell you to change it. # if CORE_SECRET is still "changethis" then zipline will exit and tell you to change it.
# if using s3/supabase make sure to comment out the other datasources # if using s3/supabase make sure to uncomment or comment out the correct lines needed.
CORE_RETURN_HTTPS=true CORE_RETURN_HTTPS=true
CORE_SECRET="changethis" CORE_SECRET="changethis"
@ -10,34 +10,36 @@ CORE_PORT=3000
CORE_DATABASE_URL="postgres://postgres:postgres@localhost/zip10" CORE_DATABASE_URL="postgres://postgres:postgres@localhost/zip10"
CORE_LOGGER=false CORE_LOGGER=false
CORE_STATS_INTERVAL=1800 CORE_STATS_INTERVAL=1800
CORE_INVITES_INTERVAL=1800
CORE_THUMBNAILS_INTERVAL=600
# default # default
DATASOURCE_TYPE=local DATASOURCE_TYPE=local
DATASOURCE_LOCAL_DIRECTORY=./uploads DATASOURCE_LOCAL_DIRECTORY=./uploads
# or you can choose to use s3 # or you can choose to use s3
DATASOURCE_TYPE=s3 # DATASOURCE_TYPE=s3
DATASOURCE_S3_ACCESS_KEY_ID=key # DATASOURCE_S3_ACCESS_KEY_ID=key
DATASOURCE_S3_SECRET_ACCESS_KEY=secret # DATASOURCE_S3_SECRET_ACCESS_KEY=secret
DATASOURCE_S3_BUCKET=bucket # DATASOURCE_S3_BUCKET=bucket
DATASOURCE_S3_ENDPOINT=s3.amazonaws.com # DATASOURCE_S3_ENDPOINT=s3.amazonaws.com
DATASOURCE_S3_REGION=us-west-2 # DATASOURCE_S3_REGION=us-west-2
DATASOURCE_S3_FORCE_S3_PATH=false # DATASOURCE_S3_FORCE_S3_PATH=false
DATASOURCE_S3_USE_SSL=false # DATASOURCE_S3_USE_SSL=false
# or supabase # or supabase
DATASOURCE_TYPE=supabase # DATASOURCE_TYPE=supabase
DATASOURCE_SUPABASE_KEY=xxx # DATASOURCE_SUPABASE_KEY=xxx
# remember: no leading slash # remember: no leading slash
DATASOURCE_SUPABASE_URL=https://something.supabase.co # DATASOURCE_SUPABASE_URL=https://something.supabase.co
DATASOURCE_SUPABASE_BUCKET=zipline # DATASOURCE_SUPABASE_BUCKET=zipline
UPLOADER_DEFAULT_FORMAT=RANDOM UPLOADER_DEFAULT_FORMAT=RANDOM
UPLOADER_ROUTE=/u UPLOADER_ROUTE=/u
UPLOADER_LENGTH=6 UPLOADER_LENGTH=6
UPLOADER_ADMIN_LIMIT=104900000 UPLOADER_ADMIN_LIMIT=104900000
UPLOADER_USER_LIMIT=104900000 UPLOADER_USER_LIMIT=104900000
UPLOADER_DISABLED_EXTENSIONS=someext UPLOADER_DISABLED_EXTENSIONS=someext,anotherext
URLS_ROUTE=/go URLS_ROUTE=/go
URLS_LENGTH=6 URLS_LENGTH=6

View file

@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) 2022 dicedtomato Copyright (c) 2023 dicedtomato
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal

View file

@ -54,6 +54,7 @@
"fastify": "^4.15.0", "fastify": "^4.15.0",
"fastify-plugin": "^4.5.0", "fastify-plugin": "^4.5.0",
"fflate": "^0.7.4", "fflate": "^0.7.4",
"ffmpeg-static": "^5.1.0",
"find-my-way": "^7.6.0", "find-my-way": "^7.6.0",
"katex": "^0.16.4", "katex": "^0.16.4",
"mantine-datatable": "^2.2.6", "mantine-datatable": "^2.2.6",

View file

@ -0,0 +1,16 @@
-- CreateTable
CREATE TABLE "Thumbnail" (
"id" SERIAL NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"name" TEXT NOT NULL,
"fileId" INTEGER NOT NULL,
CONSTRAINT "Thumbnail_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Thumbnail_fileId_key" ON "Thumbnail"("fileId");
-- AddForeignKey
ALTER TABLE "Thumbnail" ADD CONSTRAINT "Thumbnail_fileId_fkey" FOREIGN KEY ("fileId") REFERENCES "File"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -8,24 +8,24 @@ generator client {
} }
model User { model User {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
uuid String @unique @default(dbgenerated("gen_random_uuid()")) @db.Uuid uuid String @unique @default(dbgenerated("gen_random_uuid()")) @db.Uuid
username String username String
password String? password String?
avatar String? avatar String?
token String token String
administrator Boolean @default(false) administrator Boolean @default(false)
superAdmin Boolean @default(false) superAdmin Boolean @default(false)
systemTheme String @default("system") systemTheme String @default("system")
embed Json @default("{}") embed Json @default("{}")
ratelimit DateTime? ratelimit DateTime?
totpSecret String? totpSecret String?
domains String[] domains String[]
oauth OAuth[] oauth OAuth[]
files File[] files File[]
urls Url[] urls Url[]
Invite Invite[] Invite Invite[]
Folder Folder[] Folder Folder[]
IncompleteFile IncompleteFile[] IncompleteFile IncompleteFile[]
} }
@ -62,6 +62,17 @@ model File {
folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull) folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
folderId Int? folderId Int?
thumbnail Thumbnail?
}
model Thumbnail {
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
name String
fileId Int @unique
file File @relation(fields: [fileId], references: [id], onDelete: Cascade)
} }
model InvisibleFile { model InvisibleFile {

View file

@ -63,7 +63,19 @@ export default function File({
otherUser={otherUser} otherUser={otherUser}
/> />
<Card sx={{ maxWidth: '100%', height: '100%' }} shadow='md' onClick={() => setOpen(true)}> <Card
sx={{
maxWidth: '100%',
height: '100%',
'&:hover': {
filter: 'brightness(0.75)',
},
transition: 'filter 0.2s ease-in-out',
cursor: 'pointer',
}}
shadow='md'
onClick={() => setOpen(true)}
>
<Card.Section> <Card.Section>
<LoadingOverlay visible={loading} /> <LoadingOverlay visible={loading} />
<Type <Type

View file

@ -351,7 +351,11 @@ export default function Layout({ children, props }) {
<Menu.Target> <Menu.Target>
<Button <Button
leftIcon={ leftIcon={
avatar ? <Image src={avatar} height={32} radius='md' /> : <IconUserCog size='1rem' /> avatar ? (
<Image src={avatar} height={32} width={32} fit='cover' radius='md' />
) : (
<IconUserCog size='1rem' />
)
} }
variant='subtle' variant='subtle'
color='gray' color='gray'

View file

@ -53,6 +53,35 @@ function Placeholder({ text, Icon, ...props }) {
); );
} }
function VideoThumbnailPlaceholder({ file, mediaPreview, ...props }) {
if (!file.thumbnail || !mediaPreview)
return <Placeholder Icon={IconPlayerPlay} text={`Click to view video (${file.name})`} {...props} />;
return (
<Box sx={{ position: 'relative' }}>
<Image
src={file.thumbnail}
sx={{
width: '100%',
height: 'auto',
}}
/>
<Center
sx={{
position: 'absolute',
height: '100%',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
}}
>
<IconPlayerPlay size={48} />
</Center>
</Box>
);
}
export default function Type({ file, popup = false, disableMediaPreview, ...props }) { export default function Type({ file, popup = false, disableMediaPreview, ...props }) {
const type = const type =
(file.type ?? file.mimetype) === '' (file.type ?? file.mimetype) === ''
@ -159,7 +188,8 @@ export default function Type({ file, popup = false, disableMediaPreview, ...prop
) )
) : media ? ( ) : media ? (
{ {
video: <Placeholder Icon={IconPlayerPlay} text={`Click to view video (${file.name})`} {...props} />, // video: <Placeholder Icon={IconPlayerPlay} text={`Click to view video (${file.name})`} {...props} />,
video: <VideoThumbnailPlaceholder file={file} mediaPreview={!disableMediaPreview} />,
image: ( image: (
<Image <Image
placeholder={<PlaceholderContent Icon={IconPhotoCancel} text={'Image failed to load...'} />} placeholder={<PlaceholderContent Icon={IconPhotoCancel} text={'Image failed to load...'} />}

View file

@ -7,12 +7,16 @@ import Link from 'next/link';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import FilePagation from './FilePagation'; import FilePagation from './FilePagation';
import PendingFilesModal from './PendingFilesModal'; import PendingFilesModal from './PendingFilesModal';
import { showNonMediaSelector } from 'lib/recoil/settings';
import { useRecoilState } from 'recoil';
export default function Files({ disableMediaPreview, exifEnabled, queryPage, compress }) { export default function Files({ disableMediaPreview, exifEnabled, queryPage, compress }) {
const [checked] = useRecoilState(showNonMediaSelector);
const [favoritePage, setFavoritePage] = useState(1); const [favoritePage, setFavoritePage] = useState(1);
const [favoriteNumPages, setFavoriteNumPages] = useState(0); const [favoriteNumPages, setFavoriteNumPages] = useState(0);
const favoritePages = usePaginatedFiles(favoritePage, { const favoritePages = usePaginatedFiles(favoritePage, {
filter: 'media', filter: checked ? 'none' : 'media',
favorite: true, favorite: true,
}); });

View file

@ -55,7 +55,7 @@ function CreateInviteModal({ open, setOpen, updateInvites }) {
setOpen(false); setOpen(false);
const res = await useFetch('/api/auth/invite', 'POST', { const res = await useFetch('/api/auth/invite', 'POST', {
expiresAt: expiresAt === null ? null : `date=${expiresAt.toISOString()}`, expiresAt: `date=${expiresAt.toISOString()}`,
count: values.count, count: values.count,
}); });
@ -95,7 +95,6 @@ function CreateInviteModal({ open, setOpen, updateInvites }) {
{ value: '3d', label: '3 days' }, { value: '3d', label: '3 days' },
{ value: '5d', label: '5 days' }, { value: '5d', label: '5 days' },
{ value: '7d', label: '7 days' }, { value: '7d', label: '7 days' },
{ value: 'never', label: 'Never' },
]} ]}
/> />
@ -299,45 +298,65 @@ export default function Invites() {
/> />
) : ( ) : (
<SimpleGrid cols={3} spacing='lg' breakpoints={[{ maxWidth: 'sm', cols: 1, spacing: 'sm' }]}> <SimpleGrid cols={3} spacing='lg' breakpoints={[{ maxWidth: 'sm', cols: 1, spacing: 'sm' }]}>
{invites.length {!ok && !invites.length && (
? invites.map((invite) => ( <>
<Card key={invite.id} sx={{ maxWidth: '100%' }}> {[1, 2, 3].map((x) => (
<Group position='apart'> <Skeleton key={x} width='100%' height={100} radius='sm' />
<Group position='left'> ))}
<Avatar size='lg' color={invite.used ? 'dark' : 'primary'}> </>
{invite.id} )}
</Avatar>
<Stack spacing={0}> {invites.length && ok ? (
<Title> invites.map((invite) => (
{invite.code} <Card key={invite.id} sx={{ maxWidth: '100%' }}>
{invite.used && <> (Used)</>} <Group position='apart'>
</Title> <Group position='left'>
<Tooltip label={new Date(invite.createdAt).toLocaleString()}> <Avatar size='lg' color={invite.used ? 'dark' : 'primary'}>
<div> {invite.id}
<MutedText size='sm'> </Avatar>
Created {relativeTime(new Date(invite.createdAt))} <Stack spacing={0}>
</MutedText> <Title>
</div> {invite.code}
</Tooltip> {invite.used && <> (Used)</>}
<Tooltip label={new Date(invite.expiresAt).toLocaleString()}> </Title>
<div> <Tooltip label={new Date(invite.createdAt).toLocaleString()}>
<MutedText size='sm'>{expireText(invite.expiresAt.toString())}</MutedText> <div>
</div> <MutedText size='sm'>Created {relativeTime(new Date(invite.createdAt))}</MutedText>
</Tooltip> </div>
</Stack> </Tooltip>
</Group> <Tooltip label={new Date(invite.expiresAt).toLocaleString()}>
<Stack> <div>
<ActionIcon aria-label='copy' onClick={() => handleCopy(invite)}> <MutedText size='sm'>{expireText(invite.expiresAt.toString())}</MutedText>
<IconClipboardCopy size='1rem' /> </div>
</ActionIcon> </Tooltip>
<ActionIcon aria-label='delete' onClick={() => openDeleteModal(invite)}>
<IconTrash size='1rem' />
</ActionIcon>
</Stack> </Stack>
</Group> </Group>
</Card> <Stack>
)) <ActionIcon aria-label='copy' onClick={() => handleCopy(invite)}>
: [1, 2, 3].map((x) => <Skeleton key={x} width='100%' height={100} radius='sm' />)} <IconClipboardCopy size='1rem' />
</ActionIcon>
<ActionIcon aria-label='delete' onClick={() => openDeleteModal(invite)}>
<IconTrash size='1rem' />
</ActionIcon>
</Stack>
</Group>
</Card>
))
) : (
<>
<div></div>
<Group>
<div>
<IconTag size={48} />
</div>
<div>
<Title>Nothing here</Title>
<MutedText size='md'>Create some invites and they will show up here</MutedText>
</div>
</Group>
<div></div>
</>
)}
</SimpleGrid> </SimpleGrid>
)} )}
</> </>

View file

@ -122,6 +122,8 @@ export default function File({ chunks: chunks_config }) {
}); });
if (j === chunks.length - 1) { if (j === chunks.length - 1) {
window.removeEventListener('beforeunload', beforeUnload);
router.events.off('routeChangeStart', beforeRouteChange);
updateNotification({ updateNotification({
id: 'upload-chunked', id: 'upload-chunked',
title: 'Finalizing partial upload', title: 'Finalizing partial upload',

View file

@ -10,6 +10,7 @@ export interface ConfigCore {
stats_interval: number; stats_interval: number;
invites_interval: number; invites_interval: number;
thumbnails_interval: number;
} }
export interface ConfigCompression { export interface ConfigCompression {

View file

@ -63,8 +63,11 @@ export default function readConfig() {
map('CORE_PORT', 'number', 'core.port'), map('CORE_PORT', 'number', 'core.port'),
map('CORE_DATABASE_URL', 'string', 'core.database_url'), map('CORE_DATABASE_URL', 'string', 'core.database_url'),
map('CORE_LOGGER', 'boolean', 'core.logger'), map('CORE_LOGGER', 'boolean', 'core.logger'),
map('CORE_STATS_INTERVAL', 'number', 'core.stats_interval'), map('CORE_STATS_INTERVAL', 'number', 'core.stats_interval'),
map('CORE_INVITES_INTERVAL', 'number', 'core.invites_interval'), map('CORE_INVITES_INTERVAL', 'number', 'core.invites_interval'),
map('CORE_THUMBNAILS_INTERVAL', 'number', 'core.thumbnails_interval'),
map('CORE_COMPRESSION_ENABLED', 'boolean', 'core.compression.enabled'), map('CORE_COMPRESSION_ENABLED', 'boolean', 'core.compression.enabled'),
map('CORE_COMPRESSION_THRESHOLD', 'human-to-byte', 'core.compression.threshold'), map('CORE_COMPRESSION_THRESHOLD', 'human-to-byte', 'core.compression.threshold'),
map('CORE_COMPRESSION_ON_DASHBOARD', 'boolean', 'core.compression.on_dashboard'), map('CORE_COMPRESSION_ON_DASHBOARD', 'boolean', 'core.compression.on_dashboard'),

View file

@ -35,8 +35,9 @@ const validator = s.object({
port: s.number.default(3000), port: s.number.default(3000),
database_url: s.string, database_url: s.string,
logger: s.boolean.default(false), logger: s.boolean.default(false),
stats_interval: s.number.default(1800), stats_interval: s.number.default(1800), // 30m
invites_interval: s.number.default(1800), invites_interval: s.number.default(1800), // 30m
thumbnails_interval: s.number.default(600), // 10m
compression: s compression: s
.object({ .object({
enabled: s.boolean.default(false), enabled: s.boolean.default(false),

View file

@ -1,10 +1,10 @@
import { File } from '@prisma/client'; import { File } from '@prisma/client';
import { createWriteStream } from 'fs';
import { ExifTool, Tags } from 'exiftool-vendored'; import { ExifTool, Tags } from 'exiftool-vendored';
import { createWriteStream } from 'fs';
import { readFile, rm } from 'fs/promises';
import datasource from 'lib/datasource'; import datasource from 'lib/datasource';
import Logger from 'lib/logger'; import Logger from 'lib/logger';
import { join } from 'path'; import { join } from 'path';
import { readFile, unlink } from 'fs/promises';
const logger = Logger.get('exif'); const logger = Logger.get('exif');
@ -43,47 +43,54 @@ export async function removeGPSData(image: File): Promise<void> {
await new Promise((resolve) => writeStream.on('finish', resolve)); await new Promise((resolve) => writeStream.on('finish', resolve));
logger.debug(`removing GPS data from ${file}`); logger.debug(`removing GPS data from ${file}`);
await exiftool.write(file, { try {
GPSVersionID: null, await exiftool.write(file, {
GPSAltitude: null, GPSVersionID: null,
GPSAltitudeRef: null, GPSAltitude: null,
GPSAreaInformation: null, GPSAltitudeRef: null,
GPSDateStamp: null, GPSAreaInformation: null,
GPSDateTime: null, GPSDateStamp: null,
GPSDestBearing: null, GPSDateTime: null,
GPSDestBearingRef: null, GPSDestBearing: null,
GPSDestDistance: null, GPSDestBearingRef: null,
GPSDestLatitude: null, GPSDestDistance: null,
GPSDestLatitudeRef: null, GPSDestLatitude: null,
GPSDestLongitude: null, GPSDestLatitudeRef: null,
GPSDestLongitudeRef: null, GPSDestLongitude: null,
GPSDifferential: null, GPSDestLongitudeRef: null,
GPSDOP: null, GPSDifferential: null,
GPSHPositioningError: null, GPSDOP: null,
GPSImgDirection: null, GPSHPositioningError: null,
GPSImgDirectionRef: null, GPSImgDirection: null,
GPSLatitude: null, GPSImgDirectionRef: null,
GPSLatitudeRef: null, GPSLatitude: null,
GPSLongitude: null, GPSLatitudeRef: null,
GPSLongitudeRef: null, GPSLongitude: null,
GPSMapDatum: null, GPSLongitudeRef: null,
GPSPosition: null, GPSMapDatum: null,
GPSProcessingMethod: null, GPSPosition: null,
GPSSatellites: null, GPSProcessingMethod: null,
GPSSpeed: null, GPSSatellites: null,
GPSSpeedRef: null, GPSSpeed: null,
GPSStatus: null, GPSSpeedRef: null,
GPSTimeStamp: null, GPSStatus: null,
GPSTrack: null, GPSTimeStamp: null,
GPSTrackRef: null, GPSTrack: null,
}); GPSTrackRef: null,
});
} catch (e) {
logger.debug(`removing temp file: ${file}`);
await rm(file);
return;
}
logger.debug(`reading file to upload to datasource: ${file} -> ${image.name}`); logger.debug(`reading file to upload to datasource: ${file} -> ${image.name}`);
const buffer = await readFile(file); const buffer = await readFile(file);
await datasource.save(image.name, buffer); await datasource.save(image.name, buffer);
logger.debug(`removing temp file: ${file}`); logger.debug(`removing temp file: ${file}`);
await unlink(file); await rm(file);
await exiftool.end(true); await exiftool.end(true);

View file

@ -3,6 +3,8 @@ import Logger from 'lib/logger';
import prisma from 'lib/prisma'; import prisma from 'lib/prisma';
import { hashPassword } from 'lib/util'; import { hashPassword } from 'lib/util';
import { jsonUserReplacer } from 'lib/utils/client'; import { jsonUserReplacer } from 'lib/utils/client';
import { formatRootUrl } from 'lib/utils/urls';
import zconfig from 'lib/config';
import { NextApiReq, NextApiRes, UserExtended, withZipline } from 'middleware/withZipline'; import { NextApiReq, NextApiRes, UserExtended, withZipline } from 'middleware/withZipline';
const logger = Logger.get('user'); const logger = Logger.get('user');
@ -15,7 +17,11 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
id: Number(id), id: Number(id),
}, },
include: { include: {
files: true, files: {
include: {
thumbnail: true,
},
},
Folder: true, Folder: true,
}, },
}); });
@ -179,9 +185,21 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
} else { } else {
delete target.password; delete target.password;
if (user.superAdmin && target.superAdmin) delete target.files; if (user.superAdmin && target.superAdmin) {
if (user.administrator && !user.superAdmin && (target.administrator || target.superAdmin))
delete target.files; delete target.files;
return res.json(target);
}
if (user.administrator && !user.superAdmin && (target.administrator || target.superAdmin)) {
delete target.files;
return res.json(target);
}
for (const file of target.files) {
(file as unknown as { url: string }).url = formatRootUrl(zconfig.uploader.route, file.name);
if (file.thumbnail) {
(file.thumbnail as unknown as string) = formatRootUrl('/r', file.thumbnail.name);
}
}
return res.json(target); return res.json(target);
} }

View file

@ -14,10 +14,14 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
where: { where: {
userId: user.id, userId: user.id,
}, },
include: {
thumbnail: true,
},
}); });
for (let i = 0; i !== files.length; ++i) { for (let i = 0; i !== files.length; ++i) {
await datasource.delete(files[i].name); await datasource.delete(files[i].name);
if (files[i].thumbnail?.name) await datasource.delete(files[i].thumbnail.name);
} }
const { count } = await prisma.file.deleteMany({ const { count } = await prisma.file.deleteMany({
@ -45,6 +49,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
id: true, id: true,
}, },
}, },
thumbnail: true,
}, },
}); });
@ -63,10 +68,12 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
id: true, id: true,
}, },
}, },
thumbnail: true,
}, },
}); });
await datasource.delete(file.name); await datasource.delete(file.name);
if (file.thumbnail?.name) await datasource.delete(file.thumbnail.name);
logger.info( logger.info(
`User ${user.username} (${user.id}) deleted an image ${file.name} (${file.id}) owned by ${file.user.username} (${file.user.id})` `User ${user.username} (${user.id}) deleted an image ${file.name} (${file.id}) owned by ${file.user.username} (${file.user.id})`
@ -134,6 +141,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
views: number; views: number;
size: number; size: number;
originalName: string; originalName: string;
thumbnail?: { name: string };
}[] = await prisma.file.findMany({ }[] = await prisma.file.findMany({
where: { where: {
userId: user.id, userId: user.id,
@ -154,11 +162,16 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
maxViews: true, maxViews: true,
size: true, size: true,
originalName: true, originalName: true,
thumbnail: true,
}, },
}); });
for (let i = 0; i !== files.length; ++i) { for (let i = 0; i !== files.length; ++i) {
(files[i] as unknown as { url: string }).url = formatRootUrl(config.uploader.route, files[i].name); (files[i] as unknown as { url: string }).url = formatRootUrl(config.uploader.route, files[i].name);
if (files[i].thumbnail) {
(files[i].thumbnail as unknown as string) = formatRootUrl('/r', files[i].thumbnail.name);
}
} }
if (req.query.filter && req.query.filter === 'media') if (req.query.filter && req.query.filter === 'media')

View file

@ -85,6 +85,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
folderId: number; folderId: number;
size: number; size: number;
password: string | boolean; password: string | boolean;
thumbnail?: { name: string };
}[] = await prisma.file.findMany({ }[] = await prisma.file.findMany({
where, where,
orderBy: { orderBy: {
@ -102,6 +103,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
folderId: true, folderId: true,
size: true, size: true,
password: true, password: true,
thumbnail: true,
}, },
skip: page ? (Number(page) - 1) * pageCount : undefined, skip: page ? (Number(page) - 1) * pageCount : undefined,
take: page ? pageCount : undefined, take: page ? pageCount : undefined,
@ -112,6 +114,9 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
if (file.password) file.password = true; if (file.password) file.password = true;
(file as unknown as { url: string }).url = formatRootUrl(config.uploader.route, file.name); (file as unknown as { url: string }).url = formatRootUrl(config.uploader.route, file.name);
if (files[i].thumbnail) {
(files[i].thumbnail as unknown as string) = formatRootUrl('/r', files[i].thumbnail.name);
}
} }
return res.json(files); return res.json(files);

View file

@ -27,11 +27,15 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
folderId: true, folderId: true,
size: true, size: true,
favorite: true, favorite: true,
thumbnail: true,
}, },
}); });
for (let i = 0; i !== files.length; ++i) { for (let i = 0; i !== files.length; ++i) {
(files[i] as unknown as { url: string }).url = formatRootUrl(config.uploader.route, files[i].name); (files[i] as unknown as { url: string }).url = formatRootUrl(config.uploader.route, files[i].name);
if (files[i].thumbnail) {
(files[i].thumbnail as unknown as string) = formatRootUrl('/r', files[i].thumbnail.name);
}
} }
if (req.query.filter && req.query.filter === 'media') if (req.query.filter && req.query.filter === 'media')

View file

@ -11,7 +11,9 @@ async function main() {
process.exit(0); process.exit(0);
} }
const files = (await readdir(temp)).filter((x) => x.startsWith('zipline_partial_')); const files = (await readdir(temp)).filter(
(x) => x.startsWith('zipline_partial_') || x.startsWith('zipline_thumb_')
);
if (files.length === 0) { if (files.length === 0) {
console.log('No partial files found, exiting..'); console.log('No partial files found, exiting..');
process.exit(0); process.exit(0);

View file

@ -47,6 +47,9 @@ async function main() {
}, },
}, },
}); });
await prisma.$disconnect();
console.log(`Deleted ${count} files from the database.`); console.log(`Deleted ${count} files from the database.`);
for (let i = 0; i !== toDelete.length; ++i) { for (let i = 0; i !== toDelete.length; ++i) {

View file

@ -52,6 +52,8 @@ async function main() {
await datasource.save(file, await readFile(join(directory, file))); await datasource.save(file, await readFile(join(directory, file)));
} }
console.log(`Finished copying files to ${config.datasource.type} storage.`); console.log(`Finished copying files to ${config.datasource.type} storage.`);
process.exit(0);
} }
main(); main();

View file

@ -1,6 +1,7 @@
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
import config from 'lib/config'; import config from 'lib/config';
import { migrations } from 'server/util'; import { migrations } from 'server/util';
import { inspect } from 'util';
async function main() { async function main() {
const extras = (process.argv[2] ?? '').split(','); const extras = (process.argv[2] ?? '').split(',');
@ -13,6 +14,7 @@ async function main() {
const select = { const select = {
username: true, username: true,
administrator: true, administrator: true,
superAdmin: true,
id: true, id: true,
}; };
for (let i = 0; i !== extras.length; ++i) { for (let i = 0; i !== extras.length; ++i) {
@ -30,7 +32,11 @@ async function main() {
select, select,
}); });
console.log(JSON.stringify(users, null, 2)); await prisma.$disconnect();
console.log(inspect(users, false, 4, true));
process.exit(0);
} }
main(); main();

View file

@ -60,11 +60,14 @@ async function main() {
} }
} }
await prisma.$disconnect();
notFound notFound
? console.log( ? console.log(
'At least one file has been found to not exist in the datasource but was on the database. To remove these files, run the script with the --force-delete flag.' 'At least one file has been found to not exist in the datasource but was on the database. To remove these files, run the script with the --force-delete flag.'
) )
: console.log('Done.'); : console.log('Done.');
process.exit(0); process.exit(0);
} }

View file

@ -66,11 +66,15 @@ async function main() {
data, data,
}); });
await prisma.$disconnect();
if (args[1] === 'password') { if (args[1] === 'password') {
parsed = '***'; parsed = '***';
} }
console.log(`Updated user ${user.id} with ${args[1]} = ${parsed}`); console.log(`Updated user ${user.id} with ${args[1]} = ${parsed}`);
process.exit(0);
} }
main(); main();

View file

@ -1,11 +1,12 @@
import config from 'lib/config'; import config from 'lib/config';
import datasource from 'lib/datasource'; import datasource from 'lib/datasource';
import Logger from 'lib/logger'; import Logger from 'lib/logger';
import { version } from '../../package.json';
import { getStats } from 'server/util'; import { getStats } from 'server/util';
import { version } from '../../package.json';
import fastify, { FastifyInstance, FastifyServerOptions } from 'fastify'; import fastify, { FastifyInstance, FastifyServerOptions } from 'fastify';
import { createReadStream, existsSync, readFileSync } from 'fs'; import { createReadStream, existsSync, readFileSync } from 'fs';
import { Worker } from 'worker_threads';
import dbFileDecorator from './decorators/dbFile'; import dbFileDecorator from './decorators/dbFile';
import notFound from './decorators/notFound'; import notFound from './decorators/notFound';
import postFileDecorator from './decorators/postFile'; import postFileDecorator from './decorators/postFile';
@ -183,9 +184,11 @@ Disallow: ${config.urls.route}
await clearInvites.bind(server)(); await clearInvites.bind(server)();
await stats.bind(server)(); await stats.bind(server)();
await thumbs.bind(server)();
setInterval(() => clearInvites.bind(server)(), config.core.invites_interval * 1000); setInterval(() => clearInvites.bind(server)(), config.core.invites_interval * 1000);
setInterval(() => stats.bind(server)(), config.core.stats_interval * 1000); setInterval(() => stats.bind(server)(), config.core.stats_interval * 1000);
setInterval(() => thumbs.bind(server)(), config.core.thumbnails_interval * 1000);
} }
async function stats(this: FastifyInstance) { async function stats(this: FastifyInstance) {
@ -217,6 +220,51 @@ async function clearInvites(this: FastifyInstance) {
logger.child('invites').debug(`deleted ${count} used invites`); logger.child('invites').debug(`deleted ${count} used invites`);
} }
async function thumbs(this: FastifyInstance) {
const videoFiles = await this.prisma.file.findMany({
where: {
mimetype: {
startsWith: 'video/',
},
thumbnail: null,
},
include: {
thumbnail: true,
},
});
// avoids reaching prisma connection limit
const MAX_THUMB_THREADS = 4;
// make all the files fit into 4 arrays
const chunks = [];
for (let i = 0; i !== MAX_THUMB_THREADS; ++i) {
chunks.push([]);
for (let j = i; j < videoFiles.length; j += MAX_THUMB_THREADS) {
chunks[i].push(videoFiles[j]);
}
}
logger.child('thumbnail').debug(`starting ${chunks.length} thumbnail threads`);
for (let i = 0; i !== chunks.length; ++i) {
const chunk = chunks[i];
if (chunk.length === 0) continue;
logger.child('thumbnail').debug(`starting thumbnail generation for ${chunk.length} videos`);
new Worker('./dist/worker/thumbnail.js', {
workerData: {
videos: chunk,
config,
datasource,
},
});
}
}
function genFastifyOpts(): FastifyServerOptions { function genFastifyOpts(): FastifyServerOptions {
const opts = {}; const opts = {};

128
src/worker/thumbnail.ts Normal file
View file

@ -0,0 +1,128 @@
import { type File, PrismaClient, type Thumbnail } from '@prisma/client';
import { spawn } from 'child_process';
import ffmpeg from 'ffmpeg-static';
import { createWriteStream } from 'fs';
import { rm } from 'fs/promises';
import type { Config } from 'lib/config/Config';
import Logger from 'lib/logger';
import { randomChars } from 'lib/util';
import { join } from 'path';
import { isMainThread, workerData } from 'worker_threads';
import datasource from 'lib/datasource';
const { videos, config } = workerData as {
videos: (File & {
thumbnail: Thumbnail;
})[];
config: Config;
};
const logger = Logger.get('worker::thumbnail').child(randomChars(4));
logger.debug(`thumbnail generation for ${videos.length} videos`);
if (isMainThread) {
logger.error('worker is not a thread');
process.exit(1);
}
async function loadThumbnail(path) {
const args = ['-i', path, '-frames:v', '1', '-f', 'mjpeg', 'pipe:1'];
const child = spawn(ffmpeg, args, { stdio: ['ignore', 'pipe', 'ignore'] });
const data: Buffer = await new Promise((resolve, reject) => {
const buffers = [];
child.stdout.on('data', (chunk) => {
buffers.push(chunk);
});
child.once('error', reject);
child.once('close', (code) => {
if (code !== 0) {
reject(new Error(`child exited with code ${code}`));
} else {
const buffer = Buffer.allocUnsafe(buffers.reduce((acc, val) => acc + val.length, 0));
let offset = 0;
for (let i = 0; i !== buffers.length; ++i) {
const chunk = buffers[i];
chunk.copy(buffer, offset);
offset += chunk.length;
}
resolve(buffer);
}
});
});
return data;
}
async function loadFileTmp(file: File) {
const stream = await datasource.get(file.name);
// pipe to tmp file
const tmpFile = join(config.core.temp_directory, `zipline_thumb_${file.id}_${file.id}.tmp`);
const fileWriteStream = createWriteStream(tmpFile);
await new Promise((resolve, reject) => {
stream.pipe(fileWriteStream);
stream.once('error', reject);
stream.once('end', resolve);
});
return tmpFile;
}
async function start() {
const prisma = new PrismaClient();
for (let i = 0; i !== videos.length; ++i) {
const file = videos[i];
if (!file.mimetype.startsWith('video/')) {
logger.info('file is not a video');
process.exit(0);
}
if (file.thumbnail) {
logger.info('thumbnail already exists');
process.exit(0);
}
const tmpFile = await loadFileTmp(file);
logger.debug(`loaded file to tmp: ${tmpFile}`);
const thumbnail = await loadThumbnail(tmpFile);
logger.debug(`loaded thumbnail: ${thumbnail.length} bytes mjpeg`);
const { thumbnail: thumb } = await prisma.file.update({
where: {
id: file.id,
},
data: {
thumbnail: {
create: {
name: `.thumb-${file.id}.jpg`,
},
},
},
select: {
thumbnail: true,
},
});
await datasource.save(thumb.name, thumbnail);
logger.info(`thumbnail saved - ${thumb.name}`);
logger.debug(`thumbnail ${JSON.stringify(thumb)}`);
logger.debug(`removing tmp file: ${tmpFile}`);
await rm(tmpFile);
}
await prisma.$disconnect();
process.exit(0);
}
start();

View file

@ -19,6 +19,11 @@ export default defineConfig([
outDir: 'dist/worker', outDir: 'dist/worker',
...opts, ...opts,
}, },
{
entryPoints: ['src/worker/thumbnail.ts'],
outDir: 'dist/worker',
...opts,
},
// scripts // scripts
{ {
entryPoints: ['src/scripts/import-dir.ts'], entryPoints: ['src/scripts/import-dir.ts'],

View file

@ -1142,6 +1142,18 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@derhuerst/http-basic@npm:^8.2.0":
version: 8.2.4
resolution: "@derhuerst/http-basic@npm:8.2.4"
dependencies:
caseless: ^0.12.0
concat-stream: ^2.0.0
http-response-object: ^3.0.1
parse-cache-control: ^1.0.1
checksum: dfb2f30c23fb907988d1c34318fa74c54dcd3c3ba6b4b0e64cdb584d03303ad212dd3b3874328a9367d7282a232976acbd33a20bb9c7a6ea20752e879459253b
languageName: node
linkType: hard
"@emotion/babel-plugin@npm:^11.10.6": "@emotion/babel-plugin@npm:^11.10.6":
version: 11.10.6 version: 11.10.6
resolution: "@emotion/babel-plugin@npm:11.10.6" resolution: "@emotion/babel-plugin@npm:11.10.6"
@ -2719,6 +2731,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@types/node@npm:^10.0.3":
version: 10.17.60
resolution: "@types/node@npm:10.17.60"
checksum: 2cdb3a77d071ba8513e5e8306fa64bf50e3c3302390feeaeff1fd325dd25c8441369715dfc8e3701011a72fed5958c7dfa94eb9239a81b3c286caa4d97db6eef
languageName: node
linkType: hard
"@types/node@npm:^17.0.45": "@types/node@npm:^17.0.45":
version: 17.0.45 version: 17.0.45
resolution: "@types/node@npm:17.0.45" resolution: "@types/node@npm:17.0.45"
@ -3822,9 +3841,16 @@ __metadata:
linkType: hard linkType: hard
"caniuse-lite@npm:^1.0.30001406": "caniuse-lite@npm:^1.0.30001406":
version: 1.0.30001439 version: 1.0.30001494
resolution: "caniuse-lite@npm:1.0.30001439" resolution: "caniuse-lite@npm:1.0.30001494"
checksum: 3912dd536c9735713ca85e47721988bbcefb881ddb4886b0b9923fa984247fd22cba032cf268e57d158af0e8a2ae2eae042ae01942a1d6d7849fa9fa5d62fb82 checksum: 770b742ebba6076da72e94f979ef609bbc855369d1b937c52227935d966b11c3b02baa6511fba04a804802b6eb22af0a2a4a82405963bbb769772530e6be7a8e
languageName: node
linkType: hard
"caseless@npm:^0.12.0":
version: 0.12.0
resolution: "caseless@npm:0.12.0"
checksum: b43bd4c440aa1e8ee6baefee8063b4850fd0d7b378f6aabc796c9ec8cb26d27fb30b46885350777d9bd079c5256c0e1329ad0dc7c2817e0bb466810ebb353751
languageName: node languageName: node
linkType: hard linkType: hard
@ -4136,6 +4162,18 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"concat-stream@npm:^2.0.0":
version: 2.0.0
resolution: "concat-stream@npm:2.0.0"
dependencies:
buffer-from: ^1.0.0
inherits: ^2.0.3
readable-stream: ^3.0.2
typedarray: ^0.0.6
checksum: d7f75d48f0ecd356c1545d87e22f57b488172811b1181d96021c7c4b14ab8855f5313280263dca44bb06e5222f274d047da3e290a38841ef87b59719bde967c7
languageName: node
linkType: hard
"console-control-strings@npm:^1.0.0, console-control-strings@npm:^1.1.0": "console-control-strings@npm:^1.0.0, console-control-strings@npm:^1.1.0":
version: 1.1.0 version: 1.1.0
resolution: "console-control-strings@npm:1.1.0" resolution: "console-control-strings@npm:1.1.0"
@ -5625,6 +5663,18 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"ffmpeg-static@npm:^5.1.0":
version: 5.1.0
resolution: "ffmpeg-static@npm:5.1.0"
dependencies:
"@derhuerst/http-basic": ^8.2.0
env-paths: ^2.2.0
https-proxy-agent: ^5.0.0
progress: ^2.0.3
checksum: 0e27d671a0be1f585ef03e48c2af7c2be14f4e61470ffa02e3b8919551243ee854028a898dfcd16cdf1e3c01916f3c5e9938f42cbc7e877d7dd80d566867db8b
languageName: node
linkType: hard
"file-entry-cache@npm:^6.0.1": "file-entry-cache@npm:^6.0.1":
version: 6.0.1 version: 6.0.1
resolution: "file-entry-cache@npm:6.0.1" resolution: "file-entry-cache@npm:6.0.1"
@ -6335,6 +6385,15 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"http-response-object@npm:^3.0.1":
version: 3.0.2
resolution: "http-response-object@npm:3.0.2"
dependencies:
"@types/node": ^10.0.3
checksum: 6cbdcb4ce7b27c9158a131b772c903ed54add2ba831e29cc165e91c3969fa6f8105ddf924aac5b954b534ad15a1ae697b693331b2be5281ee24d79aae20c3264
languageName: node
linkType: hard
"https-proxy-agent@npm:5.0.1, https-proxy-agent@npm:^5.0.0": "https-proxy-agent@npm:5.0.1, https-proxy-agent@npm:^5.0.0":
version: 5.0.1 version: 5.0.1
resolution: "https-proxy-agent@npm:5.0.1" resolution: "https-proxy-agent@npm:5.0.1"
@ -8874,6 +8933,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"parse-cache-control@npm:^1.0.1":
version: 1.0.1
resolution: "parse-cache-control@npm:1.0.1"
checksum: 5a70868792124eb07c2dd07a78fcb824102e972e908254e9e59ce59a4796c51705ff28196d2b20d3b7353d14e9f98e65ed0e4eda9be072cc99b5297dc0466fee
languageName: node
linkType: hard
"parse-json@npm:^4.0.0": "parse-json@npm:^4.0.0":
version: 4.0.0 version: 4.0.0
resolution: "parse-json@npm:4.0.0" resolution: "parse-json@npm:4.0.0"
@ -9301,7 +9367,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"progress@npm:2.0.3": "progress@npm:2.0.3, progress@npm:^2.0.3":
version: 2.0.3 version: 2.0.3
resolution: "progress@npm:2.0.3" resolution: "progress@npm:2.0.3"
checksum: f67403fe7b34912148d9252cb7481266a354bd99ce82c835f79070643bb3c6583d10dbcfda4d41e04bbc1d8437e9af0fb1e1f2135727878f5308682a579429b7 checksum: f67403fe7b34912148d9252cb7481266a354bd99ce82c835f79070643bb3c6583d10dbcfda4d41e04bbc1d8437e9af0fb1e1f2135727878f5308682a579429b7
@ -9736,6 +9802,17 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"readable-stream@npm:^3.0.2":
version: 3.6.2
resolution: "readable-stream@npm:3.6.2"
dependencies:
inherits: ^2.0.3
string_decoder: ^1.1.1
util-deprecate: ^1.0.1
checksum: bdcbe6c22e846b6af075e32cf8f4751c2576238c5043169a1c221c92ee2878458a816a4ea33f4c67623c0b6827c8a400409bfb3cf0bf3381392d0b1dfb52ac8d
languageName: node
linkType: hard
"readable-stream@npm:^4.0.0": "readable-stream@npm:^4.0.0":
version: 4.2.0 version: 4.2.0
resolution: "readable-stream@npm:4.2.0" resolution: "readable-stream@npm:4.2.0"
@ -11902,6 +11979,7 @@ __metadata:
fastify: ^4.15.0 fastify: ^4.15.0
fastify-plugin: ^4.5.0 fastify-plugin: ^4.5.0
fflate: ^0.7.4 fflate: ^0.7.4
ffmpeg-static: ^5.1.0
find-my-way: ^7.6.0 find-my-way: ^7.6.0
katex: ^0.16.4 katex: ^0.16.4
mantine-datatable: ^2.2.6 mantine-datatable: ^2.2.6