From 4d9cbbfdf0ad4e67c5841d358e0b718bf154da67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C4=81rlis=20=C4=8Cer=C5=86avskis?= Date: Thu, 13 Oct 2022 22:01:41 +0300 Subject: [PATCH] Added API endpoints for ED database --- package.json | 2 +- scripts/package.json | 4 +- spec/api.yml | 2 + spec/components/examples.yml | 13 +++ spec/components/schemas.yml | 42 +++++++ .../everythingdescriptions_contributions.yml | 82 ++++++++++++++ src/api/APIManager.ts | 2 + src/api/TempEDAPI.ts | 104 ++++++++++++++++++ src/database/Database.ts | 33 +++++- src/database/DatabaseManager.ts | 26 +++++ src/database/EDContribution.ts | 64 +++++++++++ 11 files changed, 369 insertions(+), 5 deletions(-) create mode 100644 spec/paths/everythingdescriptions_contributions.yml create mode 100644 src/api/TempEDAPI.ts create mode 100644 src/database/EDContribution.ts diff --git a/package.json b/package.json index 45e6f6c..ce128c8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "infernalstudios.org", - "version": "1.2.5", + "version": "1.3.0", "private": true, "description": "Source code for !", "bugs": "https://github.com/infernalexp/infernalstudios.org/issues", diff --git a/scripts/package.json b/scripts/package.json index 96d431e..61b54a1 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -10,7 +10,7 @@ "terser": "^5.10.0" }, "engines": { - "node": "^16", - "yarn": "^1.22.0" + "node": ">=16", + "yarn": ">=1.22.0" } } diff --git a/spec/api.yml b/spec/api.yml index 83306f1..f40e36f 100644 --- a/spec/api.yml +++ b/spec/api.yml @@ -105,3 +105,5 @@ paths: $ref: "./paths/users_id.yml" /users/self: $ref: "./paths/users_self.yml" + /everythingdescriptions/contributions: + $ref: "./paths/everythingdescriptions_contributions.yml" diff --git a/spec/components/examples.yml b/spec/components/examples.yml index 63fcdaa..6c4d4ae 100644 --- a/spec/components/examples.yml +++ b/spec/components/examples.yml @@ -133,3 +133,16 @@ user: id: "peasant" permissions: [] passwordChangeRequested: true + +edcontributions: + summary: ED Contributions + value: + "everydesc.minecraft:coal": + - value: "Cool furnace thing!" + user: "discord_user_id" + isDiscord: true + id: "abcdefg" + - value: "Cool furnace thing but worded differently!" + user: "user_id" + isDiscord: false + id: "3125476980" diff --git a/spec/components/schemas.yml b/spec/components/schemas.yml index 6c0100f..89dff80 100644 --- a/spec/components/schemas.yml +++ b/spec/components/schemas.yml @@ -225,6 +225,48 @@ initial_user: password: $ref: "#/user/properties/password" +edcontribution: + type: object + required: [id, value, user, isDiscord] + properties: + id: + type: string + value: + type: string + user: + type: string + isDiscord: + type: boolean + +initial_edcontribution: + type: object + required: [id, value, user, isDiscord] + properties: + value: + type: string + user: + type: string + isDiscord: + type: boolean + +edcontributions: + type: object + properties: + a: + type: array + items: + type: + $ref: "#/edcontribution" + +add_edcontributions: + type: object + properties: + a: + type: array + items: + type: + $ref: "#/initial_edcontribution" + error: type: array items: diff --git a/spec/paths/everythingdescriptions_contributions.yml b/spec/paths/everythingdescriptions_contributions.yml new file mode 100644 index 0000000..9106718 --- /dev/null +++ b/spec/paths/everythingdescriptions_contributions.yml @@ -0,0 +1,82 @@ +get: + summary: Get ED Contributions + description: | + Returns all contributions. + + The contributions aren't sorted, they're returned in the order they were returned by the database. + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/edcontributions" + examples: + contributions: + $ref: "#/components/examples/edcontributions" + "500": + $ref: "#/components/responses/internal_server_error" +post: + summary: Add ED Contribution + description: | + Adds ED contributions. + This endpoint is rate-limited to 15 calls/min (0.25/sec) in order to prevent abuse. + + security: + - token + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/initial_edcontributions" + responses: + "201": + description: Created + content: + application/json: + schema: + $ref: "#/components/schemas/edcontributions" + examples: + contributions: + $ref: "#/components/examples/edcontributions" + "400": + $ref: "#/components/responses/zod_error" + "403": + $ref: "#/components/responses/forbidden" + "419": + $ref: "#/components/responses/too_many_requests" + "500": + $ref: "#/components/responses/internal_server_error" +delete: + summary: Delete ED Contribution + security: + - token + parameters: + - name: id + in: path + required: true + schema: + type: string + description: The contribution's unique ID. + responses: + "204": + description: No Content + "400": + $ref: "#/components/responses/zod_error" + "401": + $ref: "#/components/responses/unauthorized" + "404": + description: Not Found + content: + application/json: + schema: + $ref: "#/components/schemas/error" + examples: + contribution_not_found: + summary: Contribution not found + value: { "errors": ["Contribution not found"] } + "419": + $ref: "#/components/responses/too_many_requests" + "500": + $ref: "#/components/responses/internal_server_error" diff --git a/src/api/APIManager.ts b/src/api/APIManager.ts index 7360372..6f2c030 100644 --- a/src/api/APIManager.ts +++ b/src/api/APIManager.ts @@ -7,6 +7,7 @@ import { getAuthAPI } from "./AuthAPI"; import { getModAPI } from "./ModAPI"; import { getRedirectAPI } from "./RedirectAPI"; import { StatusAPI } from "./StatusAPI"; +import { getTempEDAPI } from "./TempEDAPI"; import { getUserAPI } from "./UserAPI"; export function getAPI(database: Database): Router { @@ -64,6 +65,7 @@ export function getAPI(database: Database): Router { api.use("/redirects", getRedirectAPI(database)); api.use("/mods", getModAPI(database)); api.use("/users", getUserAPI(database)); + api.use("/everythingdescriptions", getTempEDAPI(database)); return api; } diff --git a/src/api/TempEDAPI.ts b/src/api/TempEDAPI.ts new file mode 100644 index 0000000..2a0d022 --- /dev/null +++ b/src/api/TempEDAPI.ts @@ -0,0 +1,104 @@ +// Copyright (c) 2022 Infernal Studios, All Rights Reserved unless otherwise explicitly stated. +import express, { Router } from "express"; +import { RateLimiterMemory } from "rate-limiter-flexible"; +import { z } from "zod"; +import { Database } from "../database/Database"; +import { EDContribution } from "../database/EDContribution"; +import { getAuthMiddleware, randomString } from "../util/Util"; + +export function getTempEDAPI(database: Database): Router { + const api = Router(); + + api.get("/contributions", async (_req, res) => { + res.status(200); + res.json(await database.temp_ed.getAllClean()); + return res.end(); + }); + + const postContributionsSchema = z.record( + z.string().refine(s => /^everydesc.[a-zA-Z0-9_-]+:[a-zA-Z0-9_-]+(\.[a-zA-Z0-9_-]+)*$/.test(s)), + z.array( + z + .object({ + value: z.string().max(2047), + user: z.string(), + isDiscord: z.boolean(), + }) + .strict() + ) + ); + + const rateLimit = new RateLimiterMemory({ + points: 15, + duration: 60, + }); + + api.post("/contributions", (req, res, next) => { + rateLimit + .consume(req.ip) + .then(rateLimiterRes => { + res.setHeader("X-RateLimit-Limit", rateLimit.points); + res.setHeader("X-RateLimit-Remaining", rateLimiterRes.remainingPoints); + res.setHeader("X-RateLimit-Reset", Math.ceil(rateLimiterRes.msBeforeNext / 1000)); + next(); + }) + .catch(rateLimiterRes => { + rateLimit.block(req.ip, Math.min(rateLimiterRes.msBeforeNext / 1000 + 12, 600)); + res.setHeader("X-RateLimit-Limit", rateLimit.points); + res.setHeader("X-RateLimit-Remaining", 0); + res.setHeader("X-RateLimit-Reset", Math.ceil(rateLimiterRes.msBeforeNext / 1000)); + res.status(429); + res.json({ + errors: ["Too many requests"], + }); + return res.end(); + }); + }); + + api.post("/contributions", express.json()); + api.post("/contributions", getAuthMiddleware(database)); + api.post("/contributions", async (req, res) => { + res.status(200); + const contributions = postContributionsSchema.parse(req.body); + + const createContributionsPromises: Promise[] = []; + for (const key in contributions) { + if (Object.prototype.hasOwnProperty.call(contributions, key)) { + const value = contributions[key]; + for (const contribution of value) { + const promise = database.temp_ed.create({ + id: randomString(), + key, + value: contribution.value, + user: contribution.user, + isDiscord: contribution.isDiscord, + }); + createContributionsPromises.push(promise); + } + } + } + + await Promise.all(createContributionsPromises); + + res.json(await database.temp_ed.getAllClean()); + + return res.end(); + }); + + api.delete("/contributions", getAuthMiddleware(database)); + api.delete("/contributions", async (req, res) => { + if (typeof req.query.id !== "string") { + res.status(400); + res.json({ + errors: [`Invalid id parameter`], + }); + return res.end(); + } + + await database.temp_ed.delete(req.query.id); + + return res.end(); + }); + + return api; +} diff --git a/src/database/Database.ts b/src/database/Database.ts index e6c2aa7..5eb6549 100644 --- a/src/database/Database.ts +++ b/src/database/Database.ts @@ -3,7 +3,8 @@ import chalk from "chalk"; import knex, { Knex } from "knex"; import { coloredIdentifier, Logger } from "logerian"; import { createKnexLogger } from "../util/Util"; -import { ModManager, RedirectManager, TokenManager, UserManager } from "./DatabaseManager"; +import { EDContributionManager, ModManager, RedirectManager, TokenManager, UserManager } from "./DatabaseManager"; +import { EDContributionSchema } from "./EDContribution"; import { ModSchema } from "./Mod"; import { RedirectSchema } from "./Redirect"; import { TokenSchema } from "./Token"; @@ -13,6 +14,7 @@ import { SQLVersionSchema } from "./Version"; declare module "knex/types/tables" { interface Tables { mods: ModSchema; + temp_ed: EDContributionSchema; redirects: RedirectSchema; tokens: TokenSchema; users: UserSchema; @@ -34,6 +36,7 @@ export class Database { public redirects: RedirectManager = new RedirectManager(this); public tokens: TokenManager = new TokenManager(this); public users: UserManager = new UserManager(this); + public temp_ed: EDContributionManager = new EDContributionManager(this); public constructor(options: DatabaseOptions) { this.options = options; @@ -58,6 +61,7 @@ export class Database { await this.redirects.getAllJSON(); await this.tokens.getAllJSON(); await this.users.getAllJSON(); + await this.temp_ed.getAllJSON(); if ((await this.users.getAll()).length === 0) { this.logger.warn("No users found in database"); @@ -98,7 +102,19 @@ export class Database { this.logger.debug(chalk`Table {green 'tokens'} setup`); }); - await Promise.all([modsPromise, versionsPromise, redirectsPromise, usersPromise, tokensPromise]); + this.logger.debug(chalk`Setting up table {green 'temp_ed'}...`); + const edContributionsPromise = this.setupTempEDTable().then(() => + this.logger.debug(chalk`Table {green 'temp_ed'} setup`) + ); + + await Promise.all([ + modsPromise, + versionsPromise, + redirectsPromise, + usersPromise, + tokensPromise, + edContributionsPromise, + ]); this.logger.info("Database setup complete!"); } @@ -171,4 +187,17 @@ export class Database { }); } } + + private async setupTempEDTable(): Promise { + if (!(await this.sql.schema.hasTable("temp_ed"))) { + this.logger.debug("Table temp_ed doesn't exist, creating..."); + await this.sql.schema.createTable("temp_ed", table => { + table.string("id", 32).index().primary().notNullable(); + table.string("key", 255).notNullable(); + table.string("value", 2047).notNullable(); + table.string("user", 255).notNullable(); + table.boolean("isDiscord").notNullable().defaultTo(false); + }); + } + } } diff --git a/src/database/DatabaseManager.ts b/src/database/DatabaseManager.ts index a32d673..a135993 100644 --- a/src/database/DatabaseManager.ts +++ b/src/database/DatabaseManager.ts @@ -1,6 +1,7 @@ // Copyright (c) 2022 Infernal Studios, All Rights Reserved unless otherwise explicitly stated. import { hashPassword, randomString } from "../util/Util"; import { Database } from "./Database"; +import { EDContribution, EDContributionSchema, EDContributionsJSON } from "./EDContribution"; import { Mod, ModSchema } from "./Mod"; import { Redirect, RedirectSchema } from "./Redirect"; import { Token, TokenSchema } from "./Token"; @@ -140,3 +141,28 @@ export class UserManager extends DatabaseManager { } } } + +export class EDContributionManager extends DatabaseManager { + public constructor(database: Database) { + super(database, "temp_ed", (schema, database) => new EDContribution(schema, database)); + } + + /** @deprecated Use {@link getAllClean} instead */ + public override async getAll(): Promise { + return await super.getAll(); + } + + public async getAllClean(): Promise { + const contributions = await super.getAll(); + const clean: EDContributionsJSON = {}; + for (const contribution of contributions) { + const key = contribution.getKey(); + const c = contribution.toJSON(); + // @ts-expect-error - YES TYPESCRIPT I KNOW THAT IT ISN'T POSSIBLE TO DELETE REQUIRED FIELDS I'M DELETING THEM SO THE'YERE UNDEFINED SO I CAN RETURN THEM SAFELY + delete c.key; + clean[key] ??= []; + clean[key].push(c); + } + return clean; + } +} diff --git a/src/database/EDContribution.ts b/src/database/EDContribution.ts new file mode 100644 index 0000000..0adaa77 --- /dev/null +++ b/src/database/EDContribution.ts @@ -0,0 +1,64 @@ +// Copyright (c) 2022 Infernal Studios, All Rights Reserved unless otherwise explicitly stated. +import { Database } from "./Database"; + +export class EDContribution { + private id: string; + private key: string; + private value: string; + private user: string; + private isDiscord: boolean; + private database: Database; + + constructor(contribution: EDContributionSchema, database: Database) { + this.id = contribution.id; + this.key = contribution.key; + this.value = contribution.value; + this.user = contribution.user; + this.isDiscord = contribution.isDiscord; + this.database = database; + } + + public async delete(): Promise { + return this.database.sql.from("temp_ed").where({ id: this.id }).del(); + } + + public getId(): string { + return this.id; + } + + public getKey(): string { + return this.key; + } + + public getValue(): string { + return this.value; + } + + public getUser(): string { + return this.user; + } + + public getIsDiscord(): boolean { + return this.isDiscord; + } + + public toJSON(): EDContributionSchema { + return { + id: this.id, + key: this.key, + value: this.value, + user: this.user, + isDiscord: this.isDiscord, + }; + } +} + +export interface EDContributionSchema { + id: string; + key: string; + value: string; + user: string; + isDiscord: boolean; +} + +export type EDContributionsJSON = Record[]>;