From fba42d96c0bb8549279253cfeb3972f08a4bb8db Mon Sep 17 00:00:00 2001 From: topchetoeu <36534413+TopchetoEU@users.noreply.github.com> Date: Wed, 28 Jun 2023 15:46:57 +0300 Subject: [PATCH] feat: add image uploading --- .gitignore | 1 + package.json | 5 + src/models/Image.ts | 5 +- src/routers/ImageRouter.ts | 64 ++++++++- src/server/decorators.ts | 3 +- src/server/decorators/headers.ts | 6 + src/server/decorators/jwt.ts | 4 +- src/server/decorators/schema.ts | 214 +++++++++++++++++-------------- tsconfig.json | 3 +- 9 files changed, 202 insertions(+), 103 deletions(-) create mode 100644 package.json create mode 100644 src/server/decorators/headers.ts diff --git a/.gitignore b/.gitignore index 618dd84..3d1de9d 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ node_modules/ keys/ yarn.lock +pacakage.json package.lock.json \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..c653748 --- /dev/null +++ b/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "typescript": "^5.1.5" + } +} diff --git a/src/models/Image.ts b/src/models/Image.ts index 262b429..ff12467 100644 --- a/src/models/Image.ts +++ b/src/models/Image.ts @@ -8,9 +8,8 @@ export enum Visibility { export default interface Image { _id: UUID; - backingURL: string; + author: string; name: string; visibility: Visibility; - created: Date; - author: UUID; + created: number; } \ No newline at end of file diff --git a/src/routers/ImageRouter.ts b/src/routers/ImageRouter.ts index 5d76747..3a132bf 100644 --- a/src/routers/ImageRouter.ts +++ b/src/routers/ImageRouter.ts @@ -1,19 +1,22 @@ import { Collection } from "https://deno.land/x/mongo@v0.31.2/mod.ts"; import { UUID } from "https://deno.land/x/web_bson@v0.3.0/mod.js"; -import { auth, body, jwt, page, rest, schema } from "../server/decorators.ts"; +import { auth, body, headers, jwt, page, rest, schema } from "../server/decorators.ts"; import { JWTPayload } from "../utils/JWT.ts"; import AppRouter from "./AppRouter.ts"; import HttpError from "../server/HttpError.ts"; import User from "../models/User.ts"; import Image, { Visibility } from "../models/Image.ts"; import { Page } from "../server/decorators/page.ts"; +import { Headers } from "../server/RestRequest.ts"; +import { convert } from "../server/decorators/schema.ts"; +import { now } from "../utils/utils.ts"; export default class ImageRouter extends AppRouter { public static serialize(image: Image) { return { author: image.author, - created: image.created.getTime() / 1000, + created: image.created, name: image.name, visibility: image.visibility, id: image._id, @@ -40,6 +43,63 @@ export default class ImageRouter extends AppRouter { return res.map(v => ImageRouter.serialize(v)); } + @rest('POST', '/upload') + async upload(@body() body: Blob, @headers() headers: Headers, @jwt(v => v.salt, true) @auth() jwt: JWTPayload) { + const user = await this.users.findOne({ username: jwt.name }); + if (!user) throw new HttpError("You don't exist."); + + // Parse body + const contentType = headers['content-type'] + ""; + if (!contentType.startsWith('multipart/form-data')) { + throw new HttpError("Expected a 'Content-type: multipart/form-data; ...' header."); + } + const data = await new Request('http://127.0.0.1', { + body, headers: [ ['content-type', contentType ] ], + method: 'post' + }).formData(); + + // Clean up data + if (!data.has('file')) throw new HttpError("Expected a 'file' entry in form data."); + if (!data.has('body')) throw new HttpError("Expected a 'body' entry in form data."); + + let rawFile: File = data.get('file') as File; + let rawReq: string = data.get('body') as string; + + if (typeof rawFile === 'string') rawFile = new File([rawFile], 'unknown'); + if (typeof rawReq !== 'string') rawReq = await (rawReq as Blob).text(); + if (rawFile.size > (1 << 20) * 2) throw new HttpError("File too large (max 2MB)."); + + // Extract (and check) extension + const pointI = rawFile.name.lastIndexOf("."); + if (pointI < 0) throw new HttpError("Given file has no extension."); + const ext = rawFile.name.substring(pointI + 1).trim(); + if (ext === "") throw new HttpError("Given file has no extension."); + + // Clean up request + const req = await convert(rawReq, { name: 'string?', visibility: 'number?' }); + req.name ??= new UUID().toString(); + req.visibility = 0; + if (req.visibility < 0 || req.visibility > 2) throw new HttpError("body.visibility: Must be 0, 1, or 2"); + + // Create file + const img: Image = { _id: new UUID(), author: user.username, created: now(), name: req.name!, visibility: req.visibility }; + await Deno.mkdir('images', { recursive: true }); + const out = await Deno.open(`images/${img._id}.${ext}`, { write: true, create: true }); + + for await (const bit of rawFile.stream()) out.write(bit); + out.close(); + + // Write to DB + try { + this.images.insertOne(img); + return ImageRouter.serialize(img); + } + catch (e) { + await Deno.remove(`images/${img._id}.${ext}`); + throw e; + } + } + public constructor(private salt: string, private images: Collection, private users: Collection) { super(); users.createIndexes({ indexes: [ { key: { username: 1 }, name: 'Username Index' } ] }); diff --git a/src/server/decorators.ts b/src/server/decorators.ts index 93fbb12..b2011a1 100644 --- a/src/server/decorators.ts +++ b/src/server/decorators.ts @@ -5,5 +5,6 @@ import route from "./decorators/route.ts"; import schema from "./decorators/schema.ts"; import jwt from "./decorators/jwt.ts"; import page from "./decorators/page.ts"; +import headers from "./decorators/headers.ts"; -export { body, schema, rest, route, auth , jwt, page }; \ No newline at end of file +export { body, schema, rest, route, auth , jwt, page, headers }; \ No newline at end of file diff --git a/src/server/decorators/headers.ts b/src/server/decorators/headers.ts new file mode 100644 index 0000000..4b3251d --- /dev/null +++ b/src/server/decorators/headers.ts @@ -0,0 +1,6 @@ +import { makeParameterModifier } from "../Router.ts"; + + +export default function headers() { + return makeParameterModifier(req => req.headers); +} diff --git a/src/server/decorators/jwt.ts b/src/server/decorators/jwt.ts index 0530d1f..5eeacb6 100644 --- a/src/server/decorators/jwt.ts +++ b/src/server/decorators/jwt.ts @@ -2,7 +2,9 @@ import JWT from "../../utils/JWT.ts"; import HttpError from "../HttpError.ts"; import Router, { makeParameterModifier } from "../Router.ts"; -export default function jwt(salt: ((self: T) => string) | keyof T, required = false) { +// SHUT THE FUCK UP +// deno-lint-ignore no-explicit-any +export default function jwt(salt: ((self: T) => string) | keyof T, required = false) { return makeParameterModifier(function (_req, val?: string) { if (val === undefined) return undefined; const s = typeof salt === 'function' ? salt(this) : this[salt] as string; diff --git a/src/server/decorators/schema.ts b/src/server/decorators/schema.ts index 9e0f3dc..050cd5a 100644 --- a/src/server/decorators/schema.ts +++ b/src/server/decorators/schema.ts @@ -4,107 +4,131 @@ import HttpError from "../HttpError.ts"; import { makeParameterModifier } from "../Router.ts"; export type PrimitiveSchema = 'string' | 'number' | 'boolean' | 'uuid' | 'object'; -export type OptionalSchema = `${PrimitiveSchema}?`; -export type Schema = 'any' | 'null' | PrimitiveSchema | OptionalSchema | Schema[] | ({ $optional?: boolean; } & { [key: string]: Schema }); +export type OptionalSchema = `${T}?`; +export type ObjectSchema = { $optional?: boolean; } & { [key: string]: Schema }; +export type OrSchema = [] | [Schema] | [Schema, Schema] | [Schema, Schema, Schema]; +export type Schema = 'any' | 'null' | PrimitiveSchema | OptionalSchema | OrSchema | ObjectSchema; -export default function schema(desc: Schema) { - function stringify(desc: Schema): string { - if (typeof desc === 'string') return desc; - if (desc instanceof Array) return desc.map(stringify).join(' | '); +export type ObjectSchemaType = + ({ [x in keyof T]: SchemaType }) | + (T["$optional"] extends true ? undefined : never); +export type SchemaType = + T extends 'any' ? any : + T extends 'null' ? null : + T extends 'string' ? string : + T extends 'number' ? number : + T extends 'boolean' ? boolean : + T extends 'uuid' ? UUID : + T extends OptionalSchema ? SchemaType | undefined : + T extends [] ? never : + T extends [Schema] ? SchemaType : + T extends [Schema, Schema] ? SchemaType | SchemaType : + T extends [Schema, Schema, Schema] ? SchemaType | SchemaType | SchemaType : + T extends ObjectSchema ? ObjectSchemaType : never; - let res = '{ '; + +function stringify(desc: Schema): string { + if (typeof desc === 'string') return desc; + if (desc instanceof Array) return desc.map(stringify).join(' | '); + + let res = '{ '; + + for (const key in desc) { + if (key === '$optional') continue; + if (res != '{ ') res += ', '; + res += key + ': '; + res += stringify(desc[key]); + } + res += '}'; + if (desc.$optional) res += '?'; + + return res; +} +async function _convert(path: string[], val: unknown, desc: Schema): Promise { + const _path = path.join('.'); + if (val instanceof Blob) val = await val.text(); + if (val instanceof ReadableStream) { + const res: Uint8Array[] = []; + + for await (const part of val) { + if (!(part instanceof Uint8Array)) throw new Error(`${_path}: Invalid stream given.`); + res.push(part); + } + + val = await new Blob(res).text(); + } + if (desc === 'any') return val as any; + + if (typeof desc === 'string') { + let type: string = desc; + const opt = desc.endsWith('?'); + + if (opt) type = type.substring(0, desc.length - 1); + if (opt && val === undefined) return val; + + switch (type) { + case "null": + if (val === null) return null; + throw new HttpError(`${_path}: Expected null.`); + case "string": return val + ""; + case "uuid": + try { + return new UUID(val + ""); + } + catch { + throw new HttpError(`${_path}: Expected an uuid or a value, convertible to an uuid.`); + } + case "number": { + const res = Number.parseFloat(val + ""); + if (isNaN(res)) throw new HttpError(`${_path}: Expected a number or a value, convertible to a number.`); + return res; + } + case "boolean": { + const res = val + ""; + if (res === 'true') return true; + if (res === 'false') return true; + throw new HttpError(`${_path}: Expected a boolean or a value, convertible to a boolean.`); + } + default: + throw new Error(`${_path}: Unknown type ${type}`); + } + } + else if (desc instanceof Array) { + for (const type of desc) { + try { + return await _convert(path, val, type); + } + catch { /**/ } + } + throw new HttpError(`${_path}: Expected a ${stringify(desc)}, got ${typeof val} instead.`); + } + else { + if (desc.$optional && val === undefined) return val; + if (val === null) throw new HttpError(`${_path}: Expected an object, got null instead.`); + + if (typeof val === 'string') { + try { + val = JSON.parse(val); + } + catch (e) { + throw new HttpError(`${_path}: Invalid JSON given for object: ${e}`); + } + } + else throw new HttpError(`${_path}: Expected an object or a valid json string.`); for (const key in desc) { if (key === '$optional') continue; - if (res != '{ ') res += ', '; - res += key + ': '; - res += stringify(desc[key]); + (val as any)[key] = await _convert([ ...path, key ], (val as any)[key], desc[key]); } - res += '}'; - if (desc.$optional) res += '?'; - return res; + return val; } - async function convert(path: string[], val: unknown, desc: Schema): Promise { - const _path = path.join('.'); - if (val instanceof Blob) val = await val.text(); - if (val instanceof ReadableStream) { - const res: Uint8Array[] = []; - - for await (const part of val) { - if (!(part instanceof Uint8Array)) throw new Error(`${_path}: Invalid stream given.`); - res.push(part); - } - - val = await new Blob(res).text(); - } - if (desc === 'any') return val; - - if (typeof desc === 'string') { - let type: string = desc; - const opt = desc.endsWith('?'); - - if (opt) type = type.substring(0, desc.length - 1); - if (opt && val === undefined) return val; - - switch (type) { - case "null": - if (val === null) return null; - throw new HttpError(`${_path}: Expected null.`); - case "string": return val + ""; - case "uuid": - try { - return new UUID(val + ""); - } - catch { - throw new HttpError(`${_path}: Expected an uuid or a value, convertible to an uuid.`); - } - case "number": { - const res = Number.parseFloat(val + ""); - if (isNaN(res)) throw new HttpError(`${_path}: Expected a number or a value, convertible to a number.`); - return res; - } - case "boolean": { - const res = val + ""; - if (res === 'true') return true; - if (res === 'false') return true; - throw new HttpError(`${_path}: Expected a boolean or a value, convertible to a boolean.`); - } - default: - throw new Error(`${_path}: Unknown type ${type}`); - } - } - else if (desc instanceof Array) { - for (const type of desc) { - try { - return convert(path, val, type); - } - catch { /**/ } - } - throw new HttpError(`${_path}: Expected a ${stringify(desc)}, got ${typeof val} instead.`); - } - else { - if (desc.$optional && val === undefined) return val; - if (val === null) throw new HttpError(`${_path}: Expected an object, got null instead.`); - - if (typeof val === 'string') { - try { - val = JSON.parse(val); - } - catch (e) { - if (val === null) throw new HttpError(`${_path}: Invalid JSON given for object: ${e}`); - } - } - else throw new HttpError(`${_path}: Expected an object or a valid json string.`); - - for (const key in desc) { - if (key === '$optional') continue; - (val as any)[key] = convert([ ...path, key ], (val as any)[key], desc[key]); - } - - return val; - } - } - - return makeParameterModifier(async (_req, val, name) => await convert([name], val, desc)); +} +export function convert(val: unknown, desc: T, path?: string): Promise> { + return _convert(path ? [path] : [], val, desc); +} + + +export default function schema(desc: Schema) { + return makeParameterModifier(async (_req, val, name) => await _convert([name], val, desc)); } diff --git a/tsconfig.json b/tsconfig.json index 0dd04af..9c7d629 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,7 @@ { + "include": [ "src/**/*.ts" ], "compilerOptions": { - "emitDecoratorMetadata": true, "experimentalDecorators": true, + "emitDecoratorMetadata": true, } }