From 7eb855de8f8560ee3d51840e95b943dfe43e756d Mon Sep 17 00:00:00 2001 From: diced Date: Thu, 27 Oct 2022 19:34:20 -0700 Subject: [PATCH] feat: new file serving method & max views for files --- .../20221028005627_max_views/migration.sql | 2 + .../migration.sql | 2 + prisma/schema.prisma | 2 + src/components/File.tsx | 16 +- src/components/pages/Upload.tsx | 19 ++- src/pages/api/shorten.ts | 6 + src/pages/api/upload.ts | 42 +++--- src/pages/api/user/files.ts | 1 + src/pages/{[...id].tsx => view/[id].tsx} | 142 +++++++----------- src/server/index.ts | 120 ++++++++------- src/server/util.ts | 6 + 11 files changed, 197 insertions(+), 161 deletions(-) create mode 100644 prisma/migrations/20221028005627_max_views/migration.sql create mode 100644 prisma/migrations/20221028011058_max_views_url/migration.sql rename src/pages/{[...id].tsx => view/[id].tsx} (67%) diff --git a/prisma/migrations/20221028005627_max_views/migration.sql b/prisma/migrations/20221028005627_max_views/migration.sql new file mode 100644 index 0000000..597d9fc --- /dev/null +++ b/prisma/migrations/20221028005627_max_views/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Image" ADD COLUMN "maxViews" INTEGER; diff --git a/prisma/migrations/20221028011058_max_views_url/migration.sql b/prisma/migrations/20221028011058_max_views_url/migration.sql new file mode 100644 index 0000000..4136590 --- /dev/null +++ b/prisma/migrations/20221028011058_max_views_url/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Url" ADD COLUMN "maxViews" INTEGER; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4991af3..1b180ea 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -42,6 +42,7 @@ model Image { mimetype String @default("image/png") created_at DateTime @default(now()) expires_at DateTime? + maxViews Int? views Int @default(0) favorite Boolean @default(false) embed Boolean @default(false) @@ -64,6 +65,7 @@ model Url { destination String vanity String? created_at DateTime @default(now()) + maxViews Int? views Int @default(0) invisible InvisibleUrl? user User @relation(fields: [userId], references: [id]) diff --git a/src/components/File.tsx b/src/components/File.tsx index f8fd6dc..270354f 100644 --- a/src/components/File.tsx +++ b/src/components/File.tsx @@ -128,7 +128,15 @@ export default function File({ image, updateImages, disableMediaPreview }) { - + + {image.maxViews && ( + + )} - - - + + + diff --git a/src/components/pages/Upload.tsx b/src/components/pages/Upload.tsx index e1caa1e..71e8b31 100644 --- a/src/components/pages/Upload.tsx +++ b/src/components/pages/Upload.tsx @@ -1,4 +1,14 @@ -import { Button, Collapse, Group, Progress, Select, Title, PasswordInput, Tooltip } from '@mantine/core'; +import { + Button, + Collapse, + Group, + Progress, + Select, + Title, + PasswordInput, + Tooltip, + NumberInput, +} from '@mantine/core'; import { randomId, useClipboard } from '@mantine/hooks'; import { showNotification, updateNotification } from '@mantine/notifications'; import Dropzone from 'components/dropzone/Dropzone'; @@ -20,6 +30,8 @@ export default function Upload({ chunks: chunks_config }) { const [loading, setLoading] = useState(false); const [expires, setExpires] = useState('never'); const [password, setPassword] = useState(''); + const [maxViews, setMaxViews] = useState(undefined); + console.log(maxViews); useEffect(() => { window.addEventListener('paste', (e: ClipboardEvent) => { @@ -139,6 +151,7 @@ export default function Upload({ chunks: chunks_config }) { req.setRequestHeader('X-Zipline-Partial-LastChunk', j === chunks.length - 1 ? 'true' : 'false'); expires !== 'never' && req.setRequestHeader('Expires-At', 'date=' + expires_at.toISOString()); password !== '' && req.setRequestHeader('Password', password); + maxViews && maxViews !== 0 && req.setRequestHeader('Max-Views', String(maxViews)); req.send(body); @@ -285,6 +298,7 @@ export default function Upload({ chunks: chunks_config }) { req.setRequestHeader('Authorization', user.token); expires !== 'never' && req.setRequestHeader('Expires-At', 'date=' + expires_at.toISOString()); password !== '' && req.setRequestHeader('Password', password); + maxViews && maxViews !== 0 && req.setRequestHeader('Max-Views', String(maxViews)); req.send(body); } @@ -307,6 +321,9 @@ export default function Upload({ chunks: chunks_config }) { + + setMaxViews(x)} /> + 100) + return res.error('invalid image compression percent (% < 0 || % > 100)'); + + const fileMaxViews = req.headers['max-views'] ? Number(req.headers['max-views']) : null; + if (isNaN(fileMaxViews)) return res.error('invalid max views (invalid number)'); + if (fileMaxViews < 0) return res.error('invalid max views (max views < 0)'); // handle partial uploads before ratelimits if (req.headers['content-range']) { @@ -130,6 +136,7 @@ async function handler(req: NextApiReq, res: NextApiRes) { format, password, expires_at: expiry, + maxViews: fileMaxViews, }, }); @@ -141,7 +148,7 @@ async function handler(req: NextApiReq, res: NextApiRes) { files: [ `${zconfig.core.https ? 'https' : 'http'}://${req.headers.host}${ zconfig.uploader.route === '/' ? '' : zconfig.uploader.route - }/${file.file}`, + }/${invis ? invis.invis : file.file}`, ], }); } @@ -173,11 +180,11 @@ async function handler(req: NextApiReq, res: NextApiRes) { for (let i = 0; i !== req.files.length; ++i) { const file = req.files[i]; if (file.size > zconfig.uploader[user.administrator ? 'admin_limit' : 'user_limit']) - return res.error(`file[${i}] size too big`); + return res.error(`file[${i}]: size too big`); const ext = file.originalname.split('.').pop(); if (zconfig.uploader.disabled_extensions.includes(ext)) - return res.error('disabled extension recieved: ' + ext); + return res.error(`file[${i}]: disabled extension recieved: ${ext}`); let fileName: string; switch (format) { @@ -214,6 +221,7 @@ async function handler(req: NextApiReq, res: NextApiRes) { format, password, expires_at: expiry, + maxViews: fileMaxViews, }, }); diff --git a/src/pages/api/user/files.ts b/src/pages/api/user/files.ts index 71f915f..3e1ed52 100644 --- a/src/pages/api/user/files.ts +++ b/src/pages/api/user/files.ts @@ -78,6 +78,7 @@ async function handler(req: NextApiReq, res: NextApiRes) { id: true, favorite: true, views: true, + maxViews: true, }, }); diff --git a/src/pages/[...id].tsx b/src/pages/view/[id].tsx similarity index 67% rename from src/pages/[...id].tsx rename to src/pages/view/[id].tsx index f726c9a..44d2fb6 100644 --- a/src/pages/[...id].tsx +++ b/src/pages/view/[id].tsx @@ -6,7 +6,6 @@ import config from 'lib/config'; import prisma from 'lib/prisma'; import { parse } from 'lib/utils/client'; import exts from 'lib/exts'; -import { Image } from '@prisma/client'; import { useRouter } from 'next/router'; export default function EmbeddedImage({ image, user, pass, prismRender }) { @@ -18,7 +17,7 @@ export default function EmbeddedImage({ image, user, pass, prismRender }) { const [error, setError] = useState(''); // reapply date from workaround - image.created_at = new Date(image.created_at); + image.created_at = new Date(image?.created_at); const check = async () => { const res = await fetch(`/api/auth/image?id=${image.id}&password=${password}`); @@ -139,93 +138,50 @@ export default function EmbeddedImage({ image, user, pass, prismRender }) { } export const getServerSideProps: GetServerSideProps = async (context) => { - const route = context.params.id[0]; - const serve_on_root = /(^[^\\.]+\.[^\\.]+)/.test(route); + const { id } = context.params as { id: string }; - const id = serve_on_root ? route : context.params.id[1]; - const uploader_route = config.uploader.route.substring(1); + const image = await prisma.image.findFirst({ + where: { + OR: [{ file: id }, { invisible: { invis: id } }], + }, + select: { + mimetype: true, + id: true, + file: true, + invisible: true, + userId: true, + embed: true, + created_at: true, + password: true, + }, + }); + if (!image) return { notFound: true }; - if (route === config.urls.route.substring(1)) { - const url = await prisma.url.findFirst({ - where: { - OR: [{ id }, { vanity: id }, { invisible: { invis: id } }], - }, - select: { - destination: true, - }, - }); - if (!url) return { notFound: true }; + const user = await prisma.user.findFirst({ + select: { + embedTitle: true, + embedColor: true, + embedSiteName: true, + username: true, + id: true, + }, + where: { + id: image.userId, + }, + }); + //@ts-ignore workaround because next wont allow date + image.created_at = image.created_at.toString(); + + const prismRender = Object.keys(exts).includes(image.file.split('.').pop()); + if (prismRender && !image.password) return { - props: {}, redirect: { - destination: url.destination, + destination: `/code/${image.file}`, + permanent: true, }, }; - } else if (uploader_route === '' ? /(^[^\\.]+\.[^\\.]+)/.test(route) : route === uploader_route) { - const image = await prisma.image.findFirst({ - where: { - OR: [{ file: id }, { invisible: { invis: id } }], - }, - select: { - mimetype: true, - id: true, - file: true, - invisible: true, - userId: true, - embed: true, - created_at: true, - password: true, - }, - }); - if (!image) return { notFound: true }; - - const user = await prisma.user.findFirst({ - select: { - embedTitle: true, - embedColor: true, - embedSiteName: true, - username: true, - id: true, - }, - where: { - id: image.userId, - }, - }); - - //@ts-ignore workaround because next wont allow date - image.created_at = image.created_at.toString(); - - const prismRender = Object.keys(exts).includes(image.file.split('.').pop()); - if (prismRender && !image.password) - return { - redirect: { - destination: `/code/${image.file}`, - permanent: true, - }, - }; - else if (prismRender && image.password) { - const pass = image.password ? true : false; - delete image.password; - return { - props: { - image, - user, - pass, - prismRender: true, - }, - }; - } - - if (!image.mimetype.startsWith('image') && !image.mimetype.startsWith('video')) { - const { default: datasource } = await import('lib/datasource'); - - const data = await datasource.get(image.file); - if (!data) return { notFound: true }; - - data.pipe(context.res); - return { props: {} }; - } + else if (prismRender && image.password) { const pass = image.password ? true : false; delete image.password; return { @@ -233,9 +189,27 @@ export const getServerSideProps: GetServerSideProps = async (context) => { image, user, pass, + prismRender: true, }, }; - } else { - return { notFound: true }; } + + if (!image.mimetype.startsWith('image') && !image.mimetype.startsWith('video')) { + const { default: datasource } = await import('lib/datasource'); + + const data = await datasource.get(image.file); + if (!data) return { notFound: true }; + + data.pipe(context.res); + return { props: {} }; + } + const pass = image.password ? true : false; + delete image.password; + return { + props: { + image, + user, + pass, + }, + }; }; diff --git a/src/server/index.ts b/src/server/index.ts index 34a6c99..b984654 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -5,13 +5,14 @@ import { Image, PrismaClient } from '@prisma/client'; import { createServer, IncomingMessage, OutgoingMessage, ServerResponse } from 'http'; import { extname } from 'path'; import { mkdir } from 'fs/promises'; -import { getStats, log, migrations } from './util'; +import { getStats, log, migrations, redirect } from './util'; import Logger from '../lib/logger'; import { guess } from '../lib/mimes'; import exts from '../lib/exts'; import { version } from '../../package.json'; import config from '../lib/config'; import datasource from '../lib/datasource'; +import { NextUrlWithParsedQuery } from 'next/dist/server/request-meta'; const dev = process.env.NODE_ENV === 'development'; const logger = Logger.get('server'); @@ -78,26 +79,54 @@ async function start() { }, }); - router.on( - 'GET', - config.uploader.route === '/' ? '/:id(^[^\\.]+\\.[^\\.]+)' : `${config.uploader.route}/:id`, - async (req, res, params) => { - if (params.id === '') return nextServer.render404(req, res as ServerResponse); + router.on('GET', `${config.urls.route}/:id`, async (req, res, params) => { + if (params.id === '') return nextServer.render404(req, res as ServerResponse); - const image = await prisma.image.findFirst({ + const url = await prisma.url.findFirst({ + where: { + OR: [{ id: params.id }, { vanity: params.id }, { invisible: { invis: decodeURI(params.id) } }], + }, + }); + if (!url) return nextServer.render404(req, res as ServerResponse); + + const nUrl = await prisma.url.update({ + where: { + id: url.id, + }, + data: { + views: { increment: 1 }, + }, + }); + + if (nUrl.maxViews && nUrl.views >= nUrl.maxViews) { + await prisma.url.delete({ where: { - OR: [{ file: params.id }, { invisible: { invis: decodeURI(params.id) } }], + id: nUrl.id, }, }); - if (!image) await rawFile(req, res, nextServer, params.id); - else { - if (image.password) await handle(req, res); - else if (image.embed) await handle(req, res); - else await fileDb(req, res, nextServer, prisma, handle, image); - } + return nextServer.render404(req, res as ServerResponse); } - ); + + return redirect(res, url.destination); + }); + + router.on('GET', `${config.uploader.route}/:id`, async (req, res, params) => { + if (params.id === '') return nextServer.render404(req, res as ServerResponse); + + const image = await prisma.image.findFirst({ + where: { + OR: [{ file: params.id }, { invisible: { invis: decodeURI(params.id) } }], + }, + }); + + if (!image) await rawFile(req, res, nextServer, params.id); + else { + if (image.password) return redirect(res, `/view/${image.file}`); + else if (image.embed) await handle(req, res); + else await fileDb(req, res, nextServer, prisma, handle, image); + } + }); router.on('GET', '/r/:id', async (req, res, params) => { if (params.id === '') return nextServer.render404(req, res as ServerResponse); @@ -110,8 +139,13 @@ async function start() { if (!image) await rawFile(req, res, nextServer, params.id); else { - if (image.password) await handle(req, res); - else await rawFileDb(req, res, nextServer, prisma, image); + if (image.password) { + res.setHeader('Content-Type', 'application/json'); + res.statusCode = 403; + return res.end( + JSON.stringify({ error: "can't view a raw file that has a password", url: `/view/${image.file}` }) + ); + } else await rawFile(req, res, nextServer, params.id); } }); @@ -167,39 +201,6 @@ async function rawFile(req: IncomingMessage, res: OutgoingMessage, nextServer: N data.on('end', () => res.end()); } -async function rawFileDb( - req: IncomingMessage, - res: OutgoingMessage, - nextServer: NextServer, - prisma: PrismaClient, - image: Image -) { - if (image.expires_at && image.expires_at < new Date()) { - Logger.get('server').info(`${image.file} expired`); - await datasource.delete(image.file); - await prisma.image.delete({ where: { id: image.id } }); - - return nextServer.render404(req, res as ServerResponse); - } - - const data = await datasource.get(image.file); - if (!data) return nextServer.render404(req, res as ServerResponse); - - const size = await datasource.size(image.file); - - res.setHeader('Content-Type', image.mimetype); - res.setHeader('Content-Length', size); - - data.pipe(res); - data.on('error', () => nextServer.render404(req, res as ServerResponse)); - data.on('end', () => res.end()); - - await prisma.image.update({ - where: { id: image.id }, - data: { views: { increment: 1 } }, - }); -} - async function fileDb( req: IncomingMessage, res: OutgoingMessage, @@ -221,6 +222,20 @@ async function fileDb( const data = await datasource.get(image.file); if (!data) return nextServer.render404(req, res as ServerResponse); + const nImage = await prisma.image.update({ + where: { id: image.id }, + data: { views: { increment: 1 } }, + }); + + if (nImage.maxViews && nImage.views >= nImage.maxViews) { + await datasource.delete(image.file); + await prisma.image.delete({ where: { id: image.id } }); + + Logger.get('image').info(`Image ${image.file} has been deleted due to max views (${nImage.maxViews})`); + + return nextServer.render404(req, res as ServerResponse); + } + const size = await datasource.size(image.file); res.setHeader('Content-Type', image.mimetype); @@ -228,11 +243,6 @@ async function fileDb( data.pipe(res); data.on('error', () => nextServer.render404(req, res as ServerResponse)); data.on('end', () => res.end()); - - await prisma.image.update({ - where: { id: image.id }, - data: { views: { increment: 1 } }, - }); } async function stats(prisma: PrismaClient) { diff --git a/src/server/util.ts b/src/server/util.ts index cc3ec31..84f13f0 100644 --- a/src/server/util.ts +++ b/src/server/util.ts @@ -4,6 +4,7 @@ import Logger from '../lib/logger'; import { bytesToHuman } from '../lib/utils/bytes'; import { Datasource } from '../lib/datasources'; import { PrismaClient } from '@prisma/client'; +import { ServerResponse } from 'http'; export async function migrations() { try { @@ -37,6 +38,11 @@ export function log(url: string) { return Logger.get('url').info(url); } +export function redirect(res: ServerResponse, url: string) { + res.writeHead(307, { Location: url }); + res.end(); +} + export async function getStats(prisma: PrismaClient, datasource: Datasource) { const size = await datasource.fullSize(); const byUser = await prisma.image.groupBy({