diff --git a/Dockerfile b/Dockerfile index ae4a27a..89dd431 100644 --- a/Dockerfile +++ b/Dockerfile @@ -69,7 +69,8 @@ COPY --from=builder /zipline/node_modules/@prisma/client ./node_modules/@prisma/ # Copy Startup Script COPY docker-entrypoint.sh /zipline + # 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 ENTRYPOINT ["tini", "--", "/zipline/docker-entrypoint.sh"] \ No newline at end of file diff --git a/src/components/pages/Manage/ClearStorage.tsx b/src/components/pages/Manage/ClearStorage.tsx index 4185105..05a2132 100644 --- a/src/components/pages/Manage/ClearStorage.tsx +++ b/src/components/pages/Manage/ClearStorage.tsx @@ -3,8 +3,10 @@ import { closeAllModals, openConfirmModal } from '@mantine/modals'; import { showNotification, updateNotification } from '@mantine/notifications'; import { IconFiles, IconFilesOff } from '@tabler/icons-react'; 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) => { showNotification({ id: 'clear-uploads', @@ -38,7 +40,10 @@ export default function ClearStorage({ open, setOpen, check, setCheck }) { return ( setOpen(false)} + onClose={() => { + setOpen(false); + setCheck(() => false); + }} title={Are you sure you want to clear all uploads in the database?} > , }); - setTotpEnabled(false); - + setUser((user) => ({ ...user, totpSecret: null })); onClose(); } @@ -83,8 +82,7 @@ export function TotpModal({ opened, onClose, deleteTotp, setTotpEnabled }) { icon: , }); - setTotpEnabled(true); - + setUser((user) => ({ ...user, totpSecret: secret })); onClose(); } diff --git a/src/components/pages/Manage/index.tsx b/src/components/pages/Manage/index.tsx index 117efec..5e2555b 100644 --- a/src/components/pages/Manage/index.tsx +++ b/src/components/pages/Manage/index.tsx @@ -89,7 +89,6 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_ const [file, setFile] = useState(null); const [fileDataURL, setFileDataURL] = useState(user.avatar ?? null); const [totpEnabled, setTotpEnabled] = useState(!!user.totpSecret); - const [checked, setCheck] = useState(false); const getDataURL = (f: File): Promise => { return new Promise((res, rej) => { @@ -355,7 +354,8 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_ useEffect(() => { getExports(); interval.start(); - }, [totpEnabled]); + setTotpEnabled(() => !!user.totpSecret); + }, [user]); return ( <> @@ -450,7 +450,7 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_ opened={totpOpen} onClose={() => setTotpOpen(false)} deleteTotp={totpEnabled} - setTotpEnabled={setTotpEnabled} + setUser={setUser} /> )} @@ -626,7 +626,7 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_ - + ); } diff --git a/src/components/pages/Upload/File.tsx b/src/components/pages/Upload/File.tsx index f718515..930623e 100644 --- a/src/components/pages/Upload/File.tsx +++ b/src/components/pages/Upload/File.tsx @@ -13,8 +13,11 @@ import { useEffect, useState } from 'react'; import { useRecoilValue } from 'recoil'; import showFilesModal from './showFilesModal'; import useUploadOptions from './useUploadOptions'; +import { useRouter } from 'next/router'; export default function File({ chunks: chunks_config }) { + const router = useRouter(); + const clipboard = useClipboard(); const modals = useModals(); const user = useRecoilValue(userSelector); @@ -25,6 +28,24 @@ export default function File({ chunks: chunks_config }) { 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(() => { const listener = (e: ClipboardEvent) => { 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); - 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[]) => { for (let i = 0; i !== toChunkFiles.length; ++i) { diff --git a/src/lib/config/Config.ts b/src/lib/config/Config.ts index 42aadc1..5e6b595 100644 --- a/src/lib/config/Config.ts +++ b/src/lib/config/Config.ts @@ -1,5 +1,6 @@ export interface ConfigCore { return_https: boolean; + temp_directory: string; secret: string; host: string; port: number; diff --git a/src/lib/config/readConfig.ts b/src/lib/config/readConfig.ts index a4e314f..9656c2c 100644 --- a/src/lib/config/readConfig.ts +++ b/src/lib/config/readConfig.ts @@ -57,6 +57,7 @@ export default function readConfig() { const maps = [ map('CORE_RETURN_HTTPS', 'boolean', 'core.return_https'), + map('CORE_TEMP_DIRECTORY', 'path', 'core.temp_directory'), map('CORE_SECRET', 'string', 'core.secret'), map('CORE_HOST', 'string', 'core.host'), map('CORE_PORT', 'number', 'core.port'), diff --git a/src/lib/config/validateConfig.ts b/src/lib/config/validateConfig.ts index 59ba76f..89581c3 100644 --- a/src/lib/config/validateConfig.ts +++ b/src/lib/config/validateConfig.ts @@ -3,6 +3,8 @@ import type { Config } from './Config'; import { inspect } from 'util'; import Logger from 'lib/logger'; import { humanToBytes } from 'utils/bytes'; +import { tmpdir } from 'os'; +import { join } from 'path'; const discord_content = s .object({ @@ -27,6 +29,7 @@ const discord_content = s const validator = s.object({ core: s.object({ return_https: s.boolean.default(false), + temp_directory: s.string.default(join(tmpdir(), 'zipline')), secret: s.string.lengthGreaterThanOrEqual(8), host: s.string.default('0.0.0.0'), port: s.number.default(3000), diff --git a/src/lib/utils/exif.ts b/src/lib/utils/exif.ts index 7372376..377cec2 100644 --- a/src/lib/utils/exif.ts +++ b/src/lib/utils/exif.ts @@ -3,7 +3,6 @@ import { createWriteStream } from 'fs'; import { ExifTool, Tags } from 'exiftool-vendored'; import datasource from 'lib/datasource'; import Logger from 'lib/logger'; -import { tmpdir } from 'os'; import { join } from 'path'; import { readFile, unlink } from 'fs/promises'; @@ -34,7 +33,7 @@ export async function readMetadata(filePath: string): Promise { export async function removeGPSData(image: File): Promise { 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}`); const stream = await datasource.get(image.name); diff --git a/src/lib/utils/parser.ts b/src/lib/utils/parser.ts index b9b8957..b1f2666 100644 --- a/src/lib/utils/parser.ts +++ b/src/lib/utils/parser.ts @@ -11,7 +11,10 @@ export type ParseValue = { export function parseString(str: string, value: ParseValue) { 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 = /\{(?file|url|user)\.(?\w+)(::(?\w+))?\}/gi; let matches: RegExpMatchArray; diff --git a/src/pages/api/exif.ts b/src/pages/api/exif.ts index f42b54b..588a2c5 100644 --- a/src/pages/api/exif.ts +++ b/src/pages/api/exif.ts @@ -6,7 +6,6 @@ import Logger from 'lib/logger'; import prisma from 'lib/prisma'; import { readMetadata } from 'lib/utils/exif'; import { NextApiReq, NextApiRes, UserExtended, withZipline } from 'middleware/withZipline'; -import { tmpdir } from 'os'; import { join } from 'path'; const logger = Logger.get('exif'); @@ -41,7 +40,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) { return res.json(data); } 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}`); const stream = await datasource.get(image.name); diff --git a/src/pages/api/upload.ts b/src/pages/api/upload.ts index e6b5bbe..622c872 100644 --- a/src/pages/api/upload.ts +++ b/src/pages/api/upload.ts @@ -12,7 +12,6 @@ import { createInvisImage, hashPassword } from 'lib/util'; import { parseExpiry } from 'lib/utils/client'; import { removeGPSData } from 'lib/utils/exif'; import multer from 'multer'; -import { tmpdir } from 'os'; import { join } from 'path'; 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}`); await writeFile(tempFile, req.files[0].buffer); 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}`)) ); @@ -124,8 +123,8 @@ async function handler(req: NextApiReq, res: NextApiRes) { for (let i = 0; i !== readChunks.length; ++i) { const chunkData = readChunks[i]; - const buffer = await readFile(join(tmpdir(), chunkData.filename)); - await unlink(join(tmpdir(), readChunks[i].filename)); + const buffer = await readFile(join(zconfig.core.temp_directory, chunkData.filename)); + await unlink(join(zconfig.core.temp_directory, readChunks[i].filename)); chunks.set(buffer, chunkData.start); } diff --git a/src/pages/api/user/export.ts b/src/pages/api/user/export.ts index 1e794dc..e9e8e0b 100644 --- a/src/pages/api/user/export.ts +++ b/src/pages/api/user/export.ts @@ -5,7 +5,6 @@ import datasource from 'lib/datasource'; import Logger from 'lib/logger'; import prisma from 'lib/prisma'; import { NextApiReq, NextApiRes, UserExtended, withZipline } from 'middleware/withZipline'; -import { tmpdir } from 'os'; import { join } from 'path'; const logger = Logger.get('user::export'); @@ -22,7 +21,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) { const zip = new 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}`); const write_stream = createWriteStream(path); @@ -121,18 +120,18 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) { const parts = export_name.split('_'); 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-Disposition', `attachment; filename="${export_name}"`); stream.pipe(res); } else { - const files = await readdir(tmpdir()); + const files = await readdir(config.core.temp_directory); const exp = files.filter((f) => f.startsWith('zipline_export_')); const exports = []; for (let i = 0; i !== exp.length; ++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; exports.push({ name, size: stats.size }); diff --git a/src/pages/view/[id].tsx b/src/pages/view/[id].tsx index 22f64f7..1f7ed6b 100644 --- a/src/pages/view/[id].tsx +++ b/src/pages/view/[id].tsx @@ -1,7 +1,6 @@ import { Box, Button, Modal, PasswordInput } from '@mantine/core'; import type { File } from '@prisma/client'; import AnchorNext from 'components/AnchorNext'; -import config from 'lib/config'; import exts from 'lib/exts'; import prisma from 'lib/prisma'; import { parseString } from 'lib/utils/parser'; @@ -17,18 +16,15 @@ export default function EmbeddedFile({ user, pass, prismRender, - onDash, compress, }: { file: File & { imageProps?: HTMLImageElement }; user: UserExtended; pass: boolean; prismRender: boolean; - onDash: boolean; compress?: boolean; }) { - const dataURL = (route: string) => - `${route}/${encodeURI(file.name)}?compress=${compress == null ? onDash : compress}`; + const dataURL = (route: string) => `${route}/${encodeURI(file.name)}?compress=${compress ?? false}`; const router = useRouter(); const [opened, setOpened] = useState(pass); @@ -268,7 +264,6 @@ export const getServerSideProps: GetServerSideProps = async (context) => { file, user, pass: file.password ? true : false, - onDash: config.core.compression.on_dashboard, compress, }, }; diff --git a/src/scripts/read-config.ts b/src/scripts/read-config.ts index 0b6e935..dc542b8 100644 --- a/src/scripts/read-config.ts +++ b/src/scripts/read-config.ts @@ -1,3 +1,4 @@ import config from 'lib/config'; +import { inspect } from 'util'; -console.log(JSON.stringify(config, null, 2)); +console.log(inspect(config, { depth: Infinity, colors: true })); diff --git a/src/server/plugins/config.ts b/src/server/plugins/config.ts index 358e004..d3f24eb 100644 --- a/src/server/plugins/config.ts +++ b/src/server/plugins/config.ts @@ -1,6 +1,7 @@ import { FastifyInstance } from 'fastify'; 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'; async function configPlugin(fastify: FastifyInstance, config: Config) { @@ -16,7 +17,9 @@ async function configPlugin(fastify: FastifyInstance, config: Config) { .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.' ) - .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.'); process.exit(1); @@ -26,6 +29,24 @@ async function configPlugin(fastify: FastifyInstance, config: Config) { 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; }