mirror of
https://github.com/diced/zipline.git
synced 2025-05-11 10:26:05 +02:00
387 lines
13 KiB
TypeScript
387 lines
13 KiB
TypeScript
import { FileNameFormat, InvisibleFile } from '@prisma/client';
|
|
import { randomUUID } from 'crypto';
|
|
import dayjs from 'dayjs';
|
|
import { readdir, readFile, unlink, writeFile } from 'fs/promises';
|
|
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 { removeGPSData } from 'lib/utils/exif';
|
|
import multer from 'multer';
|
|
import { tmpdir } from 'os';
|
|
import { join } from 'path';
|
|
import sharp from 'sharp';
|
|
|
|
const uploader = multer();
|
|
const logger = Logger.get('upload');
|
|
|
|
async function handler(req: NextApiReq, res: NextApiRes) {
|
|
if (!req.headers.authorization) return res.forbidden('no authorization');
|
|
|
|
const user = await prisma.user.findFirst({
|
|
where: {
|
|
token: req.headers.authorization,
|
|
},
|
|
});
|
|
|
|
if (!user) return res.forbidden('authorization incorrect');
|
|
|
|
await new Promise((resolve, reject) => {
|
|
uploader.array('file')(req as any, res as any, (result: unknown) => {
|
|
if (result instanceof Error) reject(result.message);
|
|
resolve(result);
|
|
});
|
|
});
|
|
|
|
const response: { files: string[]; expiresAt?: Date; removed_gps?: boolean } = { files: [] };
|
|
const expiresAt = req.headers['expires-at'] as string;
|
|
let expiry: Date;
|
|
|
|
if (expiresAt) {
|
|
expiry = parseExpiry(expiresAt);
|
|
if (!expiry) return res.badRequest('invalid date');
|
|
else {
|
|
response.expiresAt = expiry;
|
|
}
|
|
}
|
|
|
|
if (zconfig.uploader.default_expiration) {
|
|
expiry = parseExpiry(zconfig.uploader.default_expiration);
|
|
if (!expiry) return res.badRequest('invalid date (UPLOADER_DEFAULT_EXPIRATION)');
|
|
}
|
|
|
|
const rawFormat = ((req.headers.format || '') as string).toUpperCase() || zconfig.uploader.default_format;
|
|
const format: FileNameFormat = Object.keys(FileNameFormat).includes(rawFormat) && FileNameFormat[rawFormat];
|
|
|
|
const imageCompressionPercent = req.headers['image-compression-percent']
|
|
? Number(req.headers['image-compression-percent'])
|
|
: null;
|
|
if (isNaN(imageCompressionPercent))
|
|
return res.badRequest('invalid image compression percent (invalid number)');
|
|
if (imageCompressionPercent < 0 || imageCompressionPercent > 100)
|
|
return res.badRequest('invalid image compression percent (% < 0 || % > 100)');
|
|
|
|
const fileMaxViews = req.headers['max-views'] ? Number(req.headers['max-views']) : null;
|
|
if (isNaN(fileMaxViews)) return res.badRequest('invalid max views (invalid number)');
|
|
if (fileMaxViews < 0) return res.badRequest('invalid max views (max views < 0)');
|
|
|
|
// handle partial uploads before ratelimits
|
|
if (req.headers['content-range']) {
|
|
// parses content-range header (bytes start-end/total)
|
|
const [start, end, total] = req.headers['content-range']
|
|
.replace('bytes ', '')
|
|
.replace('-', '/')
|
|
.split('/')
|
|
.map((x) => Number(x));
|
|
|
|
const filename = req.headers['x-zipline-partial-filename'] as string;
|
|
const mimetype = req.headers['x-zipline-partial-mimetype'] as string;
|
|
const identifier = req.headers['x-zipline-partial-identifier'];
|
|
const lastchunk = req.headers['x-zipline-partial-lastchunk'] === 'true';
|
|
|
|
logger.debug(
|
|
`recieved partial upload ${JSON.stringify({
|
|
filename,
|
|
mimetype,
|
|
identifier,
|
|
lastchunk,
|
|
start,
|
|
end,
|
|
total,
|
|
})}`
|
|
);
|
|
|
|
const tempFile = join(tmpdir(), `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) =>
|
|
files.filter((x) => x.startsWith(`zipline_partial_${identifier}`))
|
|
);
|
|
|
|
const readChunks = partials.map((x) => {
|
|
const [, , , start, end] = x.split('_');
|
|
return { start: Number(start), end: Number(end), filename: x };
|
|
});
|
|
|
|
// combine chunks
|
|
const chunks = new Uint8Array(total);
|
|
|
|
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));
|
|
|
|
chunks.set(buffer, chunkData.start);
|
|
}
|
|
|
|
const ext = filename.split('.').length === 1 ? '' : filename.split('.').pop();
|
|
if (zconfig.uploader.disabled_extensions.includes(ext))
|
|
return res.error('disabled extension recieved: ' + ext);
|
|
let fileName: string;
|
|
|
|
switch (format) {
|
|
case FileNameFormat.RANDOM:
|
|
fileName = randomChars(zconfig.uploader.length);
|
|
break;
|
|
case FileNameFormat.DATE:
|
|
fileName = dayjs().format(zconfig.uploader.format_date);
|
|
break;
|
|
case FileNameFormat.UUID:
|
|
fileName = randomUUID({ disableEntropyCache: true });
|
|
break;
|
|
case FileNameFormat.NAME:
|
|
fileName = filename.split('.')[0];
|
|
break;
|
|
default:
|
|
fileName = randomChars(zconfig.uploader.length);
|
|
break;
|
|
}
|
|
|
|
let password = null;
|
|
if (req.headers.password) {
|
|
password = await hashPassword(req.headers.password as string);
|
|
}
|
|
|
|
const compressionUsed = imageCompressionPercent && mimetype.startsWith('image/');
|
|
let invis: InvisibleFile;
|
|
|
|
const file = await prisma.file.create({
|
|
data: {
|
|
name: `${fileName}${compressionUsed ? '.jpg' : `${ext ? '.' : ''}${ext}`}`,
|
|
mimetype,
|
|
userId: user.id,
|
|
embed: !!req.headers.embed,
|
|
format,
|
|
password,
|
|
expiresAt: expiry,
|
|
maxViews: fileMaxViews,
|
|
originalName: req.headers['original-name'] ? filename ?? null : null,
|
|
},
|
|
});
|
|
|
|
if (req.headers.zws) invis = await createInvisImage(zconfig.uploader.length, file.id);
|
|
|
|
await datasource.save(file.name, Buffer.from(chunks));
|
|
|
|
logger.info(`User ${user.username} (${user.id}) uploaded ${file.name} (${file.id}) (chunked)`);
|
|
let domain;
|
|
if (req.headers['override-domain']) {
|
|
domain = `${zconfig.core.return_https ? 'https' : 'http'}://${req.headers['override-domain']}`;
|
|
} else if (user.domains.length) {
|
|
domain = user.domains[Math.floor(Math.random() * user.domains.length)];
|
|
} else {
|
|
domain = `${zconfig.core.return_https ? 'https' : 'http'}://${req.headers.host}`;
|
|
}
|
|
|
|
const responseUrl = `${domain}${zconfig.uploader.route === '/' ? '/' : zconfig.uploader.route + '/'}${
|
|
invis ? invis.invis : encodeURI(file.name)
|
|
}`;
|
|
|
|
response.files.push(responseUrl);
|
|
|
|
if (zconfig.discord?.upload) {
|
|
await sendUpload(user, file, `${domain}/r/${invis ? invis.invis : file.name}`, responseUrl);
|
|
}
|
|
|
|
if (zconfig.exif.enabled && zconfig.exif.remove_gps && mimetype.startsWith('image/')) {
|
|
try {
|
|
await removeGPSData(file);
|
|
response.removed_gps = true;
|
|
} catch (e) {
|
|
logger.error(`Failed to remove GPS data from ${file.name} (${file.id}) - ${e.message}`);
|
|
|
|
response.removed_gps = false;
|
|
}
|
|
}
|
|
|
|
return res.json(response);
|
|
}
|
|
|
|
return res.json({
|
|
success: true,
|
|
});
|
|
}
|
|
|
|
if (user.ratelimit) {
|
|
const remaining = user.ratelimit.getTime() - Date.now();
|
|
logger.debug(`${user.id} encountered ratelimit, ${remaining}ms remaining`);
|
|
if (remaining <= 0) {
|
|
await prisma.user.update({
|
|
where: {
|
|
id: user.id,
|
|
},
|
|
data: {
|
|
ratelimit: null,
|
|
},
|
|
});
|
|
} else {
|
|
return res.ratelimited(remaining);
|
|
}
|
|
}
|
|
|
|
if (!req.files) return res.badRequest('no files');
|
|
if (req.files && req.files.length === 0) return res.badRequest('no files');
|
|
|
|
logger.debug(
|
|
`recieved upload (len=${req.files.length}) ${JSON.stringify(
|
|
req.files.map((x) => ({
|
|
fieldname: x.fieldname,
|
|
originalname: x.originalname,
|
|
mimetype: x.mimetype,
|
|
size: x.size,
|
|
encoding: x.encoding,
|
|
}))
|
|
)}`
|
|
);
|
|
|
|
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.badRequest(`file[${i}]: size too big`);
|
|
if (!file.originalname) return res.badRequest(`file[${i}]: no filename`);
|
|
|
|
const ext = file.originalname.split('.').length === 1 ? '' : file.originalname.split('.').pop();
|
|
if (zconfig.uploader.disabled_extensions.includes(ext))
|
|
return res.badRequest(`file[${i}]: disabled extension recieved: ${ext}`);
|
|
let fileName: string;
|
|
|
|
switch (format) {
|
|
case FileNameFormat.RANDOM:
|
|
fileName = randomChars(zconfig.uploader.length);
|
|
break;
|
|
case FileNameFormat.DATE:
|
|
fileName = dayjs().format(zconfig.uploader.format_date);
|
|
break;
|
|
case FileNameFormat.UUID:
|
|
fileName = randomUUID({ disableEntropyCache: true });
|
|
break;
|
|
case FileNameFormat.NAME:
|
|
fileName = file.originalname.split('.')[0];
|
|
break;
|
|
default:
|
|
if (req.headers['x-zipline-filename']) {
|
|
fileName = req.headers['x-zipline-filename'] as string;
|
|
const existing = await prisma.file.findFirst({
|
|
where: {
|
|
name: fileName,
|
|
},
|
|
});
|
|
if (existing) return res.badRequest(`file[${i}]: filename already exists: '${fileName}'`);
|
|
|
|
break;
|
|
}
|
|
fileName = randomChars(zconfig.uploader.length);
|
|
break;
|
|
}
|
|
|
|
let password = null;
|
|
if (req.headers.password) {
|
|
password = await hashPassword(req.headers.password as string);
|
|
}
|
|
|
|
const compressionUsed = imageCompressionPercent && file.mimetype.startsWith('image/');
|
|
let invis: InvisibleFile;
|
|
const fileUpload = await prisma.file.create({
|
|
data: {
|
|
name: `${fileName}${compressionUsed ? '.jpg' : `${ext ? '.' : ''}${ext}`}`,
|
|
mimetype: req.headers.uploadtext ? 'text/plain' : compressionUsed ? 'image/jpeg' : file.mimetype,
|
|
userId: user.id,
|
|
embed: !!req.headers.embed,
|
|
format,
|
|
password,
|
|
expiresAt: expiry,
|
|
maxViews: fileMaxViews,
|
|
originalName: req.headers['original-name'] ? file.originalname ?? null : null,
|
|
},
|
|
});
|
|
|
|
if (req.headers.zws) invis = await createInvisImage(zconfig.uploader.length, fileUpload.id);
|
|
|
|
if (compressionUsed) {
|
|
const buffer = await sharp(file.buffer).jpeg({ quality: imageCompressionPercent }).toBuffer();
|
|
await datasource.save(fileUpload.name, buffer);
|
|
logger.info(
|
|
`User ${user.username} (${user.id}) compressed image from ${file.buffer.length} -> ${buffer.length} bytes`
|
|
);
|
|
} else {
|
|
await datasource.save(fileUpload.name, file.buffer);
|
|
}
|
|
|
|
logger.info(`User ${user.username} (${user.id}) uploaded ${fileUpload.name} (${fileUpload.id})`);
|
|
let domain;
|
|
if (req.headers['override-domain']) {
|
|
domain = `${zconfig.core.return_https ? 'https' : 'http'}://${req.headers['override-domain']}`;
|
|
} else if (user.domains.length) {
|
|
domain = user.domains[Math.floor(Math.random() * user.domains.length)];
|
|
} else {
|
|
domain = `${zconfig.core.return_https ? 'https' : 'http'}://${req.headers.host}`;
|
|
}
|
|
|
|
const responseUrl = `${domain}${zconfig.uploader.route === '/' ? '/' : zconfig.uploader.route + '/'}${
|
|
invis ? invis.invis : encodeURI(fileUpload.name)
|
|
}`;
|
|
|
|
response.files.push(responseUrl);
|
|
|
|
if (zconfig.discord?.upload) {
|
|
await sendUpload(user, fileUpload, `${domain}/r/${invis ? invis.invis : fileUpload.name}`, responseUrl);
|
|
}
|
|
|
|
if (zconfig.exif.enabled && zconfig.exif.remove_gps && fileUpload.mimetype.startsWith('image/')) {
|
|
try {
|
|
await removeGPSData(fileUpload);
|
|
response.removed_gps = true;
|
|
} catch (e) {
|
|
logger.error(`Failed to remove GPS data from ${fileUpload.name} (${fileUpload.id}) - ${e.message}`);
|
|
|
|
response.removed_gps = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (user.administrator && zconfig.ratelimit.admin > 0) {
|
|
await prisma.user.update({
|
|
where: {
|
|
id: user.id,
|
|
},
|
|
data: {
|
|
ratelimit: new Date(Date.now() + zconfig.ratelimit.admin * 1000),
|
|
},
|
|
});
|
|
} else if (!user.administrator && zconfig.ratelimit.user > 0) {
|
|
if (user.administrator && zconfig.ratelimit.user > 0) {
|
|
await prisma.user.update({
|
|
where: {
|
|
id: user.id,
|
|
},
|
|
data: {
|
|
ratelimit: new Date(Date.now() + zconfig.ratelimit.user * 1000),
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
if (req.headers['no-json']) {
|
|
res.setHeader('Content-Type', 'text/plain');
|
|
return res.end(response.files.join(','));
|
|
}
|
|
|
|
return res.json(response);
|
|
}
|
|
|
|
export default withZipline(handler, {
|
|
methods: ['POST'],
|
|
});
|
|
|
|
export const config = {
|
|
api: {
|
|
bodyParser: false,
|
|
},
|
|
};
|