feat: new file serving method & max views for files

This commit is contained in:
diced 2022-10-27 19:34:20 -07:00
parent d5984f4141
commit 7eb855de8f
No known key found for this signature in database
GPG key ID: 370BD1BA142842D1
11 changed files with 197 additions and 161 deletions

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Image" ADD COLUMN "maxViews" INTEGER;

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Url" ADD COLUMN "maxViews" INTEGER;

View file

@ -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])

View file

@ -128,7 +128,15 @@ export default function File({ image, updateImages, disableMediaPreview }) {
<Stack>
<FileMeta Icon={FileIcon} title='Name' subtitle={image.file} />
<FileMeta Icon={ImageIcon} title='Type' subtitle={image.mimetype} />
<FileMeta Icon={EyeIcon} title='Views' subtitle={image.views} />
<FileMeta Icon={EyeIcon} title='Views' subtitle={image.views.toLocaleString()} />
{image.maxViews && (
<FileMeta
Icon={EyeIcon}
title='Max views'
subtitle={image.maxViews.toLocaleString()}
tooltip={`This file will be deleted after being viewed ${image.maxViews.toLocaleString()} times.`}
/>
)}
<FileMeta
Icon={CalendarIcon}
title='Uploaded at'
@ -147,12 +155,12 @@ export default function File({ image, updateImages, disableMediaPreview }) {
</Stack>
<Group position='right' mt='md'>
<Link href={image.url} target='_blank'>
<Button rightIcon={<ExternalLinkIcon />}>Open</Button>
</Link>
<Button onClick={handleCopy}>Copy URL</Button>
<Button onClick={handleDelete}>Delete</Button>
<Button onClick={handleFavorite}>{image.favorite ? 'Unfavorite' : 'Favorite'}</Button>
<Link href={image.url} target='_blank'>
<Button rightIcon={<ExternalLinkIcon />}>Open</Button>
</Link>
</Group>
</Modal>
<Card sx={{ maxWidth: '100%', height: '100%' }} shadow='md'>

View file

@ -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<number>(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 }) {
</Collapse>
<Group position='right' mt='md'>
<Tooltip label='After the file reaches this amount of views, it will be deleted automatically. Leave blank for no limit.'>
<NumberInput placeholder='Max Views' min={0} value={maxViews} onChange={(x) => setMaxViews(x)} />
</Tooltip>
<Tooltip label='Add a password to your files (optional, leave blank for none)'>
<PasswordInput
style={{ width: '252px' }}

View file

@ -19,6 +19,11 @@ async function handler(req: NextApiReq, res: NextApiRes) {
if (!user) return res.forbid('authorization incorect');
if (!req.body) return res.error('no body');
if (!req.body.url) return res.error('no url');
const maxUrlViews = req.headers['max-views'] ? Number(req.headers['max-views']) : null;
if (isNaN(maxUrlViews)) return res.error('invalid max views (invalid number)');
if (maxUrlViews < 0) return res.error('invalid max views (max views < 0)');
const rand = randomChars(zconfig.urls.length);
let invis;
@ -39,6 +44,7 @@ async function handler(req: NextApiReq, res: NextApiRes) {
vanity: req.body.vanity ?? null,
destination: req.body.url,
userId: user.id,
maxViews: maxUrlViews,
},
});

View file

@ -1,20 +1,19 @@
import multer from 'multer';
import prisma from 'lib/prisma';
import zconfig from 'lib/config';
import { NextApiReq, NextApiRes, withZipline } from 'lib/middleware/withZipline';
import { createInvisImage, randomChars, hashPassword } from 'lib/util';
import Logger from 'lib/logger';
import { ImageFormat, InvisibleImage } from '@prisma/client';
import dayjs from 'dayjs';
import datasource from 'lib/datasource';
import { randomUUID } from 'crypto';
import sharp from 'sharp';
import { parseExpiry } from 'lib/utils/client';
import { sendUpload } from 'lib/discord';
import { join } from 'path';
import { tmpdir } from 'os';
import dayjs from 'dayjs';
import { readdir, readFile, unlink, writeFile } from 'fs/promises';
import { guess } from 'lib/mimes';
import zconfig from 'lib/config';
import datasource from 'lib/datasource';
import { sendUpload } from 'lib/discord';
import Logger from 'lib/logger';
import { NextApiReq, NextApiRes, withZipline } from 'lib/middleware/withZipline';
import prisma from 'lib/prisma';
import { createInvisImage, hashPassword, randomChars } from 'lib/util';
import { parseExpiry } from 'lib/utils/client';
import multer from 'multer';
import { tmpdir } from 'os';
import { join } from 'path';
import sharp from 'sharp';
const uploader = multer();
@ -50,6 +49,13 @@ async function handler(req: NextApiReq, res: NextApiRes) {
const imageCompressionPercent = req.headers['image-compression-percent']
? Number(req.headers['image-compression-percent'])
: null;
if (isNaN(imageCompressionPercent)) return res.error('invalid image compression percent (invalid number)');
if (imageCompressionPercent < 0 || imageCompressionPercent > 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,
},
});

View file

@ -78,6 +78,7 @@ async function handler(req: NextApiReq, res: NextApiRes) {
id: true,
favorite: true,
views: true,
maxViews: true,
},
});

View file

@ -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,
},
};
};

View file

@ -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) {

View file

@ -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({