mirror of
https://github.com/diced/zipline.git
synced 2025-12-05 20:40:12 -08:00
feat: barebones upload
This commit is contained in:
1384
mimes.json
Normal file
1384
mimes.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
12
src/lib/datasource/Datasource.ts
Normal file
12
src/lib/datasource/Datasource.ts
Normal 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>;
|
||||
}
|
||||
55
src/lib/datasource/Local.ts
Normal file
55
src/lib/datasource/Local.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
28
src/lib/datasource/index.ts
Normal file
28
src/lib/datasource/index.ts
Normal 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 };
|
||||
65
src/lib/db/queries/file.ts
Normal file
65
src/lib/db/queries/file.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
20
src/lib/middleware/file.ts
Normal file
20
src/lib/middleware/file.ts
Normal 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);
|
||||
};
|
||||
};
|
||||
}
|
||||
72
src/lib/middleware/functions.ts
Normal file
72
src/lib/middleware/functions.ts
Normal 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);
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
14
src/lib/mimes.ts
Normal 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];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
24
src/lib/uploader/formatFileName.ts
Normal file
24
src/lib/uploader/formatFileName.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
190
src/lib/uploader/parseHeaders.ts
Normal file
190
src/lib/uploader/parseHeaders.ts
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
17
src/pages/api/auth/logout.ts
Normal file
17
src/pages/api/auth/logout.ts
Normal 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);
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
129
src/pages/api/upload.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
48
src/pages/api/user/token.ts
Normal file
48
src/pages/api/user/token.ts
Normal 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);
|
||||
@@ -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();
|
||||
|
||||
71
yarn.lock
71
yarn.lock
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user