feat: barebones upload

This commit is contained in:
diced
2023-06-29 14:38:31 -07:00
parent e4761c3f12
commit 14da882840
28 changed files with 2365 additions and 179 deletions

1384
mimes.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -38,6 +38,7 @@
"dayjs": "^1.11.8",
"express": "^4.18.2",
"ms": "^2.1.3",
"multer": "^1.4.5-lts.1",
"next": "^13.4.7",
"react": "^18.2.0",
"react-dom": "^18.2.0",
@@ -49,6 +50,7 @@
"@remix-run/eslint-config": "^1.16.1",
"@types/bytes": "^3.1.1",
"@types/express": "^4.17.17",
"@types/multer": "^1.4.7",
"@types/node": "^20.3.1",
"@types/react": "^18.2.7",
"@types/react-dom": "^18.2.4",

View File

@@ -12,6 +12,19 @@ export const PROP_TO_ENV: Record<string, string> = {
'core.databaseUrl': 'CORE_DATABASE_URL',
'files.route': 'FILES_ROUTE',
'datasource.type': 'DATASOURCE_TYPE',
// only for errors, not used in readenv
'datasource.s3': 'DATASOURCE_S3_*',
'datasource.local': 'DATASOURCE_LOCAL_*',
'datasource.s3.accessKeyId': 'DATASOURCE_S3_ACCESS_KEY_ID',
'datasource.s3.secretAccessKey': 'DATASOURCE_S3_SECRET_ACCESS_KEY',
'datasource.s3.region': 'DATASOURCE_S3_REGION',
'datasource.s3.bucket': 'DATASOURCE_S3_BUCKET',
'datasource.local.directory': 'DATASOURCE_LOCAL_DIRECTORY',
};
export function readEnv() {
@@ -22,9 +35,18 @@ export function readEnv() {
env(PROP_TO_ENV['core.databaseUrl'], 'core.databaseUrl', 'string'),
env(PROP_TO_ENV['files.route'], 'files.route', 'string'),
env(PROP_TO_ENV['datasource.type'], 'datasource.type', 'string'),
env(PROP_TO_ENV['datasource.s3.accessKeyId'], 'datasource.s3.accessKeyId', 'string'),
env(PROP_TO_ENV['datasource.s3.secretAccessKey'], 'datasource.s3.secretAccessKey', 'string'),
env(PROP_TO_ENV['datasource.s3.region'], 'datasource.s3.region', 'string'),
env(PROP_TO_ENV['datasource.s3.bucket'], 'datasource.s3.bucket', 'string'),
env(PROP_TO_ENV['datasource.local.directory'], 'datasource.local.directory', 'string'),
];
const raw = {
const raw: any = {
core: {
port: undefined,
hostname: undefined,
@@ -34,6 +56,9 @@ export function readEnv() {
files: {
route: undefined,
},
datasource: {
type: undefined,
},
};
for (let i = 0; i !== envs.length; ++i) {
@@ -41,6 +66,21 @@ export function readEnv() {
const value = process.env[env.variable];
if (value === undefined) continue;
if (env.variable === 'DATASOURCE_TYPE') {
if (value === 's3') {
raw.datasource.s3 = {
accessKeyId: undefined,
secretAccessKey: undefined,
region: undefined,
bucket: undefined,
};
} else if (value === 'local') {
raw.datasource.local = {
directory: undefined,
};
}
}
const parsed = parse(value, env.type);
if (parsed === undefined) continue;

View File

@@ -1,6 +1,8 @@
import { ZodError, z } from 'zod';
import { PROP_TO_ENV, ParsedEnv } from './read';
import { log } from '../logger';
import { resolve } from 'path';
import bytes from 'bytes';
const schema = z.object({
core: z.object({
@@ -27,10 +29,54 @@ const schema = z.object({
}
}),
databaseUrl: z.string().url(),
returnHttpsUrls: z.boolean().default(false),
}),
files: z.object({
route: z.string().default('u'),
length: z.number().default(6),
defaultFormat: z.enum(['random', 'date', 'uuid', 'name', 'gfycat']).default('random'),
disabledExtensions: z.array(z.string()).default([]),
maxFileSize: z.number().default(bytes('100mb')),
defaultExpiration: z.number().nullish(),
assumeMimetypes: z.boolean().default(false),
defaultDateFormat: z.string().default('YYYY-MM-DD_HH:mm:ss')
}),
datasource: z
.object({
type: z.enum(['local', 's3']).default('local'),
s3: z
.object({
accessKeyId: z.string(),
secretAccessKey: z.string(),
region: z.string(),
bucket: z.string(),
})
.optional(),
local: z
.object({
directory: z.string().transform((s) => resolve(s)),
})
.optional(),
})
.superRefine((s, c) => {
if (s.type === 's3' && !s.s3) {
for (const key of ['accessKeyId', 'secretAccessKey', 'region', 'bucket']) {
c.addIssue({
code: z.ZodIssueCode.invalid_type,
expected: 'string',
received: 'unknown',
path: ['s3', key],
});
}
} else if (s.type === 'local' && !s.local) {
c.addIssue({
code: z.ZodIssueCode.invalid_type,
expected: 'string',
received: 'unknown',
path: ['local', 'directory'],
});
}
}),
});
export type Config = z.infer<typeof schema>;
@@ -46,6 +92,8 @@ export function validateEnv(env: ParsedEnv): Config {
process.exit(1);
}
logger.debug(`environment validated: ${JSON.stringify(validated)}`);
return validated;
} catch (e) {
if (e instanceof ZodError) {

View File

@@ -77,15 +77,21 @@ export function encryptToken(token: string, secret: string): string {
return `${date64}.${encrypted64}`;
}
export function decryptToken(encryptedToken: string, secret: string): [number, string] {
export function decryptToken(encryptedToken: string, secret: string): [number, string] | null {
const key = createKey(secret);
const [date64, encrypted64] = encryptedToken.split('.');
const date = parseInt(Buffer.from(date64, 'base64').toString('ascii'), 10);
if (!date64 || !encrypted64) return null;
const encrypted = Buffer.from(encrypted64, 'base64').toString('ascii');
try {
const date = parseInt(Buffer.from(date64, 'base64').toString('ascii'), 10);
return [date, decrypt(encrypted, key)];
const encrypted = Buffer.from(encrypted64, 'base64').toString('ascii');
return [date, decrypt(encrypted, key)];
} catch (e) {
return null;
}
}
export async function hashPassword(password: string) {

View File

@@ -0,0 +1,12 @@
import { Readable } from 'stream';
export abstract class Datasource {
public name: string | undefined;
public abstract get(file: string): null | Readable | Promise<Readable>;
public abstract put(file: string, data: Buffer): Promise<void>;
public abstract delete(file: string): Promise<void>;
public abstract size(file: string): Promise<number>;
public abstract totalSize(): Promise<number>;
public abstract clear(): Promise<void>;
}

View File

@@ -0,0 +1,55 @@
import { createReadStream, existsSync } from 'fs';
import { readdir, rm, stat, writeFile } from 'fs/promises';
import { join } from 'path';
import { Readable } from 'stream';
import { Datasource } from './Datasource';
export class LocalDatasource extends Datasource {
public name = 'local';
constructor(public dir: string) {
super();
}
public get(file: string): Readable | null {
const path = join(this.dir, file);
if (!existsSync(path)) return null;
const readStream = createReadStream(path);
return readStream;
}
public async put(file: string, data: Buffer): Promise<void> {
return writeFile(join(this.dir, file), data);
}
public async delete(file: string): Promise<void> {
const path = join(this.dir, file);
if (!existsSync(path)) return Promise.resolve();
return rm(path);
}
public async size(file: string): Promise<number> {
const path = join(this.dir, file);
if (!existsSync(path)) return 0;
const { size } = await stat(path);
return size;
}
public async totalSize(): Promise<number> {
const files = await readdir(this.dir);
const sizes = await Promise.all(files.map((file) => this.size(file)));
return sizes.reduce((a, b) => a + b, 0);
}
public async clear(): Promise<void> {
for (const file of await readdir(this.dir)) {
await rm(join(this.dir, file));
}
}
}

View File

@@ -0,0 +1,28 @@
import { config } from '../config';
import { log } from '../logger';
import { Datasource } from './Datasource';
import { LocalDatasource } from './Local';
let datasource: Datasource;
declare global {
var __datasource__: Datasource;
}
if (!global.__datasource__) {
const logger = log('datasource');
switch (config.datasource.type) {
case 'local':
global.__datasource__ = new LocalDatasource(config.datasource.local!.directory);
break;
case 's3':
default:
logger.error(`Datasource type ${config.datasource.type} is not supported`);
process.exit(1);
}
}
datasource = global.__datasource__;
export { datasource };

View File

@@ -0,0 +1,65 @@
import { Prisma } from '@prisma/client';
import { prisma } from '..';
export type File = {
createdAt: Date;
updatedAt: Date;
deletesAt: Date | null;
favorite: boolean;
id: string;
originalName: string;
name: string;
path: string;
size: number;
type: string;
views: number;
zeroWidthSpace: string | null;
password?: string | null;
};
export type FileSelectOptions = { password?: boolean };
export async function getFile(
where: Prisma.FileWhereInput | Prisma.FileWhereUniqueInput,
options?: FileSelectOptions
): Promise<File | null> {
return prisma.file.findFirst({
where,
select: {
createdAt: true,
updatedAt: true,
deletesAt: true,
favorite: true,
id: true,
originalName: true,
name: true,
path: true,
size: true,
type: true,
views: true,
zeroWidthSpace: true,
password: options?.password || false,
},
});
}
export async function createFile(data: Prisma.FileCreateInput, options?: FileSelectOptions): Promise<File> {
return prisma.file.create({
data,
select: {
createdAt: true,
updatedAt: true,
deletesAt: true,
favorite: true,
id: true,
originalName: true,
name: true,
path: true,
size: true,
type: true,
views: true,
zeroWidthSpace: true,
password: options?.password || false,
},
});
}

View File

@@ -9,11 +9,14 @@ export type User = {
administrator: boolean;
avatar?: string | null;
password?: string | null;
token?: string | null;
};
export type UserSelectOptions = { password?: boolean; avatar?: boolean; token?: boolean };
export async function getUser(
where: Prisma.UserWhereInput | Prisma.UserWhereUniqueInput,
options?: { password?: boolean; avatar?: boolean }
options?: UserSelectOptions
): Promise<User | null> {
return prisma.user.findFirst({
where,
@@ -25,21 +28,28 @@ export async function getUser(
updatedAt: true,
password: options?.password || false,
username: true,
token: options?.token || false,
},
});
}
export async function getUserTokenRaw(
where: Prisma.UserWhereInput | Prisma.UserWhereUniqueInput
): Promise<string | null> {
const user = await prisma.user.findFirst({
export async function updateUser(
where: Prisma.UserWhereUniqueInput,
data: Prisma.UserUpdateInput,
options?: UserSelectOptions
): Promise<User> {
return prisma.user.update({
where,
data,
select: {
token: true,
administrator: true,
avatar: options?.avatar || false,
id: true,
createdAt: true,
updatedAt: true,
password: options?.password || false,
username: true,
token: options?.token || false,
},
});
if (!user) return null;
return user.token;
}

View File

@@ -1,6 +1,11 @@
import { NextApiReq, NextApiRes } from '../response';
import { cors } from './cors';
import { functions } from './functions';
export function combine(middleware: Middleware[], handler: Handler) {
middleware.unshift(functions(), cors());
return middleware.reduceRight((handler, middleware) => {
return middleware(handler);
}, handler);

View File

@@ -0,0 +1,20 @@
import { NextApiReq, NextApiRes } from '../response';
import { Handler } from './combine';
import multer from 'multer';
const uploader = multer();
export function file() {
return (handler: Handler) => {
return async (req: NextApiReq, res: NextApiRes) => {
await new Promise((resolve, reject) => {
uploader.array('file')(req as never, res as never, (result: unknown) => {
if (result instanceof Error) reject(result);
resolve(result);
});
});
return handler(req, res);
};
};
}

View File

@@ -0,0 +1,72 @@
import { ErrorBody, NextApiReq, NextApiRes } from '../response';
import { Handler } from './combine';
export function functions() {
return (handler: Handler) => {
return async (req: NextApiReq, res: NextApiRes) => {
res.ok = (data?: any) => {
return res.status(200).json(data);
};
res.badRequest = (message: string = 'Bad Request', data: ErrorBody = {}) => {
return res.status(400).json({
code: 400,
message,
...data,
});
};
res.unauthorized = (message: string = 'Unauthorized', data: ErrorBody = {}) => {
return res.status(401).json({
code: 401,
message,
...data,
});
};
res.forbidden = (message: string = 'Forbidden', data: ErrorBody = {}) => {
return res.status(403).json({
code: 403,
message,
...data,
});
};
res.notFound = (message: string = 'Not Found', data: ErrorBody = {}) => {
return res.status(404).json({
code: 404,
message,
...data,
});
};
res.ratelimited = (retryAfter: number, message: string = 'Ratelimited', data: ErrorBody = {}) => {
res.setHeader('Retry-After', retryAfter);
return res.status(429).json({
code: 429,
message,
retryAfter,
...data,
});
};
res.serverError = (message: string = 'Internal Server Error', data: ErrorBody = {}) => {
return res.status(500).json({
code: 500,
message,
...data,
});
};
res.methodNotAllowed = () => {
return res.status(405).json({
code: 405,
message: 'Method Not Allowed',
method: req.method || 'unknown',
});
};
return handler(req, res);
};
};
}

View File

@@ -1,4 +1,4 @@
import { NextApiReq, NextApiRes, methodNotAllowed } from '../response';
import { NextApiReq, NextApiRes } from '../response';
import { Handler } from './combine';
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'OPTIONS';
@@ -10,7 +10,7 @@ export function method(allowedMethods: HttpMethod[] = []) {
if (!allowedMethods.includes(req.method as HttpMethod)) {
res.setHeader('Allow', allowedMethods.join(', '));
return methodNotAllowed(res);
return res.methodNotAllowed();
}
return handler(req, res);

View File

@@ -1,38 +1,42 @@
import { config } from '../config';
import { decryptToken } from '../crypto';
import { prisma } from '../db';
import { NextApiReq, NextApiRes, forbidden, unauthorized } from '../response';
import { User, UserSelectOptions, getUser } from '../db/queries/user';
import { NextApiReq, NextApiRes } from '../response';
import { Handler } from './combine';
export type ZiplineAuthOptions = {
administratorOnly?: boolean;
getOptions?: UserSelectOptions;
};
export function ziplineAuth(options: ZiplineAuthOptions) {
declare module 'next' {
export interface NextApiRequest {
user: User;
}
}
export function ziplineAuth(options?: ZiplineAuthOptions) {
return (handler: Handler) => {
return async (req: NextApiReq, res: NextApiRes) => {
let rawToken: string | undefined;
if (req.cookies.zipline_auth) rawToken = req.cookies.zipline_auth;
if (req.cookies.zipline_token) rawToken = req.cookies.zipline_token;
else if (req.headers.authorization) rawToken = req.headers.authorization;
if (!rawToken) return unauthorized(res);
if (!rawToken) return res.unauthorized();
const [date, token] = decryptToken(rawToken, config.core.secret);
const decryptedToken = decryptToken(rawToken, config.core.secret);
if (!decryptedToken) return res.unauthorized('could not decrypt token');
if (isNaN(new Date(date).getTime())) return unauthorized(res);
const [date, token] = decryptedToken;
if (isNaN(new Date(date).getTime())) return res.unauthorized('could not decrypt token date');
const user = await prisma.user.findUnique({
where: {
token,
},
});
if (!user) return unauthorized(res);
const user = await getUser({ token }, options?.getOptions || {});
if (!user) return res.unauthorized();
req.user = user;
if (options.administratorOnly && !user.administrator) return forbidden(res);
if (options?.administratorOnly && !user.administrator) return res.forbidden();
return handler(req, res);
};

14
src/lib/mimes.ts Normal file
View File

@@ -0,0 +1,14 @@
import { readFile } from 'fs/promises';
export type Mimes = [string, string[]][];
export async function guess(extension: string | null): Promise<string> {
if (!extension) return 'application/octet-stream';
const mimes: Mimes = JSON.parse(await readFile('./mimes.json', 'utf8'));
const mime = mimes.find((x) => x[0] === extension);
if (!mime) return 'application/octet-stream';
return mime[1][0];
}

View File

@@ -1,96 +1,43 @@
import { NextApiRequest, NextApiResponse } from 'next';
import { User } from './db/queries/user';
import { Readable } from 'stream';
export interface File {
fieldname: string;
originalname: string;
mimetype: string;
size: number;
buffer: Buffer;
}
export type Cookies = {
zipline_token?: string;
};
export interface NextApiReq<Body = any, Query = any, Headers = any> extends NextApiRequest {
query: Query & { [k: string]: string | string[] };
body: Body;
headers: Headers & { [k: string]: string };
cookies: Cookies & { [k: string]: string };
user?: User;
files?: File[];
}
export type NextApiRes<Data = any> = NextApiResponse<Data>;
export type ErrorBody = {
message?: string;
data?: any;
code?: any;
export function ok(res: NextApiRes, data: Record<string, unknown> = {}) {
return res.status(200).json(data);
}
[key: string]: any;
};
// Client wrong data, etc
export function badRequest(
res: NextApiRes,
message: string = 'Bad Request',
data: Record<string, unknown> = {}
) {
return res.status(400).json({
error: message,
...data,
});
}
// No authorization
export function unauthorized(
res: NextApiRes,
message: string = 'Unauthorized',
data: Record<string, unknown> = {}
) {
return res.status(401).json({
error: message,
...data,
});
}
// User's permission level does not meet requirements for this resource
export function forbidden(
res: NextApiRes,
message: string = 'Forbidden',
data: Record<string, unknown> = {}
) {
return res.status(403).json({
error: message,
...data,
});
}
export function notFound(res: NextApiRes, message: string = 'Not Found', data: Record<string, unknown> = {}) {
return res.status(404).json({
error: message,
...data,
});
}
export function ratelimited(
res: NextApiRes,
retryAfter: number,
message: string = 'Ratelimited',
data: Record<string, unknown> = {}
) {
res.setHeader('Retry-After', retryAfter);
return res.status(429).json({
error: message,
retryAfter,
...data,
});
}
export function serverError(
res: NextApiRes,
message: string = 'Internal Server Error',
data: Record<string, unknown> = {}
) {
return res.status(500).json({
error: message,
...data,
});
}
export function methodNotAllowed(
res: NextApiRes,
message: string = 'Method Not Allowed',
data: Record<string, unknown> = {}
) {
return res.status(405).json({
error: message,
method: res.req?.method || 'unknown',
...data,
});
export interface NextApiRes<Data = any> extends NextApiResponse {
ok: (data?: Data) => void;
badRequest: (message?: string, data?: ErrorBody) => void;
unauthorized: (message?: string, data?: ErrorBody) => void;
forbidden: (message?: string, data?: ErrorBody) => void;
notFound: (message?: string, data?: ErrorBody) => void;
ratelimited: (retryAfter: number, message?: string, data?: ErrorBody) => void;
serverError: (message?: string, data?: ErrorBody) => void;
methodNotAllowed: (message?: string, data?: ErrorBody) => void;
}

View File

@@ -0,0 +1,24 @@
import dayjs from 'dayjs';
import { config } from '../config';
import { Config } from '../config/validate';
import { randomCharacters } from '../crypto';
import { randomUUID } from 'crypto';
import { parse } from 'path';
export function formatFileName(nameFormat: Config['files']['defaultFormat'], originalName?: string) {
switch (nameFormat) {
case 'random':
return randomCharacters(config.files.length);
case 'date':
return dayjs().format(config.files.defaultDateFormat);
case 'uuid':
return randomUUID({ disableEntropyCache: true });
case 'name':
const { name } = parse(originalName!);
return name;
case 'gfycat':
default:
return randomCharacters(config.files.length);
}
}

View File

@@ -0,0 +1,190 @@
import ms from 'ms';
import { Config } from '../config/validate';
// from ms@3.0.0-canary.1
type Unit =
| 'Years'
| 'Year'
| 'Yrs'
| 'Yr'
| 'Y'
| 'Weeks'
| 'Week'
| 'W'
| 'Days'
| 'Day'
| 'D'
| 'Hours'
| 'Hour'
| 'Hrs'
| 'Hr'
| 'H'
| 'Minutes'
| 'Minute'
| 'Mins'
| 'Min'
| 'M'
| 'Seconds'
| 'Second'
| 'Secs'
| 'Sec'
| 's'
| 'Milliseconds'
| 'Millisecond'
| 'Msecs'
| 'Msec'
| 'Ms';
type UnitAnyCase = Unit | Uppercase<Unit> | Lowercase<Unit>;
type StringValue = `${number}` | `${number}${UnitAnyCase}` | `${number} ${UnitAnyCase}`;
export type UploadHeaders = {
'x-zipline-deletes-at': string;
'x-zipline-format': Config['files']['defaultFormat'];
'x-zipline-image-compression-percent': string;
'x-zipline-password': string;
'x-zipline-max-views': string;
'x-zipline-no-json': 'true' | 'false';
'x-zipline-original-name': string;
'x-zipline-folder': string;
'x-zipline-filename': string;
'x-zipline-domain': string;
};
export type UploadOptions = {
deletesAt?: Date;
format?: Config['files']['defaultFormat'];
imageCompressionPercent?: number;
password?: string;
maxViews?: number;
noJson?: boolean;
addOriginalName?: boolean;
overrides?: {
filename?: string;
returnDomain?: string;
};
folder?: string;
// error
header?: string;
message?: string;
};
export function humanTime(string: StringValue | string): Date | null {
try {
const mil = ms(string as StringValue);
if (typeof mil !== 'number') return null;
if (isNaN(mil)) return null;
if (!mil) return null;
return new Date(Date.now() + mil);
} catch (_) {
return null;
}
}
export function parseExpiry(header: string): Date | null {
if (!header) return null;
header = header.toLowerCase();
if (header.startsWith('date=')) {
const date = new Date(header.substring(5));
if (!date.getTime()) return null;
if (date.getTime() < Date.now()) return null;
return date;
}
const human = humanTime(header);
if (!human) return null;
if (human.getTime() < Date.now()) return null;
return human;
}
function headerError(header: keyof UploadHeaders, message: string) {
return {
header,
message,
};
}
const FORMATS = ['random', 'uuid', 'date', 'name'];
export function parseHeaders(headers: UploadHeaders, fileConfig: Config['files']): UploadOptions {
const response: UploadOptions = {};
if (headers['x-zipline-deletes-at']) {
const expiresAt = parseExpiry(headers['x-zipline-deletes-at']);
if (!expiresAt) return headerError('x-zipline-deletes-at', 'Invalid expiry date');
response.deletesAt = expiresAt;
} else {
if (fileConfig.defaultExpiration) {
const expiresAt = new Date(Date.now() + fileConfig.defaultExpiration);
response.deletesAt = expiresAt;
}
}
const format = headers['x-zipline-format'];
if (format) {
if (!FORMATS.includes(format)) return headerError('x-zipline-format', 'Invalid format');
response.format = format;
} else {
response.format = fileConfig.defaultFormat;
}
const imageCompressionPercent = headers['x-zipline-image-compression-percent'];
if (imageCompressionPercent) {
const num = Number(imageCompressionPercent);
if (isNaN(num))
return headerError('x-zipline-image-compression-percent', 'Invalid compression percent (NaN)');
// check bounds, 0-100
if (num < 0 || num > 100)
return headerError(
'x-zipline-image-compression-percent',
'Invalid compression percent (must be between 0 and 100)'
);
response.imageCompressionPercent = num;
}
const password = headers['x-zipline-password'];
if (password) response.password = password;
const maxViews = headers['x-zipline-max-views'];
if (maxViews) {
const num = Number(maxViews);
if (isNaN(num)) return headerError('x-zipline-max-views', 'Invalid max views (NaN)');
response.maxViews = num;
}
const noJson = headers['x-zipline-no-json'];
if (noJson) response.noJson = noJson === 'true';
const addOriginalName = headers['x-zipline-original-name'];
if (addOriginalName) response.addOriginalName = addOriginalName === 'true';
const folder = headers['x-zipline-folder'];
if (folder) {
// TODO: add check for folder
response.folder = folder;
}
response.overrides = {};
const filename = headers['x-zipline-filename'];
if (filename) response.overrides.filename = filename;
const returnDomain = headers['x-zipline-domain'];
if (returnDomain) response.overrides.returnDomain = returnDomain;
return response;
}

View File

@@ -1,11 +1,11 @@
import { config } from '@/lib/config';
import { serializeCookie } from '@/lib/cookie';
import { encryptToken, verifyPassword } from '@/lib/crypto';
import { User, getUser, getUserTokenRaw } from '@/lib/db/queries/user';
import { User, getUser } from '@/lib/db/queries/user';
import { combine } from '@/lib/middleware/combine';
import { cors } from '@/lib/middleware/cors';
import { method } from '@/lib/middleware/method';
import { NextApiReq, NextApiRes, badRequest, ok } from '@/lib/response';
import { NextApiReq, NextApiRes } from '@/lib/response';
type Data = {
user: User;
@@ -15,25 +15,22 @@ type Data = {
type Body = {
username: string;
password: string;
}
};
async function handler(req: NextApiReq<Body>, res: NextApiRes<Data>) {
const { username, password } = req.body;
if (!username) return badRequest(res, 'Username is required');
if (!password) return badRequest(res, 'Password is required');
if (!username) return res.badRequest('Username is required');
if (!password) return res.badRequest('Password is required');
const user = await getUser({ username }, { password: true });
if (!user) return badRequest(res, 'Invalid username');
const user = await getUser({ username }, { password: true, token: true });
if (!user) return res.badRequest('Invalid username');
if (!user.password) return badRequest(res, 'User does not have a password, login through a provider');
if (!user.password) return res.badRequest('User does not have a password, login through a provider');
const valid = await verifyPassword(password, user.password);
if (!valid) return badRequest(res, 'Invalid password');
if (!valid) return res.badRequest('Invalid password');
const rawToken = await getUserTokenRaw({ id: user.id });
if (!rawToken) return badRequest(res, 'User does not have a token');
const token = encryptToken(rawToken, config.core.secret);
const token = encryptToken(user.token!, config.core.secret);
const cookie = serializeCookie('zipline_token', token, {
// week
@@ -45,11 +42,12 @@ async function handler(req: NextApiReq<Body>, res: NextApiRes<Data>) {
res.setHeader('Set-Cookie', cookie);
delete user.token;
delete user.password;
return ok(res, {
user,
return res.ok({
token,
user,
});
}

View File

@@ -0,0 +1,17 @@
import { combine } from '@/lib/middleware/combine';
import { cors } from '@/lib/middleware/cors';
import { method } from '@/lib/middleware/method';
import { ziplineAuth } from '@/lib/middleware/ziplineAuth';
import { NextApiReq, NextApiRes } from '@/lib/response';
type Data = {
loggedOut?: boolean;
};
async function handler(req: NextApiReq, res: NextApiRes<Data>) {
res.setHeader('Set-Cookie', `zipline_token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT`);
return res.ok({ loggedOut: true });
}
export default combine([cors(), method(['POST']), ziplineAuth()], handler);

View File

@@ -1,48 +1,25 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { prisma } from '@/lib/db';
import { log } from '@/lib/logger';
import { badRequest, ok, ratelimited, serverError } from '@/lib/response';
import { combine } from '@/lib/middleware/combine';
import { cors } from '@/lib/middleware/cors';
import { method } from '@/lib/middleware/method';
import { NextApiReq, NextApiRes } from '@/lib/response';
type Data = {
pass: boolean;
};
const ratelimit: Map<string, number> = new Map();
export async function handler(req: NextApiRequest, res: NextApiResponse<Data>) {
export async function handler(req: NextApiReq, res: NextApiRes<Data>) {
const logger = log('api').c('healthcheck');
const ip = (req.headers['x-forwarded-for'] as string) || req.socket.remoteAddress;
if (!ip) {
logger.debug(`request without an ip address blocked`);
return badRequest(res, 'no ip address found');
}
const last = ratelimit.get(ip);
if (last) {
if (last && Date.now() - last < 10000) {
logger.debug(`request from ${ip} blocked due to ratelimit`);
return ratelimited(res, Math.ceil((last + 10000 - Date.now()) / 1000));
} else {
ratelimit.delete(ip);
}
}
try {
await prisma.$queryRaw`SELECT 1;`;
ratelimit.set(ip, Date.now());
return ok(res, { pass: true });
return res.ok({ pass: true });
} catch (e) {
logger.error('there was an error during a healthcheck').error(e);
ratelimit.set(ip, Date.now());
return serverError(res, 'there was an error during a healthcheck', {
return res.serverError('there was an error during a healthcheck', {
pass: false,
});
}

View File

@@ -1,18 +1,16 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { createToken, hashPassword } from '@/lib/crypto';
import { prisma } from '@/lib/db';
import { log } from '@/lib/logger';
import { User } from '@/lib/db/queries/user';
import { getZipline } from '@/lib/db/queries/zipline';
import { NextApiReq, NextApiRes, badRequest, forbidden, methodNotAllowed, ok } from '@/lib/response';
import { log } from '@/lib/logger';
import { combine } from '@/lib/middleware/combine';
import { cors } from '@/lib/middleware/cors';
import { method } from '@/lib/middleware/method';
import { createToken, hashPassword } from '@/lib/crypto';
import { User } from '@/lib/db/queries/user';
import { NextApiReq, NextApiRes } from '@/lib/response';
type Response = {
firstSetup: boolean;
user: User;
firstSetup?: boolean;
user?: User;
};
type Body = {
@@ -24,19 +22,19 @@ export async function handler(req: NextApiReq<Body>, res: NextApiRes<Response>)
const logger = log('api').c('setup');
const { firstSetup, id } = await getZipline();
if (!firstSetup) return forbidden(res);
if (!firstSetup) return res.forbidden();
logger.info('first setup running');
if (req.method === 'GET') {
return ok(res, { firstSetup });
return res.ok({ firstSetup });
}
const { username, password } = req.body;
if (!username) return badRequest(res, 'Username is required');
if (!password) return badRequest(res, 'Password is required');
if (!username) return res.badRequest('Username is required');
if (!password) return res.badRequest('Password is required');
if (password.length < 8) return badRequest(res, 'Password must be at least 8 characters long');
if (password.length < 8) return res.badRequest('Password must be at least 8 characters long');
const user = await prisma.user.create({
data: {
@@ -65,10 +63,10 @@ export async function handler(req: NextApiReq<Body>, res: NextApiRes<Response>)
},
});
return ok(res, {
return res.ok({
firstSetup,
user,
});
}
export default combine([cors(), method(['GET', 'POST'])], handler);
export default combine([method(['GET', 'POST'])], handler);

129
src/pages/api/upload.ts Normal file
View File

@@ -0,0 +1,129 @@
import { config as zconfig } from '@/lib/config';
import { hashPassword } from '@/lib/crypto';
import { datasource } from '@/lib/datasource';
import { createFile, getFile } from '@/lib/db/queries/file';
import { log } from '@/lib/logger';
import { combine } from '@/lib/middleware/combine';
import { cors } from '@/lib/middleware/cors';
import { file } from '@/lib/middleware/file';
import { method } from '@/lib/middleware/method';
import { ziplineAuth } from '@/lib/middleware/ziplineAuth';
import { guess } from '@/lib/mimes';
import { NextApiReq, NextApiRes } from '@/lib/response';
import { formatFileName } from '@/lib/uploader/formatFileName';
import { UploadHeaders, parseHeaders } from '@/lib/uploader/parseHeaders';
import bytes from 'bytes';
import { extname, parse } from 'path';
type Data = {
files: {
id: string;
type: string;
url: string;
}[];
deletesAt?: string;
assumedMimetypes?: boolean[];
};
const logger = log('api').c('upload');
export async function handler(req: NextApiReq<any, any, UploadHeaders>, res: NextApiRes<Data>) {
if (!req.files || !req.files.length) return res.badRequest('No files received');
const options = parseHeaders(req.headers, zconfig.files);
if (options.header) return res.badRequest('', options);
const response: Data = {
files: [],
...(options.deletesAt && { deletesAt: options.deletesAt.toISOString() }),
...(zconfig.files.assumeMimetypes && { assumedMimetypes: Array(req.files.length) }),
};
let domain;
if (options.overrides?.returnDomain) {
domain = `${zconfig.core.returnHttpsUrls ? 'https' : 'http'}://${options.overrides.returnDomain}`;
} else {
domain = `${zconfig.core.returnHttpsUrls ? 'https' : 'http'}://${req.headers.host}`;
}
for (let i = 0; i !== req.files.length; ++i) {
const file = req.files[i];
const extension = extname(file.originalname);
if (zconfig.files.disabledExtensions.includes(extension))
return res.badRequest(`File extension ${extension} is not allowed`);
if (file.size > zconfig.files.maxFileSize)
return res.badRequest(
`File size is too large. Maximum file size is ${zconfig.files.maxFileSize} bytes`
);
let fileName = formatFileName(options.format || zconfig.files.defaultFormat, file.originalname);
if (options.overrides?.filename) {
fileName = options.overrides!.filename!;
const existing = await getFile({
name: {
startsWith: fileName,
},
});
if (existing) return res.badRequest(`A file with the name "${fileName}*" already exists`);
}
let mimetype = file.mimetype;
if (mimetype === 'application/octet-stream' && zconfig.files.assumeMimetypes) {
const ext = parse(file.originalname).ext.replace('.', '');
const mime = await guess(ext);
if (!mime) response.assumedMimetypes![i] = false;
else {
response.assumedMimetypes![i] = true;
mimetype = mime;
}
}
const fileUpload = await createFile({
name: `${fileName}${extension}`,
path: `${fileName}${extension}`,
originalName: file.originalname,
size: file.size,
type: mimetype,
...(options.maxViews && { maxViews: options.maxViews }),
...(options.password && { password: await hashPassword(options.password) }),
...(options.deletesAt && { deletesAt: options.deletesAt }),
...(options.folder && { Folder: { connect: { id: options.folder } } }),
});
// TODO: remove gps
// TODO: zws
// TODO: image compression
await datasource.put(fileUpload.name, file.buffer);
logger.info(`${req.user.username} uploaded ${fileUpload.name} (size=${bytes(fileUpload.size)})`);
const responseUrl = `${domain}${
zconfig.files.route === '/' || zconfig.files.route === '' ? '' : `/${zconfig.files.route}`
}/${fileUpload.name}`;
response.files.push({
id: fileUpload.id,
type: fileUpload.type,
url: responseUrl,
});
}
if (options.noJson) return res.status(200).end(response.files.map((x) => x.url).join(','));
return res.ok(response);
}
export default combine([cors(), method(['POST']), ziplineAuth(), file()], handler);
export const config = {
api: {
bodyParser: false,
},
};

View File

@@ -1,13 +1,38 @@
import { hashPassword } from '@/lib/crypto';
import { prisma } from '@/lib/db';
import { User } from '@/lib/db/queries/user';
import { combine } from '@/lib/middleware/combine';
import { cors } from '@/lib/middleware/cors';
import { method } from '@/lib/middleware/method';
import { ziplineAuth } from '@/lib/middleware/ziplineAuth';
import { NextApiReq, NextApiRes } from '@/lib/response';
type Response = {
user: User;
user?: User;
token?: string;
};
export async function handler(req: NextApiReq, res: NextApiRes<Response>) {}
type EditBody = {
username?: string;
password?: string;
avatar?: string;
};
export default combine([cors(), method(['GET', 'POST', 'PATCH'])], handler);
export async function handler(req: NextApiReq<EditBody>, res: NextApiRes<Response>) {
if (req.method === 'GET') {
return res.ok({ user: req.user, token: req.cookies.zipline_token });
} else if (req.method === 'PATCH') {
await prisma.user.update({
where: {
id: req.user.id,
},
data: {
...(req.body.username && { username: req.body.username }),
...(req.body.password && { password: await hashPassword(req.body.password) }),
...(req.body.avatar && { avatar: req.body.avatar }),
},
});
}
}
export default combine([cors(), method(['GET', 'PATCH']), ziplineAuth()], handler);

View File

@@ -0,0 +1,48 @@
import { config } from '@/lib/config';
import { serializeCookie } from '@/lib/cookie';
import { createToken, encryptToken } from '@/lib/crypto';
import { User, updateUser } from '@/lib/db/queries/user';
import { combine } from '@/lib/middleware/combine';
import { cors } from '@/lib/middleware/cors';
import { method } from '@/lib/middleware/method';
import { ziplineAuth } from '@/lib/middleware/ziplineAuth';
import { NextApiReq, NextApiRes } from '@/lib/response';
type Response = {
user?: User;
token?: string;
};
export async function handler(req: NextApiReq, res: NextApiRes<Response>) {
const user = await updateUser(
{
id: req.user.id,
},
{
token: createToken(),
},
{
token: true,
}
);
const token = encryptToken(user.token!, config.core.secret);
const cookie = serializeCookie('zipline_token', token, {
// week
maxAge: 60 * 60 * 24 * 7,
expires: new Date(Date.now() + 60 * 60 * 24 * 7 * 1000),
path: '/',
sameSite: 'lax',
});
res.setHeader('Set-Cookie', cookie);
delete user.token;
return res.ok({
user,
token,
});
}
export default combine([cors(), method(['PATCH']), ziplineAuth()], handler);

View File

@@ -6,6 +6,7 @@ import { log } from '@/lib/logger';
import express from 'express';
import next from 'next';
import { parse } from 'url';
import { mkdir } from 'fs/promises';
const MODE = process.env.NODE_ENV || 'production';
@@ -19,6 +20,10 @@ async function main() {
logger.info('reading environment for configuration');
const config = validateEnv(readEnv());
if (config.datasource.type === 'local') {
await mkdir(config.datasource.local!.directory, { recursive: true });
}
process.env.DATABASE_URL = config.core.databaseUrl;
await runMigrations();

View File

@@ -4417,7 +4417,7 @@ __metadata:
languageName: node
linkType: hard
"@types/express@npm:^4.17.17":
"@types/express@npm:*, @types/express@npm:^4.17.17":
version: 4.17.17
resolution: "@types/express@npm:4.17.17"
dependencies:
@@ -4536,6 +4536,15 @@ __metadata:
languageName: node
linkType: hard
"@types/multer@npm:^1.4.7":
version: 1.4.7
resolution: "@types/multer@npm:1.4.7"
dependencies:
"@types/express": "*"
checksum: 680cb0710aa25264d20cdcdaf34c212b636b55ea141310f06c25354ab1401193c7aa6839f9d22abf64a223fab1f2b8287f2512b0bef7e1628c4e9ffe54b4aeb2
languageName: node
linkType: hard
"@types/node@npm:*, @types/node@npm:^20.3.1":
version: 20.3.1
resolution: "@types/node@npm:20.3.1"
@@ -5045,6 +5054,13 @@ __metadata:
languageName: node
linkType: hard
"append-field@npm:^1.0.0":
version: 1.0.0
resolution: "append-field@npm:1.0.0"
checksum: 482ba08acc0ecef00fe7da6bf2f8e48359a9905ee1af525f3120c9260c02e91eedf0579b59d898e8d8455b6c199e340bc0a2fd4b9e02adaa29a8a86c722b37f9
languageName: node
linkType: hard
"aproba@npm:^1.0.3 || ^2.0.0":
version: 2.0.0
resolution: "aproba@npm:2.0.0"
@@ -5607,7 +5623,7 @@ __metadata:
languageName: node
linkType: hard
"busboy@npm:1.6.0":
"busboy@npm:1.6.0, busboy@npm:^1.0.0":
version: 1.6.0
resolution: "busboy@npm:1.6.0"
dependencies:
@@ -6014,6 +6030,18 @@ __metadata:
languageName: node
linkType: hard
"concat-stream@npm:^1.5.2":
version: 1.6.2
resolution: "concat-stream@npm:1.6.2"
dependencies:
buffer-from: ^1.0.0
inherits: ^2.0.3
readable-stream: ^2.2.2
typedarray: ^0.0.6
checksum: 1ef77032cb4459dcd5187bd710d6fc962b067b64ec6a505810de3d2b8cc0605638551b42f8ec91edf6fcd26141b32ef19ad749239b58fae3aba99187adc32285
languageName: node
linkType: hard
"console-control-strings@npm:^1.0.0, console-control-strings@npm:^1.1.0":
version: 1.1.0
resolution: "console-control-strings@npm:1.1.0"
@@ -10702,6 +10730,17 @@ __metadata:
languageName: node
linkType: hard
"mkdirp@npm:^0.5.4":
version: 0.5.6
resolution: "mkdirp@npm:0.5.6"
dependencies:
minimist: ^1.2.6
bin:
mkdirp: bin/cmd.js
checksum: 0c91b721bb12c3f9af4b77ebf73604baf350e64d80df91754dc509491ae93bf238581e59c7188360cec7cb62fc4100959245a42cfe01834efedc5e9d068376c2
languageName: node
linkType: hard
"mkdirp@npm:^1.0.3, mkdirp@npm:^1.0.4":
version: 1.0.4
resolution: "mkdirp@npm:1.0.4"
@@ -10839,6 +10878,21 @@ __metadata:
languageName: node
linkType: hard
"multer@npm:^1.4.5-lts.1":
version: 1.4.5-lts.1
resolution: "multer@npm:1.4.5-lts.1"
dependencies:
append-field: ^1.0.0
busboy: ^1.0.0
concat-stream: ^1.5.2
mkdirp: ^0.5.4
object-assign: ^4.1.1
type-is: ^1.6.4
xtend: ^4.0.0
checksum: d6dfa78a6ec592b74890412f8962da8a87a3dcfe20f612e039b735b8e0faa72c735516c447f7de694ee0d981eb0a1b892fb9e2402a0348dc6091d18c38d89ecc
languageName: node
linkType: hard
"multipipe@npm:^1.0.2":
version: 1.0.2
resolution: "multipipe@npm:1.0.2"
@@ -12444,7 +12498,7 @@ __metadata:
languageName: node
linkType: hard
"readable-stream@npm:^2.0.0, readable-stream@npm:^2.0.2, readable-stream@npm:^2.0.5, readable-stream@npm:~2.3.6":
"readable-stream@npm:^2.0.0, readable-stream@npm:^2.0.2, readable-stream@npm:^2.0.5, readable-stream@npm:^2.2.2, readable-stream@npm:~2.3.6":
version: 2.3.8
resolution: "readable-stream@npm:2.3.8"
dependencies:
@@ -14079,7 +14133,7 @@ __metadata:
languageName: node
linkType: hard
"type-is@npm:~1.6.18":
"type-is@npm:^1.6.4, type-is@npm:~1.6.18":
version: 1.6.18
resolution: "type-is@npm:1.6.18"
dependencies:
@@ -14100,6 +14154,13 @@ __metadata:
languageName: node
linkType: hard
"typedarray@npm:^0.0.6":
version: 0.0.6
resolution: "typedarray@npm:0.0.6"
checksum: 33b39f3d0e8463985eeaeeacc3cb2e28bc3dfaf2a5ed219628c0b629d5d7b810b0eb2165f9f607c34871d5daa92ba1dc69f49051cf7d578b4cbd26c340b9d1b1
languageName: node
linkType: hard
"typescript@npm:^5.1.3":
version: 5.1.3
resolution: "typescript@npm:5.1.3"
@@ -14919,6 +14980,7 @@ __metadata:
"@tabler/icons-react": ^2.22.0
"@types/bytes": ^3.1.1
"@types/express": ^4.17.17
"@types/multer": ^1.4.7
"@types/node": ^20.3.1
"@types/react": ^18.2.7
"@types/react-dom": ^18.2.4
@@ -14932,6 +14994,7 @@ __metadata:
eslint: ^8.41.0
express: ^4.18.2
ms: ^2.1.3
multer: ^1.4.5-lts.1
next: ^13.4.7
npm-run-all: ^4.1.5
prisma: ^4.16.1