chore: move backend to own folder

This commit is contained in:
TopchetoEU 2023-06-29 17:31:25 +03:00
parent d8cec0bc2d
commit 3f040b36a4
25 changed files with 1035 additions and 0 deletions

View File

@ -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<User>;
public readonly images: Collection<Image>;
public constructor(db: Database) {
this.db = db;
this.users = db.collection('users');
this.images = db.collection('images');
}
}

24
backend/src/index.ts Normal file
View File

@ -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' }));

View File

@ -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;
}

View File

@ -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[];
}

View File

@ -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<RestResponse | HttpError> {
if (error instanceof HttpError) return new HttpError({ error: error.body }, error.status);
return super.onError(_req, error);
}
}

View File

@ -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();
}
}

View File

@ -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);
}
}

View File

@ -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' } ] });
}
}

View File

@ -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());
}
}

View File

@ -0,0 +1,112 @@
export type ParamDict = Record<string, string>;
export type Headers = Record<string, string | string[] | undefined>;
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);
}
}

View File

@ -0,0 +1,50 @@
import { stream } from "../utils/utils.ts";
import { Headers } from "./RestRequest.ts";
export default class RestResponse {
#status = 200;
#statusMsg = '';
#body?: ReadableStream<Uint8Array>;
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<Uint8Array>) {
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,
});
}
}

View File

@ -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> | RestResponse | undefined;
export interface Handler {
handle(req: RestRequest): HandlerRes;
}
export interface RestOptions {
route?: string;
}
// deno-lint-ignore no-explicit-any
export type ProcessFunc<T extends Router> = (this: T, req: RestRequest, arg: any, name: string) => Promise<unknown> | unknown;
export interface RouterHandler {
path: string;
handler: Handler;
}
export function addMetaQuery<T extends Router>(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<T extends Router>(func: ProcessFunc<T>) {
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> | 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());
}
}
}
}

View File

@ -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 };

View File

@ -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;
});
}

View File

@ -0,0 +1,9 @@
import { makeParameterModifier } from "../Router.ts";
export type BodyType = 'raw' | 'json';
export default function body() {
return makeParameterModifier(req => {
return req.body;
});
}

View File

@ -0,0 +1,6 @@
import { makeParameterModifier } from "../Router.ts";
export default function headers() {
return makeParameterModifier(req => req.headers);
}

View File

@ -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<T extends Router = any>(salt: ((self: T) => string) | string, required = false) {
return makeParameterModifier<T>(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;
}
});
}

View File

@ -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<T>(cursor: FindCursor<T>) {
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);
});
}

View File

@ -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<KeyT extends string> = Router & { [X in KeyT]: Function; };
type ModArray<T extends Router> = ([ProcessFunc<T>, ...ProcessFunc<T>[]] | undefined)[];
export default function rest<KeyT extends keyof T & string, T extends Base<KeyT>>(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<T> = [];
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<T>;
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));
}
},
}));
};
}

View File

@ -0,0 +1,10 @@
import Router, { Handler, addMetaQuery } from "../Router.ts";
export default function route<KeyT extends keyof T & string, T extends Router & { [x in KeyT]: Handler }>(path?: string) {
return (target: T, key: KeyT) => {
addMetaQuery(target, r => ({
handler: r[key],
path: path ?? key,
}));
};
}

View File

@ -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 extends PrimitiveSchema | number = PrimitiveSchema> = `${T}?`;
export type LiteralSchema<T extends string = string> = `:${T}`;
export type OptionalLiteralSchema<T extends string = string> = `:${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<T extends ObjectSchema> =
({ [x in keyof T]: SchemaType<T[x]> }) |
(T["$optional"] extends true ? undefined : never);
export type SchemaType<T extends Schema> =
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<infer T> ? SchemaType<T> | undefined :
T extends LiteralSchema<infer T> ? T :
T extends OptionalLiteralSchema<infer T> ? T | undefined :
T extends [] ? never :
T extends [Schema] ? SchemaType<T[0]> :
T extends [Schema, Schema] ? SchemaType<T[0]> | SchemaType<T[1]> :
T extends [Schema, Schema, Schema] ? SchemaType<T[0]> | SchemaType<T[1]> | SchemaType<T[2]> :
T extends ObjectSchema ? ObjectSchemaType<T> : 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<any> {
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<T extends Schema>(val: unknown, desc: T, path?: string): Promise<SchemaType<T>> {
return _convert(path ? [path] : [], val, desc);
}
export default function schema(desc: Schema) {
return makeParameterModifier(async (_req, val, name) => await _convert([name], val, desc));
}

View File

@ -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<ReadableStream<Uint8Array>> {
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));
}

76
backend/src/utils/JWT.ts Normal file
View File

@ -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;
}
}

View File

@ -0,0 +1,6 @@
export function now() {
return new Date().getTime() / 1000;
}
export function stream(...text: string[]) {
return new Blob(text).stream();
}