diff --git a/backend/src/AppDatabase.ts b/backend/src/AppDatabase.ts new file mode 100644 index 0000000..e281539 --- /dev/null +++ b/backend/src/AppDatabase.ts @@ -0,0 +1,15 @@ +import { Collection, Database } from "https://deno.land/x/mongo@v0.31.2/mod.ts"; +import Image from "./models/Image.ts"; +import User from "./models/User.ts"; + +export default class AppDatabase { + public readonly db: Database; + public readonly users: Collection; + public readonly images: Collection; + + public constructor(db: Database) { + this.db = db; + this.users = db.collection('users'); + this.images = db.collection('images'); + } +} \ No newline at end of file diff --git a/backend/src/index.ts b/backend/src/index.ts new file mode 100644 index 0000000..2c55d88 --- /dev/null +++ b/backend/src/index.ts @@ -0,0 +1,24 @@ +import { MongoClient } from "https://deno.land/x/mongo@v0.31.2/mod.ts"; +import { RootRouter } from "./routers/RootRouter.ts"; +import * as bcrypt from "https://deno.land/x/bcrypt@v0.4.1/mod.ts"; +import AppDatabase from "./AppDatabase.ts"; + +export default async function clonegur() { + let salt; + + try { + salt = new TextDecoder().decode(await Deno.readFile('keys/salt.txt')); + } + catch { + salt = await bcrypt.genSalt(); + await Deno.writeFile('keys/salt.txt', new TextEncoder().encode(salt)); + } + + const db = await new MongoClient().connect({ + db: 'clonegur', + servers: [ { host: '127.0.0.1', port: 27017 } ] + }); + return new RootRouter(salt, new AppDatabase(db)); +} + +(await clonegur()).attach(Deno.listen({ port: 4000, hostname: 'localhost' })); diff --git a/backend/src/models/Image.ts b/backend/src/models/Image.ts new file mode 100644 index 0000000..ff12467 --- /dev/null +++ b/backend/src/models/Image.ts @@ -0,0 +1,15 @@ +import { UUID } from "https://deno.land/x/mongo@v0.31.2/mod.ts"; + +export enum Visibility { + Public, + Unlisted, + Private, +} + +export default interface Image { + _id: UUID; + author: string; + name: string; + visibility: Visibility; + created: number; +} \ No newline at end of file diff --git a/backend/src/models/User.ts b/backend/src/models/User.ts new file mode 100644 index 0000000..c6f3049 --- /dev/null +++ b/backend/src/models/User.ts @@ -0,0 +1,8 @@ +import { UUID } from "https://deno.land/x/mongo@v0.31.2/mod.ts"; + +export default interface User { + _id: string; + username: string; + password: string; + images: UUID[]; +} diff --git a/backend/src/routers/AppRouter.ts b/backend/src/routers/AppRouter.ts new file mode 100644 index 0000000..d6852aa --- /dev/null +++ b/backend/src/routers/AppRouter.ts @@ -0,0 +1,11 @@ +import HttpError from "../server/HttpError.ts"; +import RestRequest from "../server/RestRequest.ts"; +import RestResponse from "../server/RestResponse.ts"; +import Router from "../server/Router.ts"; + +export default class AppRouter extends Router { + public onError(_req: RestRequest, error: unknown): RestResponse | HttpError | Promise { + if (error instanceof HttpError) return new HttpError({ error: error.body }, error.status); + return super.onError(_req, error); + } +} \ No newline at end of file diff --git a/backend/src/routers/ImageRouter.ts b/backend/src/routers/ImageRouter.ts new file mode 100644 index 0000000..c94ff20 --- /dev/null +++ b/backend/src/routers/ImageRouter.ts @@ -0,0 +1,117 @@ +import { UUID } from "https://deno.land/x/web_bson@v0.3.0/mod.js"; + +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 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"; +import AppDatabase from "../AppDatabase.ts"; + +export default class ImageRouter extends AppRouter { + public static deserialize(image: Image) { + return { + author: image.author, + created: image.created, + name: image.name, + visibility: image.visibility, + id: image._id, + }; + } + + @rest('GET', '/') + async get(@schema('uuid') id: UUID, @jwt(v => v.salt, false) @auth() jwt?: JWTPayload) { + const image = await this.db.images.findOne({ _id: new UUID(id) }); + + if ( + !image || + image.visibility === Visibility.Private && image.author !== jwt?.name + ) throw new HttpError("Image doesn't exist."); + + return ImageRouter.deserialize(image); + } + @rest('GET', '/feed') + async self(@page() page: Page) { + const res = await page.apply(this.db.images.find({})).toArray(); + if (!res) throw new HttpError('User not found.'); + return res.map(v => ImageRouter.deserialize(v)); + } + + @rest('POST', '/upload') + async upload(@body() body: Blob, @headers() headers: Headers, @jwt(v => v.salt, true) @auth() jwt: JWTPayload) { + const user = await this.db.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 { + await this.db.images.insertOne(img); + await this.db.users.updateOne({ username: user.username }, { $push: { images: img._id } }); + return ImageRouter.deserialize(img); + } + catch (e) { + await Deno.remove(`images/${img._id}.${ext}`); + throw e; + } + } + @rest('POST', '/change') + async change(@body() raw: unknown, @jwt(v => v.salt, true) @auth() jwt: JWTPayload) { + const body = await convert(raw, { id: 'uuid', name: 'string?', visibility: 'number?' }); + const user = await this.db.users.findOne({ username: jwt.name }); + if (!user) throw new HttpError("You don't exist."); + + const img = await this.db.images.findOne({ _id: body.id }); + if (!img) throw new HttpError("Image doesn't exist."); + if (user.username !== img.author) throw new HttpError("You don't own the image."); + + await this.db.images.updateOne({ _id: body.id }, { $set: { name: body.name, visibility: body.visibility } }); + return ImageRouter.deserialize(img); + } + + public constructor(private db: AppDatabase) { + super(); + } +} \ No newline at end of file diff --git a/backend/src/routers/RootRouter.ts b/backend/src/routers/RootRouter.ts new file mode 100644 index 0000000..3b17667 --- /dev/null +++ b/backend/src/routers/RootRouter.ts @@ -0,0 +1,24 @@ +import UserRouter from "../routers/UserRouter.ts"; +import ImageRouter from "./ImageRouter.ts"; +import AppRouter from "./AppRouter.ts"; +import RestResponse from "../server/RestResponse.ts"; +import { rest, route } from "../server/decorators.ts"; +import { stream } from "../utils/utils.ts"; +import AppDatabase from "../AppDatabase.ts"; + +export class RootRouter extends AppRouter { + @route('users/*') users; + @route('images/*') images; + + @rest('*', '*') + default() { + return new RestResponse().body(stream('Page not found :/')).status(404); + } + + constructor(salt: string, db: AppDatabase) { + super(); + + this.users = new UserRouter(salt, db); + this.images = new ImageRouter(db); + } +} diff --git a/backend/src/routers/UserRouter.ts b/backend/src/routers/UserRouter.ts new file mode 100644 index 0000000..d1856df --- /dev/null +++ b/backend/src/routers/UserRouter.ts @@ -0,0 +1,95 @@ +import * as bcrypt from "https://deno.land/x/bcrypt@v0.4.1/mod.ts"; + +import { auth, body, rest, schema } from "../server/decorators.ts"; +import HttpError from "../server/HttpError.ts"; +import User from "../models/User.ts"; +import AppRouter from "./AppRouter.ts"; +import jwt from "../server/decorators/jwt.ts"; +import JWT, { JWTPayload } from "../utils/JWT.ts"; +import { now } from "../utils/utils.ts"; +import { convert } from "../server/decorators/schema.ts"; +import AppDatabase from "../AppDatabase.ts"; +import { Visibility } from "../models/Image.ts"; + +export interface LoginRequest { + username: string; + password: string; +} + +export default class UserRouter extends AppRouter { + public deserialize(user: User, self = false) { + let images = user.images; + if (!self) { + images = []; + Promise.all(user.images.map(async v => { + if ((await this.db.images.findOne({ _id: v }))?.visibility === Visibility.Public) { + images.push(v); + } + })); + } + return { username: user.username, images }; + } + + @rest('GET', '/') + async get(@schema('string') username: string, @jwt('salt', true) @auth() jwt: JWTPayload) { + const res = await this.db.users.findOne({ username }); + if (res === undefined) throw new HttpError('User not found.'); + + return this.deserialize(res, jwt.name === username); + } + @rest('GET', '/self') + async self(@jwt('salt', true) @auth() auth: JWTPayload) { + if (auth === undefined) throw new HttpError('You are not logged in.'); + const res = await this.db.users.findOne({ username: auth.name }); + + if (res === undefined) throw new HttpError('User not found.'); + + return this.deserialize(res); + } + + @rest('POST', '/signup') + async signup(@body() raw: unknown) { + const body = await convert(raw, { + username: 'string', + password: 'string', + }); + + if (await this.db.users.countDocuments({ username: body.username }) > 0) { + throw new HttpError('User with the same username already exists.'); + } + + const password = await bcrypt.hash(body.password, this.salt); + + await this.db.users.insertOne({ + username: body.username, + password: password, + images: [], + }); + + return {}; + } + @rest('POST', '/login') + async login(@body() raw: unknown) { + const body = await convert(raw, { + username: 'string', + password: 'string' + }); + const res = await this.db.users.findOne({ username: body.username }); + if (!res) throw new HttpError('Incorrect username or password.'); + const hashed = await bcrypt.hash(body.password, this.salt); + + if (res.password !== hashed) throw new HttpError('Incorrect username or password.'); + + const time = now(); + return JWT.encode({ + iat: time, + exp: time + 3600 * 12, + name: res.username, + }, this.salt); + } + + public constructor(private salt: string, private db: AppDatabase) { + super(); + db.users.createIndexes({ indexes: [ { key: { username: 1 }, name: 'Username Index' } ] }); + } +} \ No newline at end of file diff --git a/backend/src/server/HttpError.ts b/backend/src/server/HttpError.ts new file mode 100644 index 0000000..8dd1549 --- /dev/null +++ b/backend/src/server/HttpError.ts @@ -0,0 +1,8 @@ +import serialize from "./serialize.ts"; + +export default class HttpError extends Error { + constructor(public readonly body: unknown, public readonly status = 400) { + super(); + serialize(body).then(v => this.message = v.toString()); + } +} diff --git a/backend/src/server/RestRequest.ts b/backend/src/server/RestRequest.ts new file mode 100644 index 0000000..958252e --- /dev/null +++ b/backend/src/server/RestRequest.ts @@ -0,0 +1,112 @@ +export type ParamDict = Record; +export type Headers = Record; + +function splitUrl(url: string) { + return url.split('/').map(v => v.trim()).filter(v => v !== ''); +} +function sanitizeUrl(url: string, forceAbsolute = true) { + url = url.trim(); + if (forceAbsolute || url.startsWith('/')) { + return '/' + splitUrl(url).join('/'); + } + else { + return '/' + splitUrl(url).join('/'); + } +} + +export default class RestRequest { + public readonly body: unknown; + public readonly method: string; + public readonly url: string; + public readonly pathParams: ParamDict; + public readonly queryParams: ParamDict; + public readonly headers: Headers; + + public get params() { + return { ...this.queryParams, ...this.pathParams }; + } + + public constructor( + body: unknown, + headers: Headers, + method: string, + url: string, + pathParams: ParamDict = {}, + queryParams: ParamDict = {} + ) { + this.body = body; + this.headers = headers; + this.pathParams = { ...pathParams }; + this.queryParams = { ...queryParams }; + this.method = method.toLowerCase(); + this.url = sanitizeUrl(url); + + if (this.url.includes('?')) { + const questionIndex = this.url.indexOf('?'); + this.url = this.url.substring(0, questionIndex); + const params = this.url + .substring(questionIndex + 1) + .split('&') + .map(v => v.trim()) + .filter(v => v !== ''); + + for (const rawParam of params) { + const i = rawParam.indexOf('='); + if (i < 0) continue; + + const name = rawParam.substring(0, i); + const val = rawParam.substring(i + 1); + + if (name === '') continue; + this.queryParams[name] = val; + } + } + } + + public match(predicate: string) { + const urlSegments = splitUrl(this.url); + const predSegments = splitUrl(predicate); + const wildcardIndex = predSegments.indexOf('*'); + const hasWildcard = wildcardIndex >= 0; + const pathParams: ParamDict = { ...this.pathParams }; + + if (wildcardIndex >= 0) { + if (predSegments.includes('*', wildcardIndex + 1)) throw new Error("A path predicate may not have more than one wildcard."); + if (predSegments.splice(wildcardIndex).length > 1) throw new Error("A path predicate must be the last segment."); + } + + for (const predSeg of predSegments) { + const urlSeg = urlSegments.shift(); + if (urlSeg === undefined) return undefined; + else if (predSeg.startsWith(':')) { + const name = predSeg.substring(1); + if (name.length === 0) throw new Error('Invalid path predicate - a segment may not be ":".'); + pathParams[name] = decodeURI(urlSeg); + } + else if (predSeg === urlSeg) continue; + else return undefined; + } + + if (!hasWildcard && urlSegments.length > 0) return undefined; + + return new RestRequest( + this.body, this.headers, this.method, '/' + urlSegments.join('/'), + { ...this.pathParams, ...pathParams }, this.queryParams + ); + } + + public static fromMessage(msg: Deno.RequestEvent) { + const raw = msg.request.body; + const headers = {} as Headers; + + for (const entry of msg.request.headers.entries()) { + headers[entry[0]] = entry[1]; + } + + const url = new URL(msg.request.url); + const params = {} as ParamDict; + for (const entry of url.searchParams.entries()) params[entry[0]] = entry[1]; + + return new RestRequest(raw, headers, msg.request.method, url.pathname, {}, params); + } +} diff --git a/backend/src/server/RestResponse.ts b/backend/src/server/RestResponse.ts new file mode 100644 index 0000000..d4e694f --- /dev/null +++ b/backend/src/server/RestResponse.ts @@ -0,0 +1,50 @@ +import { stream } from "../utils/utils.ts"; +import { Headers } from "./RestRequest.ts"; + +export default class RestResponse { + #status = 200; + #statusMsg = ''; + #body?: ReadableStream; + + headers: Headers = {}; + + public constructor() { } + + public get statusCode() { return this.#status; } + public get statusMessage() { return this.#statusMsg; } + public get content() { return this.#body; } + + public header(name: string, val: string | string[]) { + this.headers[name] = val; + return this; + } + public status(val: number, message = '') { + this.#status = val; + this.#statusMsg = message; + return this; + } + public body(val: string | ReadableStream) { + if (typeof val === 'string') val = stream(val); + this.#body = val; + return this; + } + + public toFetchResponse(): Response { + const headers: string[][] = []; + for (const key in this.headers) { + const val = this.headers[key]; + if (typeof val === 'string') { + headers.push([key, val]); + } + else if (val instanceof Array) { + headers.push([key, ...val]); + } + else headers.push([key]); + } + return new Response(this.#body, { + headers: headers, + status: this.#status, + statusText: this.#statusMsg, + }); + } +} \ No newline at end of file diff --git a/backend/src/server/Router.ts b/backend/src/server/Router.ts new file mode 100644 index 0000000..fd8e778 --- /dev/null +++ b/backend/src/server/Router.ts @@ -0,0 +1,108 @@ +import { Reflect } from "https://deno.land/x/reflect_metadata@v0.1.12/mod.ts"; +import serialize from "./serialize.ts"; +import HttpError from "./HttpError.ts"; +import RestResponse from "./RestResponse.ts"; +import RestRequest from "./RestRequest.ts"; + +export type HandlerRes = Promise | RestResponse | undefined; + +export interface Handler { + handle(req: RestRequest): HandlerRes; +} +export interface RestOptions { + route?: string; +} + +// deno-lint-ignore no-explicit-any +export type ProcessFunc = (this: T, req: RestRequest, arg: any, name: string) => Promise | unknown; + +export interface RouterHandler { + path: string; + handler: Handler; +} +export function addMetaQuery(target: T, ...handlers: ((r: T) => RouterHandler)[]) { + let props = Reflect.getOwnMetadata('router:queries', target); + if (props === undefined) Reflect.defineMetadata('router:queries', props = [], target); + + props.push(...handlers); +} + +export function makeParameterModifier(func: ProcessFunc) { + return (target: T, key: string, index: number) => { + let res = Reflect.getOwnMetadata('router:params', target, key); + + if (res === undefined) Reflect.defineMetadata('router:params', res = [], target, key); + (res[index] ??= []).push(func); + + return res; + } +} + + +export default class Router { + private _handlers?: RouterHandler[]; + public defaultHandler?: Handler; + + private _init() { + if (this._handlers === undefined) { + this._handlers = []; + + // why the actual fuck not + // deno-lint-ignore no-this-alias + for (let proto = this; proto instanceof Router; proto = Object.getPrototypeOf(proto)) { + const props = Reflect.getOwnMetadata('router:queries', proto); + for (const handler of props ?? []) { + this._handlers.push(handler(this)); + } + } + } + return this._handlers; + } + + public async handle(req: RestRequest) { + for (const hnd of this._init()) { + const _req = req.match(hnd.path); + if (_req) { + try { + const res = await hnd.handler.handle(_req); + if (res) return res; + } + catch (e) { + const res = await this.onError(_req, e); + if (res instanceof HttpError) return new RestResponse() + .body(await serialize(res.body)) + .status(res.status); + else return res; + } + } + } + + return this.defaultHandler?.handle(req); + } + public addHandler(path: string, handler: Handler) { + this._init().push({ path, handler }); + return this; + } + + public onError(_req: RestRequest, error: unknown): Promise | RestResponse | HttpError { + if (error instanceof HttpError) return error; + else { + console.error(error); + try { + return new HttpError(`Internal error: ${error}\nSee logs for details`, 500); + } + catch { + return new HttpError('Internal error.\nSee logs for details', 500); + } + } + } + + public async attach(server: Deno.Listener) { + for await (const conn of server) { + for await (const req of Deno.serveHttp(conn)) { + const r = await this.handle(RestRequest.fromMessage(req)); + if (r) req.respondWith(r.toFetchResponse()); + } + } + } +} diff --git a/backend/src/server/decorators.ts b/backend/src/server/decorators.ts new file mode 100644 index 0000000..b2011a1 --- /dev/null +++ b/backend/src/server/decorators.ts @@ -0,0 +1,10 @@ +import body from "./decorators/body.ts"; +import rest from "./decorators/rest.ts"; +import auth from "./decorators/auth.ts"; +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, headers }; \ No newline at end of file diff --git a/backend/src/server/decorators/auth.ts b/backend/src/server/decorators/auth.ts new file mode 100644 index 0000000..5abbe14 --- /dev/null +++ b/backend/src/server/decorators/auth.ts @@ -0,0 +1,10 @@ +import { makeParameterModifier } from "../Router.ts"; + +export default function auth() { + return makeParameterModifier(function (req) { + const res = req.headers.authorization; + if (typeof res !== 'string') return undefined; + if (res.startsWith('Bearer')) return res.substring(6).trimStart(); + else return undefined; + }); +} \ No newline at end of file diff --git a/backend/src/server/decorators/body.ts b/backend/src/server/decorators/body.ts new file mode 100644 index 0000000..2ddaab5 --- /dev/null +++ b/backend/src/server/decorators/body.ts @@ -0,0 +1,9 @@ +import { makeParameterModifier } from "../Router.ts"; + +export type BodyType = 'raw' | 'json'; + +export default function body() { + return makeParameterModifier(req => { + return req.body; + }); +} \ No newline at end of file diff --git a/backend/src/server/decorators/headers.ts b/backend/src/server/decorators/headers.ts new file mode 100644 index 0000000..4b3251d --- /dev/null +++ b/backend/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/backend/src/server/decorators/jwt.ts b/backend/src/server/decorators/jwt.ts new file mode 100644 index 0000000..8c9986e --- /dev/null +++ b/backend/src/server/decorators/jwt.ts @@ -0,0 +1,23 @@ +// SHUT THE FUCK UP +// deno-lint-ignore-file no-explicit-any +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) | string, required = false) { + return makeParameterModifier(function (_req, val?: string) { + if (val === undefined) return undefined; + const s = typeof salt === 'function' ? salt(this) : (this as any)[salt] as string; + try { + const res = JWT.decode(val, s); + if (required && res === undefined) throw new HttpError('You are not logged in.'); + return res; + } + catch (e) { + if (e instanceof Error && !(e instanceof HttpError)) { + throw new HttpError(e.message, 400); + } + else throw e; + } + }); +} \ No newline at end of file diff --git a/backend/src/server/decorators/page.ts b/backend/src/server/decorators/page.ts new file mode 100644 index 0000000..1418a1b --- /dev/null +++ b/backend/src/server/decorators/page.ts @@ -0,0 +1,39 @@ +import { makeParameterModifier } from "../Router.ts"; +import { FindCursor } from "https://deno.land/x/mongo@v0.31.2/src/collection/commands/find.ts"; + +export class Page { + public size?: number; + public index?: number; + + public apply(cursor: FindCursor) { + let res = cursor; + + if (this.size !== undefined) { + if (this.index !== undefined) res = res.skip(this.index * this.size); + res = res.limit(this.size); + } + + return res; + } + + public constructor(size?: number); + public constructor(size: number, index?: number); + public constructor(size?: number, index?: number) { + this.size = size; + this.index = index; + } +} + +export default function uuid() { + return makeParameterModifier(req => { + let n: number | undefined = Number.parseInt(req.params.n); + let i: number | undefined = Number.parseInt(req.params.i); + + if (isNaN(n) || n < 1) n = undefined; + if (isNaN(i) || i < 0) i = undefined; + + if (n === undefined) return new Page(); + else if (i === undefined) return new Page(n); + else return new Page(n, i); + }); +} \ No newline at end of file diff --git a/backend/src/server/decorators/rest.ts b/backend/src/server/decorators/rest.ts new file mode 100644 index 0000000..703661e --- /dev/null +++ b/backend/src/server/decorators/rest.ts @@ -0,0 +1,60 @@ +// deno-lint-ignore-file ban-types +import { Reflect } from "https://deno.land/x/reflect_metadata@v0.1.12/mod.ts"; + +import RestResponse from "../RestResponse.ts"; +import Router, { ProcessFunc, addMetaQuery } from "../Router.ts"; +import serialize from "../serialize.ts"; + +export type HttpMethod = '*' | 'GET' | 'POST' | 'CHANGE' | 'DELETE' | 'PUT' | 'UPDATE'; + +type Base = Router & { [X in KeyT]: Function; }; +type ModArray = ([ProcessFunc, ...ProcessFunc[]] | undefined)[]; + +export default function rest>(method: HttpMethod, route?: string) { + return (target: T, key: KeyT) => { + const path = route ?? key; + + addMetaQuery(target, (r) => ({ + path, handler: { + async handle(req) { + if (method !== '*' && req.method.toUpperCase() !== method) return undefined; + + const params: string[] = []; + const args: unknown[] = []; + const allMods: ModArray = []; + + let signature = r[key].toString(); + signature = signature.substring(signature.indexOf('(') + 1, signature.indexOf(')')); + params.push(...signature.split(',').map(v => v.trim()).filter(v => v !== '')); + + for (let proto = r; proto instanceof Router; proto = Object.getPrototypeOf(proto)) { + const data = Reflect.getOwnMetadata('router:params', proto, key) as ModArray; + if (!data) continue; + for (let i = 0; i < data.length; i++) { + if (data[i] === undefined) continue; + + if (allMods[i]) allMods[i]!.push(...data[i]!); + else allMods[i] ??= [...data[i]!]; + } + } + + for (let i = 0; i < params.length; i++) { + const param = params[i]; + + let arg: unknown = req.params[param]; + + for (const mod of allMods[i] ?? []) { + arg = await mod.call(r, req, arg, params[i]); + } + + args.push(arg); + } + + const res = r[key].apply(r, args); + if (res instanceof RestResponse) return res; + return new RestResponse().body(await serialize(res)); + } + }, + })); + }; +} \ No newline at end of file diff --git a/backend/src/server/decorators/route.ts b/backend/src/server/decorators/route.ts new file mode 100644 index 0000000..e1e35a6 --- /dev/null +++ b/backend/src/server/decorators/route.ts @@ -0,0 +1,10 @@ +import Router, { Handler, addMetaQuery } from "../Router.ts"; + +export default function route(path?: string) { + return (target: T, key: KeyT) => { + addMetaQuery(target, r => ({ + handler: r[key], + path: path ?? key, + })); + }; +} \ No newline at end of file diff --git a/backend/src/server/decorators/schema.ts b/backend/src/server/decorators/schema.ts new file mode 100644 index 0000000..8ad8f1a --- /dev/null +++ b/backend/src/server/decorators/schema.ts @@ -0,0 +1,167 @@ +// deno-lint-ignore-file no-explicit-any +import { UUID } from "https://deno.land/x/mongo@v0.31.2/mod.ts"; +import HttpError from "../HttpError.ts"; +import { makeParameterModifier } from "../Router.ts"; + +export type PrimitiveSchema = number | 'any' | 'undefined' | 'null' | 'string' | 'number' | 'boolean' | 'true' | 'false' | 'uuid' | 'object'; +export type OptionalSchema = `${T}?`; +export type LiteralSchema = `:${T}`; +export type OptionalLiteralSchema = `:${T}?`; +export type ObjectSchema = { $optional?: boolean; } & { [key: string]: Schema }; +export type OrSchema = [] | [Schema] | [Schema, Schema] | [Schema, Schema, Schema]; +export type Schema = + PrimitiveSchema | OptionalSchema | OrSchema | ObjectSchema | + LiteralSchema | OptionalLiteralSchema; + +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 'undefined' ? undefined : + T extends 'string' ? string : + T extends 'number' ? number : + T extends 'boolean' ? boolean : + T extends 'uuid' ? UUID : + T extends 'true' ? true : + T extends 'false' ? false : + T extends number ? T : + T extends OptionalSchema ? SchemaType | undefined : + T extends LiteralSchema ? T : + T extends OptionalLiteralSchema ? T | 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; + + +function stringify(desc: Schema): string { + if (typeof desc === 'string') return desc; + if (typeof desc === 'number') return desc.toString(); + 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; + + if (type.startsWith(':')) { + if (val !== type.substring(1)) return val; + else throw new HttpError(`${_path}: Expected ${type.substring(1)}.`); + } + + switch (type) { + case "true": + if (val === true) return true; + throw new HttpError(`${_path}: Expected true.`); + case "false": + if (val === false) return false; + throw new HttpError(`${_path}: Expected false.`); + case "null": + if (val === null) return null; + throw new HttpError(`${_path}: Expected null.`); + case "undefined": + if (val === undefined) return undefined; + throw new HttpError(`${_path}: Expected undefined.`); + 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.`); + } + } + + const num = Number.parseFloat(type); + + if (!isNaN(num)) { + const res = Number.parseFloat(val + ""); + if (res !== num) throw new HttpError(`${_path}: Expected ${num}.`); + return res; + } + + throw new Error(`${_path}: Unknown type ${type}`); + } + else if (typeof desc === 'number') { + if (desc === val) return desc; + else throw new HttpError(`${_path}: Expected ${desc}.`); + } + 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; + (val as any)[key] = await _convert([ ...path, key ], (val as any)[key], desc[key]); + } + + return val; + } +} +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/backend/src/server/serialize.ts b/backend/src/server/serialize.ts new file mode 100644 index 0000000..44699a6 --- /dev/null +++ b/backend/src/server/serialize.ts @@ -0,0 +1,32 @@ +import { stream } from "../utils/utils.ts"; + +const undefinedBuff = new Blob([new TextEncoder().encode('undefined')]); +const nullBuff = new Blob([new TextEncoder().encode('null')]); + +export default async function serialize(val: unknown, depth = 16): Promise> { + while (true) { + if (depth <= 0) throw new Error("Call depth exceeded limit."); + + if (val instanceof Promise) val = await val; + else if (val instanceof Function) { + if (val.length !== 0) throw new Error('Can\'t serialize an argument-accepting function'); + val = val(); + } + else break; + + depth--; + } + + if (val === undefined) return undefinedBuff.stream(); + if (val === null) return nullBuff.stream(); + if (typeof val === 'string') return stream(val); + if (val instanceof Blob) return val.stream(); + if (val instanceof ReadableStream) return val; + if (val instanceof Uint8Array) return new Blob([val]).stream(); + + if (!(val instanceof Array) && val.toString !== Object.prototype.toString && val.toString instanceof Function) { + val = val.toString(); + } + + return stream(JSON.stringify(val)); +} diff --git a/backend/src/utils/JWT.ts b/backend/src/utils/JWT.ts new file mode 100644 index 0000000..495e440 --- /dev/null +++ b/backend/src/utils/JWT.ts @@ -0,0 +1,76 @@ +import { hmac } from "https://deno.land/x/hmac@v2.0.1/mod.ts"; + +function trimBase64(val: string | Uint8Array) { + if (val instanceof Uint8Array) { + val = new TextDecoder().decode(val); + } + + while (val.endsWith('=')) { + val = val.substring(0, val.length - 1); + } + + return val.replaceAll('+', '-').replaceAll('/', '_'); +} +function toBase64(val: string) { + val = btoa(val); + + while (val.endsWith('=')) { + val = val.substring(0, val.length - 1); + } + + return val.replaceAll('+', '-').replaceAll('/', '_'); +} +function fromBase64(val: string) { + return atob(val.replaceAll('-', '+').replaceAll('_', '/')); +} + +export interface JWTPayload { + iss?: string; + sub?: string; + aud?: string; + exp?: number; + nbf?: number; + iat?: number; + jti?: string; + // deno-lint-ignore no-explicit-any + [prop: string]: any; +} + +export default { + encode(payload: JWTPayload | string, key: string) { + if (typeof payload === 'string') return payload; + const rawHeader = JSON.stringify({ alg: "HS256", typ: "JWT" }); + const rawPayload = JSON.stringify(payload); + const data = toBase64(rawHeader) + '.' + toBase64(rawPayload); + const rawSignature = trimBase64(hmac('sha256', key, data, 'utf8', 'base64')); + return data + '.' + rawSignature; + }, + decode(jwt: string | JWTPayload, key?: string) { + if (typeof jwt === 'object') return jwt; + const segments = jwt.split('.'); + if (segments.length != 3) throw new Error("Expected JWT to have exactly 2 dots."); + const [ rawHeader, rawPayload, givenSig ] = segments; + const data = rawHeader + '.' + rawPayload; + + if (key != undefined) { + if (JSON.parse(fromBase64(rawHeader))?.alg != "HS256") throw new Error("Invalid JWT algorithm."); + const actualSig = trimBase64(hmac('sha256', key, data, 'utf8', 'base64')) as string; + if (givenSig != actualSig) throw new Error("Invalid JWT signature."); + } + + try { return JSON.parse(fromBase64(rawPayload)) as JWTPayload; } + catch { throw new Error("Invalid JWT payload."); } + }, + validate(j: string | JWTPayload, key: string) { + if (typeof j === 'object') { + j = this.encode(j, key); + } + const segments = j.split('.'); + if (segments.length != 3) throw new Error("Expected jwt to have exactly 2 dots."); + const [ header, payload, givenSig ] = segments; + const data = header + '.' + payload; + const actualSig = trimBase64(hmac('sha256', key, data, 'utf8', 'base64')) as string; + + return givenSig != actualSig; + } +} diff --git a/backend/src/utils/utils.ts b/backend/src/utils/utils.ts new file mode 100644 index 0000000..7002f99 --- /dev/null +++ b/backend/src/utils/utils.ts @@ -0,0 +1,6 @@ +export function now() { + return new Date().getTime() / 1000; +} +export function stream(...text: string[]) { + return new Blob(text).stream(); +} \ No newline at end of file diff --git a/tsconfig.json b/backend/tsconfig.json similarity index 100% rename from tsconfig.json rename to backend/tsconfig.json