feat: many things (#351)

* remove source from final image

* move check state to ClearStorage

* use inspect for fancy colors

* newlines are now possible! yay!

* Catch user's leave if uploading

* feat?: Temp directory can be specified by the user.
Default is /tmp/zipline (or os equivalent)

* fix: ignore onDash config, use only ?compress query

---------

Co-authored-by: dicedtomato <35403473+diced@users.noreply.github.com>
This commit is contained in:
Jayvin Hernandez 2023-03-31 22:25:00 -07:00 committed by GitHub
parent bc58c1b56e
commit bf40fa9cd2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 90 additions and 38 deletions

View file

@ -69,7 +69,8 @@ COPY --from=builder /zipline/node_modules/@prisma/client ./node_modules/@prisma/
# Copy Startup Script # Copy Startup Script
COPY docker-entrypoint.sh /zipline COPY docker-entrypoint.sh /zipline
# Make Startup Script Executable # Make Startup Script Executable
RUN chmod a+x /zipline/docker-entrypoint.sh RUN chmod a+x /zipline/docker-entrypoint.sh && rm -rf /zipline/src
# Set the entrypoint to the startup script # Set the entrypoint to the startup script
ENTRYPOINT ["tini", "--", "/zipline/docker-entrypoint.sh"] ENTRYPOINT ["tini", "--", "/zipline/docker-entrypoint.sh"]

View file

@ -3,8 +3,10 @@ import { closeAllModals, openConfirmModal } from '@mantine/modals';
import { showNotification, updateNotification } from '@mantine/notifications'; import { showNotification, updateNotification } from '@mantine/notifications';
import { IconFiles, IconFilesOff } from '@tabler/icons-react'; import { IconFiles, IconFilesOff } from '@tabler/icons-react';
import useFetch from 'hooks/useFetch'; import useFetch from 'hooks/useFetch';
import { useState } from 'react';
export default function ClearStorage({ open, setOpen, check, setCheck }) { export default function ClearStorage({ open, setOpen }) {
const [check, setCheck] = useState(false);
const handleDelete = async (datasource: boolean, orphaned?: boolean) => { const handleDelete = async (datasource: boolean, orphaned?: boolean) => {
showNotification({ showNotification({
id: 'clear-uploads', id: 'clear-uploads',
@ -38,7 +40,10 @@ export default function ClearStorage({ open, setOpen, check, setCheck }) {
return ( return (
<Modal <Modal
opened={open} opened={open}
onClose={() => setOpen(false)} onClose={() => {
setOpen(false);
setCheck(() => false);
}}
title={<Title size='sm'>Are you sure you want to clear all uploads in the database?</Title>} title={<Title size='sm'>Are you sure you want to clear all uploads in the database?</Title>}
> >
<Checkbox <Checkbox

View file

@ -4,7 +4,7 @@ import { Icon2fa, IconBarcodeOff, IconCheck } from '@tabler/icons-react';
import useFetch from 'hooks/useFetch'; import useFetch from 'hooks/useFetch';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
export function TotpModal({ opened, onClose, deleteTotp, setTotpEnabled }) { export function TotpModal({ opened, onClose, deleteTotp, setUser }) {
const [secret, setSecret] = useState(''); const [secret, setSecret] = useState('');
const [qrCode, setQrCode] = useState(''); const [qrCode, setQrCode] = useState('');
const [disabled, setDisabled] = useState(false); const [disabled, setDisabled] = useState(false);
@ -52,8 +52,7 @@ export function TotpModal({ opened, onClose, deleteTotp, setTotpEnabled }) {
icon: <Icon2fa size='1rem' />, icon: <Icon2fa size='1rem' />,
}); });
setTotpEnabled(false); setUser((user) => ({ ...user, totpSecret: null }));
onClose(); onClose();
} }
@ -83,8 +82,7 @@ export function TotpModal({ opened, onClose, deleteTotp, setTotpEnabled }) {
icon: <Icon2fa size='1rem' />, icon: <Icon2fa size='1rem' />,
}); });
setTotpEnabled(true); setUser((user) => ({ ...user, totpSecret: secret }));
onClose(); onClose();
} }

View file

@ -89,7 +89,6 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
const [fileDataURL, setFileDataURL] = useState(user.avatar ?? null); const [fileDataURL, setFileDataURL] = useState(user.avatar ?? null);
const [totpEnabled, setTotpEnabled] = useState(!!user.totpSecret); const [totpEnabled, setTotpEnabled] = useState(!!user.totpSecret);
const [checked, setCheck] = useState(false);
const getDataURL = (f: File): Promise<string> => { const getDataURL = (f: File): Promise<string> => {
return new Promise((res, rej) => { return new Promise((res, rej) => {
@ -355,7 +354,8 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
useEffect(() => { useEffect(() => {
getExports(); getExports();
interval.start(); interval.start();
}, [totpEnabled]); setTotpEnabled(() => !!user.totpSecret);
}, [user]);
return ( return (
<> <>
@ -450,7 +450,7 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
opened={totpOpen} opened={totpOpen}
onClose={() => setTotpOpen(false)} onClose={() => setTotpOpen(false)}
deleteTotp={totpEnabled} deleteTotp={totpEnabled}
setTotpEnabled={setTotpEnabled} setUser={setUser}
/> />
</Box> </Box>
)} )}
@ -626,7 +626,7 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
<ShareX user={user} open={shareXOpen} setOpen={setShareXOpen} /> <ShareX user={user} open={shareXOpen} setOpen={setShareXOpen} />
<Flameshot user={user} open={flameshotOpen} setOpen={setFlameshotOpen} /> <Flameshot user={user} open={flameshotOpen} setOpen={setFlameshotOpen} />
<ClearStorage open={clrStorOpen} setOpen={setClrStorOpen} check={checked} setCheck={setCheck} /> <ClearStorage open={clrStorOpen} setOpen={setClrStorOpen} />
</> </>
); );
} }

View file

@ -13,8 +13,11 @@ import { useEffect, useState } from 'react';
import { useRecoilValue } from 'recoil'; import { useRecoilValue } from 'recoil';
import showFilesModal from './showFilesModal'; import showFilesModal from './showFilesModal';
import useUploadOptions from './useUploadOptions'; import useUploadOptions from './useUploadOptions';
import { useRouter } from 'next/router';
export default function File({ chunks: chunks_config }) { export default function File({ chunks: chunks_config }) {
const router = useRouter();
const clipboard = useClipboard(); const clipboard = useClipboard();
const modals = useModals(); const modals = useModals();
const user = useRecoilValue(userSelector); const user = useRecoilValue(userSelector);
@ -25,6 +28,24 @@ export default function File({ chunks: chunks_config }) {
const [options, setOpened, OptionsModal] = useUploadOptions(); const [options, setOpened, OptionsModal] = useUploadOptions();
const beforeUnload = (e: BeforeUnloadEvent) => {
if (loading) {
e.preventDefault();
e.returnValue = "Are you sure you want to leave? Your upload(s) won't be saved.";
return e.returnValue;
}
};
const beforeRouteChange = (url: string) => {
if (loading) {
const confirmed = confirm("Are you sure you want to leave? Your upload(s) won't be saved.");
if (!confirmed) {
router.events.emit('routeChangeComplete', url);
throw 'Route change aborted';
}
}
};
useEffect(() => { useEffect(() => {
const listener = (e: ClipboardEvent) => { const listener = (e: ClipboardEvent) => {
const item = Array.from(e.clipboardData.items).find((x) => /^image/.test(x.type)); const item = Array.from(e.clipboardData.items).find((x) => /^image/.test(x.type));
@ -41,8 +62,14 @@ export default function File({ chunks: chunks_config }) {
}; };
document.addEventListener('paste', listener); document.addEventListener('paste', listener);
return () => document.removeEventListener('paste', listener); window.addEventListener('beforeunload', beforeUnload);
}, []); router.events.on('routeChangeStart', beforeRouteChange);
return () => {
window.removeEventListener('beforeunload', beforeUnload);
router.events.off('routeChangeStart', beforeRouteChange);
document.removeEventListener('paste', listener);
};
}, [loading, beforeUnload, beforeRouteChange]);
const handleChunkedFiles = async (expiresAt: Date, toChunkFiles: File[]) => { const handleChunkedFiles = async (expiresAt: Date, toChunkFiles: File[]) => {
for (let i = 0; i !== toChunkFiles.length; ++i) { for (let i = 0; i !== toChunkFiles.length; ++i) {

View file

@ -1,5 +1,6 @@
export interface ConfigCore { export interface ConfigCore {
return_https: boolean; return_https: boolean;
temp_directory: string;
secret: string; secret: string;
host: string; host: string;
port: number; port: number;

View file

@ -57,6 +57,7 @@ export default function readConfig() {
const maps = [ const maps = [
map('CORE_RETURN_HTTPS', 'boolean', 'core.return_https'), map('CORE_RETURN_HTTPS', 'boolean', 'core.return_https'),
map('CORE_TEMP_DIRECTORY', 'path', 'core.temp_directory'),
map('CORE_SECRET', 'string', 'core.secret'), map('CORE_SECRET', 'string', 'core.secret'),
map('CORE_HOST', 'string', 'core.host'), map('CORE_HOST', 'string', 'core.host'),
map('CORE_PORT', 'number', 'core.port'), map('CORE_PORT', 'number', 'core.port'),

View file

@ -3,6 +3,8 @@ import type { Config } from './Config';
import { inspect } from 'util'; import { inspect } from 'util';
import Logger from 'lib/logger'; import Logger from 'lib/logger';
import { humanToBytes } from 'utils/bytes'; import { humanToBytes } from 'utils/bytes';
import { tmpdir } from 'os';
import { join } from 'path';
const discord_content = s const discord_content = s
.object({ .object({
@ -27,6 +29,7 @@ const discord_content = s
const validator = s.object({ const validator = s.object({
core: s.object({ core: s.object({
return_https: s.boolean.default(false), return_https: s.boolean.default(false),
temp_directory: s.string.default(join(tmpdir(), 'zipline')),
secret: s.string.lengthGreaterThanOrEqual(8), secret: s.string.lengthGreaterThanOrEqual(8),
host: s.string.default('0.0.0.0'), host: s.string.default('0.0.0.0'),
port: s.number.default(3000), port: s.number.default(3000),

View file

@ -3,7 +3,6 @@ import { createWriteStream } from 'fs';
import { ExifTool, Tags } from 'exiftool-vendored'; import { ExifTool, Tags } from 'exiftool-vendored';
import datasource from 'lib/datasource'; import datasource from 'lib/datasource';
import Logger from 'lib/logger'; import Logger from 'lib/logger';
import { tmpdir } from 'os';
import { join } from 'path'; import { join } from 'path';
import { readFile, unlink } from 'fs/promises'; import { readFile, unlink } from 'fs/promises';
@ -34,7 +33,7 @@ export async function readMetadata(filePath: string): Promise<Tags> {
export async function removeGPSData(image: File): Promise<void> { export async function removeGPSData(image: File): Promise<void> {
const exiftool = new ExifTool({ cleanupChildProcs: false }); const exiftool = new ExifTool({ cleanupChildProcs: false });
const file = join(tmpdir(), `zipline-exif-remove-${Date.now()}-${image.name}`); const file = join(config.core.temp_directory, `zipline-exif-remove-${Date.now()}-${image.name}`);
logger.debug(`writing temp file to remove GPS data: ${file}`); logger.debug(`writing temp file to remove GPS data: ${file}`);
const stream = await datasource.get(image.name); const stream = await datasource.get(image.name);

View file

@ -11,7 +11,10 @@ export type ParseValue = {
export function parseString(str: string, value: ParseValue) { export function parseString(str: string, value: ParseValue) {
if (!str) return null; if (!str) return null;
str = str.replace(/\{link\}/gi, value.link).replace(/\{raw_link\}/gi, value.raw_link); str = str
.replace(/\{link\}/gi, value.link)
.replace(/\{raw_link\}/gi, value.raw_link)
.replace(/\\n/g, '\n');
const re = /\{(?<type>file|url|user)\.(?<prop>\w+)(::(?<mod>\w+))?\}/gi; const re = /\{(?<type>file|url|user)\.(?<prop>\w+)(::(?<mod>\w+))?\}/gi;
let matches: RegExpMatchArray; let matches: RegExpMatchArray;

View file

@ -6,7 +6,6 @@ import Logger from 'lib/logger';
import prisma from 'lib/prisma'; import prisma from 'lib/prisma';
import { readMetadata } from 'lib/utils/exif'; import { readMetadata } from 'lib/utils/exif';
import { NextApiReq, NextApiRes, UserExtended, withZipline } from 'middleware/withZipline'; import { NextApiReq, NextApiRes, UserExtended, withZipline } from 'middleware/withZipline';
import { tmpdir } from 'os';
import { join } from 'path'; import { join } from 'path';
const logger = Logger.get('exif'); const logger = Logger.get('exif');
@ -41,7 +40,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
return res.json(data); return res.json(data);
} else { } else {
const file = join(tmpdir(), `zipline-exif-read-${Date.now()}-${image.name}`); const file = join(config.core.temp_directory, `zipline-exif-read-${Date.now()}-${image.name}`);
logger.debug(`writing temp file to view metadata: ${file}`); logger.debug(`writing temp file to view metadata: ${file}`);
const stream = await datasource.get(image.name); const stream = await datasource.get(image.name);

View file

@ -12,7 +12,6 @@ import { createInvisImage, hashPassword } from 'lib/util';
import { parseExpiry } from 'lib/utils/client'; import { parseExpiry } from 'lib/utils/client';
import { removeGPSData } from 'lib/utils/exif'; import { removeGPSData } from 'lib/utils/exif';
import multer from 'multer'; import multer from 'multer';
import { tmpdir } from 'os';
import { join } from 'path'; import { join } from 'path';
import sharp from 'sharp'; import sharp from 'sharp';
@ -104,12 +103,12 @@ async function handler(req: NextApiReq, res: NextApiRes) {
})}` })}`
); );
const tempFile = join(tmpdir(), `zipline_partial_${identifier}_${start}_${end}`); const tempFile = join(zconfig.core.temp_directory, `zipline_partial_${identifier}_${start}_${end}`);
logger.debug(`writing partial to disk ${tempFile}`); logger.debug(`writing partial to disk ${tempFile}`);
await writeFile(tempFile, req.files[0].buffer); await writeFile(tempFile, req.files[0].buffer);
if (lastchunk) { if (lastchunk) {
const partials = await readdir(tmpdir()).then((files) => const partials = await readdir(zconfig.core.temp_directory).then((files) =>
files.filter((x) => x.startsWith(`zipline_partial_${identifier}`)) files.filter((x) => x.startsWith(`zipline_partial_${identifier}`))
); );
@ -124,8 +123,8 @@ async function handler(req: NextApiReq, res: NextApiRes) {
for (let i = 0; i !== readChunks.length; ++i) { for (let i = 0; i !== readChunks.length; ++i) {
const chunkData = readChunks[i]; const chunkData = readChunks[i];
const buffer = await readFile(join(tmpdir(), chunkData.filename)); const buffer = await readFile(join(zconfig.core.temp_directory, chunkData.filename));
await unlink(join(tmpdir(), readChunks[i].filename)); await unlink(join(zconfig.core.temp_directory, readChunks[i].filename));
chunks.set(buffer, chunkData.start); chunks.set(buffer, chunkData.start);
} }

View file

@ -5,7 +5,6 @@ import datasource from 'lib/datasource';
import Logger from 'lib/logger'; import Logger from 'lib/logger';
import prisma from 'lib/prisma'; import prisma from 'lib/prisma';
import { NextApiReq, NextApiRes, UserExtended, withZipline } from 'middleware/withZipline'; import { NextApiReq, NextApiRes, UserExtended, withZipline } from 'middleware/withZipline';
import { tmpdir } from 'os';
import { join } from 'path'; import { join } from 'path';
const logger = Logger.get('user::export'); const logger = Logger.get('user::export');
@ -22,7 +21,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
const zip = new Zip(); const zip = new Zip();
const export_name = `zipline_export_${user.id}_${Date.now()}.zip`; const export_name = `zipline_export_${user.id}_${Date.now()}.zip`;
const path = join(tmpdir(), export_name); const path = join(config.core.temp_directory, export_name);
logger.debug(`creating write stream at ${path}`); logger.debug(`creating write stream at ${path}`);
const write_stream = createWriteStream(path); const write_stream = createWriteStream(path);
@ -121,18 +120,18 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
const parts = export_name.split('_'); const parts = export_name.split('_');
if (Number(parts[2]) !== user.id) return res.unauthorized('cannot access export owned by another user'); if (Number(parts[2]) !== user.id) return res.unauthorized('cannot access export owned by another user');
const stream = createReadStream(join(tmpdir(), export_name)); const stream = createReadStream(join(config.core.temp_directory, export_name));
res.setHeader('Content-Type', 'application/zip'); res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Disposition', `attachment; filename="${export_name}"`); res.setHeader('Content-Disposition', `attachment; filename="${export_name}"`);
stream.pipe(res); stream.pipe(res);
} else { } else {
const files = await readdir(tmpdir()); const files = await readdir(config.core.temp_directory);
const exp = files.filter((f) => f.startsWith('zipline_export_')); const exp = files.filter((f) => f.startsWith('zipline_export_'));
const exports = []; const exports = [];
for (let i = 0; i !== exp.length; ++i) { for (let i = 0; i !== exp.length; ++i) {
const name = exp[i]; const name = exp[i];
const stats = await stat(join(tmpdir(), name)); const stats = await stat(join(config.core.temp_directory, name));
if (Number(exp[i].split('_')[2]) !== user.id) continue; if (Number(exp[i].split('_')[2]) !== user.id) continue;
exports.push({ name, size: stats.size }); exports.push({ name, size: stats.size });

View file

@ -1,7 +1,6 @@
import { Box, Button, Modal, PasswordInput } from '@mantine/core'; import { Box, Button, Modal, PasswordInput } from '@mantine/core';
import type { File } from '@prisma/client'; import type { File } from '@prisma/client';
import AnchorNext from 'components/AnchorNext'; import AnchorNext from 'components/AnchorNext';
import config from 'lib/config';
import exts from 'lib/exts'; import exts from 'lib/exts';
import prisma from 'lib/prisma'; import prisma from 'lib/prisma';
import { parseString } from 'lib/utils/parser'; import { parseString } from 'lib/utils/parser';
@ -17,18 +16,15 @@ export default function EmbeddedFile({
user, user,
pass, pass,
prismRender, prismRender,
onDash,
compress, compress,
}: { }: {
file: File & { imageProps?: HTMLImageElement }; file: File & { imageProps?: HTMLImageElement };
user: UserExtended; user: UserExtended;
pass: boolean; pass: boolean;
prismRender: boolean; prismRender: boolean;
onDash: boolean;
compress?: boolean; compress?: boolean;
}) { }) {
const dataURL = (route: string) => const dataURL = (route: string) => `${route}/${encodeURI(file.name)}?compress=${compress ?? false}`;
`${route}/${encodeURI(file.name)}?compress=${compress == null ? onDash : compress}`;
const router = useRouter(); const router = useRouter();
const [opened, setOpened] = useState(pass); const [opened, setOpened] = useState(pass);
@ -268,7 +264,6 @@ export const getServerSideProps: GetServerSideProps = async (context) => {
file, file,
user, user,
pass: file.password ? true : false, pass: file.password ? true : false,
onDash: config.core.compression.on_dashboard,
compress, compress,
}, },
}; };

View file

@ -1,3 +1,4 @@
import config from 'lib/config'; import config from 'lib/config';
import { inspect } from 'util';
console.log(JSON.stringify(config, null, 2)); console.log(inspect(config, { depth: Infinity, colors: true }));

View file

@ -1,6 +1,7 @@
import { FastifyInstance } from 'fastify'; import { FastifyInstance } from 'fastify';
import fastifyPlugin from 'fastify-plugin'; import fastifyPlugin from 'fastify-plugin';
import { mkdir } from 'fs/promises'; import { existsSync } from 'fs';
import { mkdir, readdir } from 'fs/promises';
import type { Config } from 'lib/config/Config'; import type { Config } from 'lib/config/Config';
async function configPlugin(fastify: FastifyInstance, config: Config) { async function configPlugin(fastify: FastifyInstance, config: Config) {
@ -16,7 +17,9 @@ async function configPlugin(fastify: FastifyInstance, config: Config) {
.error( .error(
'The config file is located at `.env.local`, or if using docker-compose you can change the variables in the `docker-compose.yml` file.' 'The config file is located at `.env.local`, or if using docker-compose you can change the variables in the `docker-compose.yml` file.'
) )
.error('It is recomended to use a secret that is alphanumeric and randomized.') .error(
'It is recomended to use a secret that is alphanumeric and randomized. If you include special characters, surround the secret with quotes.'
)
.error('A way you can generate this is through a password manager you may have.'); .error('A way you can generate this is through a password manager you may have.');
process.exit(1); process.exit(1);
@ -26,6 +29,24 @@ async function configPlugin(fastify: FastifyInstance, config: Config) {
await mkdir(config.datasource.local.directory, { recursive: true }); await mkdir(config.datasource.local.directory, { recursive: true });
} }
if (!existsSync(config.core.temp_directory)) {
await mkdir(config.core.temp_directory, { recursive: true });
} else {
const files = await readdir(config.core.temp_directory);
if (
files.filter((x: string) => x.startsWith('zipline_partial_') || x.startsWith('zipline-exif-read-'))
.length > 0
)
fastify.logger
.error("Found temporary files in Zipline's temp directory.")
.error('This can happen if Zipline crashes or is stopped while chunking a file.')
.error(
'If you are sure that no files are currently being processed, you can delete the files in the temp directory.'
)
.error('The temp directory is located at: ' + config.core.temp_directory)
.error('If you are unsure, you can safely ignore this message.');
}
return; return;
} }