mirror of
https://github.com/monkeytypegame/monkeytype.git
synced 2025-10-14 09:36:00 +08:00
Add public api docs (#2656) by bruce
* Add public documentation * typo * added hard limit on the leaderboards * ignoring worker when compiling * added leaderboard routes * leaderboards accept apekeys * Fix docs * Fix * Specify parse base * Add ape rate limiter * added documentation link * updated message Co-authored-by: Miodec <bartnikjack@gmail.com>
This commit is contained in:
parent
4ebfd98c2c
commit
2dba957adc
11 changed files with 392 additions and 47 deletions
|
@ -7,12 +7,14 @@ class LeaderboardsController {
|
|||
const { language, mode, mode2, skip, limit = 50 } = req.query;
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
|
||||
const queryLimit = Math.min(parseInt(limit as string, 10), 50);
|
||||
|
||||
const leaderboard = await LeaderboardsDAO.get(
|
||||
mode,
|
||||
mode2,
|
||||
language,
|
||||
parseInt(skip as string),
|
||||
parseInt(limit as string)
|
||||
parseInt(skip as string, 10),
|
||||
queryLimit
|
||||
);
|
||||
|
||||
const normalizedLeaderboard = _.map(leaderboard, (entry) => {
|
||||
|
|
|
@ -1,17 +1,16 @@
|
|||
import _ from "lodash";
|
||||
import psas from "./psas";
|
||||
import users from "./users";
|
||||
import quotes from "./quotes";
|
||||
import configs from "./configs";
|
||||
import results from "./results";
|
||||
import presets from "./presets";
|
||||
import psas from "./psas";
|
||||
import leaderboards from "./leaderboards";
|
||||
import quotes from "./quotes";
|
||||
import apeKeys from "./ape-keys";
|
||||
import leaderboards from "./leaderboards";
|
||||
import addSwaggerMiddlewares from "./swagger";
|
||||
import { asyncHandler } from "../../middlewares/api-utils";
|
||||
import { MonkeyResponse } from "../../utils/monkey-response";
|
||||
import { Application, NextFunction, Response, Router } from "express";
|
||||
import swStats from "swagger-stats";
|
||||
import SwaggerSpec from "../../swagger.json";
|
||||
|
||||
const pathOverride = process.env.API_PATH_OVERRIDE;
|
||||
const BASE_ROUTE = pathOverride ? `/${pathOverride}` : "";
|
||||
|
@ -31,34 +30,7 @@ const API_ROUTE_MAP = {
|
|||
function addApiRoutes(app: Application): void {
|
||||
let requestsProcessed = 0;
|
||||
|
||||
app.use(
|
||||
swStats.getMiddleware({
|
||||
name: "Monkeytype API",
|
||||
// hostname: process.env.MODE === "dev" ? "localhost": process.env.STATS_HOSTNAME,
|
||||
// ip: process.env.MODE === "dev" ? "127.0.0.1": process.env.STATS_IP,
|
||||
uriPath: "/stats",
|
||||
authentication: process.env.MODE !== "dev",
|
||||
apdexThreshold: 100,
|
||||
swaggerSpec: SwaggerSpec,
|
||||
onAuthenticate: (_req, username, password) => {
|
||||
return (
|
||||
username === process.env.STATS_USERNAME &&
|
||||
password === process.env.STATS_PASSWORD
|
||||
);
|
||||
},
|
||||
onResponseFinish: (_req, res, rrr) => {
|
||||
//@ts-ignore ignored because monkeyMessage doesnt exist on the type
|
||||
rrr.http.response.message = res.monkeyMessage;
|
||||
if (process.env.MODE === "dev") {
|
||||
return;
|
||||
}
|
||||
const authHeader = rrr.http.request.headers?.authorization ?? "None";
|
||||
const authType = authHeader.split(" ");
|
||||
_.set(rrr.http.request, "headers.authorization", authType[0]);
|
||||
_.set(rrr.http.request, "headers['x-forwarded-for']", "");
|
||||
},
|
||||
})
|
||||
);
|
||||
addSwaggerMiddlewares(app);
|
||||
|
||||
app.use(
|
||||
(req: MonkeyTypes.Request, res: Response, next: NextFunction): void => {
|
||||
|
@ -85,7 +57,7 @@ function addApiRoutes(app: Application): void {
|
|||
})
|
||||
);
|
||||
|
||||
app.get("/psa", (req, res) => {
|
||||
app.get("/psa", (_req, res) => {
|
||||
res.json([
|
||||
{
|
||||
message:
|
||||
|
|
|
@ -1,23 +1,24 @@
|
|||
import joi from "joi";
|
||||
import { Router } from "express";
|
||||
import * as RateLimit from "../../middlewares/rate-limit";
|
||||
import apeRateLimit from "../../middlewares/ape-rate-limit";
|
||||
import { authenticateRequest } from "../../middlewares/auth";
|
||||
import LeaderboardsController from "../controllers/leaderboards";
|
||||
import * as RateLimit from "../../middlewares/rate-limit";
|
||||
import { asyncHandler, validateRequest } from "../../middlewares/api-utils";
|
||||
import { Router } from "express";
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get(
|
||||
"/",
|
||||
RateLimit.leaderboardsGet,
|
||||
authenticateRequest({ isPublic: true }),
|
||||
authenticateRequest({ isPublic: true, acceptApeKeys: true }),
|
||||
validateRequest({
|
||||
query: {
|
||||
language: joi.string().required(),
|
||||
mode: joi.string().required(),
|
||||
mode2: joi.string().required(),
|
||||
skip: joi.number().min(0).required(),
|
||||
limit: joi.number(),
|
||||
skip: joi.number().min(0),
|
||||
limit: joi.number().min(0).max(50),
|
||||
},
|
||||
validationErrorMessage: "Missing parameters",
|
||||
}),
|
||||
|
@ -27,7 +28,8 @@ router.get(
|
|||
router.get(
|
||||
"/rank",
|
||||
RateLimit.leaderboardsGet,
|
||||
authenticateRequest(),
|
||||
authenticateRequest({ acceptApeKeys: true }),
|
||||
apeRateLimit,
|
||||
validateRequest({
|
||||
query: {
|
||||
language: joi.string().required(),
|
||||
|
|
48
backend/api/routes/swagger.ts
Normal file
48
backend/api/routes/swagger.ts
Normal file
|
@ -0,0 +1,48 @@
|
|||
import _ from "lodash";
|
||||
import { Application } from "express";
|
||||
import swaggerStats from "swagger-stats";
|
||||
import swaggerUi from "swagger-ui-express";
|
||||
import publicSwaggerSpec from "../../documentation/public-swagger.json";
|
||||
import internalSwaggerSpec from "../../documentation/internal-swagger.json";
|
||||
|
||||
const SWAGGER_UI_OPTIONS = {
|
||||
customCss: ".swagger-ui .topbar { display: none }",
|
||||
customSiteTitle: "Monkeytype API Documentation",
|
||||
};
|
||||
|
||||
function addSwaggerMiddlewares(app: Application): void {
|
||||
app.use(
|
||||
swaggerStats.getMiddleware({
|
||||
name: "Monkeytype API",
|
||||
uriPath: "/stats",
|
||||
authentication: process.env.MODE !== "dev",
|
||||
apdexThreshold: 100,
|
||||
swaggerSpec: internalSwaggerSpec,
|
||||
onAuthenticate: (_req, username, password) => {
|
||||
return (
|
||||
username === process.env.STATS_USERNAME &&
|
||||
password === process.env.STATS_PASSWORD
|
||||
);
|
||||
},
|
||||
onResponseFinish: (_req, res, rrr) => {
|
||||
//@ts-ignore ignored because monkeyMessage doesnt exist in response
|
||||
rrr.http.response.message = res.monkeyMessage;
|
||||
if (process.env.MODE === "dev") {
|
||||
return;
|
||||
}
|
||||
const authHeader = rrr.http.request.headers?.authorization ?? "None";
|
||||
const authType = authHeader.split(" ");
|
||||
_.set(rrr.http.request, "headers.authorization", authType[0]);
|
||||
_.set(rrr.http.request, "headers['x-forwarded-for']", "");
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
app.use(
|
||||
"/documentation",
|
||||
swaggerUi.serve,
|
||||
swaggerUi.setup(publicSwaggerSpec, SWAGGER_UI_OPTIONS)
|
||||
);
|
||||
}
|
||||
|
||||
export default addSwaggerMiddlewares;
|
|
@ -4,7 +4,7 @@ import { Router } from "express";
|
|||
import UserController from "../controllers/user";
|
||||
import { asyncHandler, validateRequest } from "../../middlewares/api-utils";
|
||||
import * as RateLimit from "../../middlewares/rate-limit";
|
||||
import ApeRateLimit from "../../middlewares/ape-rate-limit";
|
||||
import apeRateLimit from "../../middlewares/ape-rate-limit";
|
||||
import { isUsernameValid } from "../../utils/validation";
|
||||
|
||||
const router = Router();
|
||||
|
@ -207,7 +207,7 @@ router.get(
|
|||
authenticateRequest({
|
||||
acceptApeKeys: true,
|
||||
}),
|
||||
ApeRateLimit,
|
||||
apeRateLimit,
|
||||
validateRequest({
|
||||
query: {
|
||||
mode: joi.string().required(),
|
||||
|
|
257
backend/documentation/public-swagger.json
Normal file
257
backend/documentation/public-swagger.json
Normal file
|
@ -0,0 +1,257 @@
|
|||
{
|
||||
"swagger": "2.0",
|
||||
"info": {
|
||||
"description": "Documentation for the public endpoints provided by the Monketype API server.\n\nNote that authentication is performed with the Authorization HTTP header in the format `Authorization: ApeKey YOUR_APE_KEY` ",
|
||||
"version": "1.0.0",
|
||||
"title": "Monkeytype API",
|
||||
"termsOfService": "https://monkeytype.com/terms-of-service",
|
||||
"contact": {
|
||||
"name": "Developer",
|
||||
"email": "jack@monkeytype.com"
|
||||
}
|
||||
},
|
||||
"basePath": "/",
|
||||
"consumes": ["application/json"],
|
||||
"produces": ["application/json"],
|
||||
"tags": [
|
||||
{
|
||||
"name": "users",
|
||||
"description": "User data and related operations"
|
||||
},
|
||||
{
|
||||
"name": "leaderboards",
|
||||
"description": "Leaderboard data and related operations"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"/users/personalBests": {
|
||||
"get": {
|
||||
"tags": ["users"],
|
||||
"summary": "Gets a user's personal best data",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "mode",
|
||||
"in": "query",
|
||||
"description": "The primary mode (i.e., time)",
|
||||
"required": true,
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"name": "mode2",
|
||||
"in": "query",
|
||||
"description": "The secondary mode (i.e., 60)",
|
||||
"required": false,
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/PersonalBest"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/leaderboards": {
|
||||
"get": {
|
||||
"tags": ["leaderboards"],
|
||||
"summary": "Gets global leaderboard data",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "language",
|
||||
"in": "query",
|
||||
"description": "The leaderboard's language (i.e., english)",
|
||||
"required": true,
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"name": "mode",
|
||||
"in": "query",
|
||||
"description": "The primary mode (i.e., time)",
|
||||
"required": true,
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"name": "mode2",
|
||||
"in": "query",
|
||||
"description": "The secondary mode (i.e., 60)",
|
||||
"required": true,
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"name": "skip",
|
||||
"in": "query",
|
||||
"description": "How many leaderboard entries to skip",
|
||||
"required": false,
|
||||
"type": "number",
|
||||
"minimum": 0
|
||||
},
|
||||
{
|
||||
"name": "limit",
|
||||
"in": "query",
|
||||
"description": "How many leaderboard entries to request",
|
||||
"required": false,
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 50
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/LeaderboardEntry"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/leaderboards/rank": {
|
||||
"get": {
|
||||
"tags": ["leaderboards"],
|
||||
"summary": "Gets your qualifying rank from a leaderboard",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "language",
|
||||
"in": "query",
|
||||
"description": "The leaderboard's language (i.e., english)",
|
||||
"required": true,
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"name": "mode",
|
||||
"in": "query",
|
||||
"description": "The primary mode (i.e., time)",
|
||||
"required": true,
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"name": "mode2",
|
||||
"in": "query",
|
||||
"description": "The secondary mode (i.e., 60)",
|
||||
"required": true,
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/LeaderboardEntry"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
"Response": {
|
||||
"type": "object",
|
||||
"required": ["message", "data"],
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"data": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"PersonalBest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"acc": {
|
||||
"type": "number",
|
||||
"format": "double",
|
||||
"example": 94.44
|
||||
},
|
||||
"consistency": {
|
||||
"type": "number",
|
||||
"format": "double",
|
||||
"example": 75.98
|
||||
},
|
||||
"difficulty": {
|
||||
"type": "string",
|
||||
"example": "normal"
|
||||
},
|
||||
"lazyMode": {
|
||||
"type": "boolean",
|
||||
"example": false
|
||||
},
|
||||
"language": {
|
||||
"type": "string",
|
||||
"example": "english"
|
||||
},
|
||||
"punctuation": {
|
||||
"type": "boolean",
|
||||
"example": false
|
||||
},
|
||||
"raw": {
|
||||
"type": "number",
|
||||
"format": "double",
|
||||
"example": 116.6
|
||||
},
|
||||
"wpm": {
|
||||
"type": "number",
|
||||
"format": "double",
|
||||
"example": 107.6
|
||||
},
|
||||
"timestamp": {
|
||||
"type": "integer",
|
||||
"example": 1644438189583
|
||||
}
|
||||
}
|
||||
},
|
||||
"LeaderboardEntry": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"_id": {
|
||||
"type": "string",
|
||||
"example": "6226b17aebc27a4a8d1ce04b"
|
||||
},
|
||||
"acc": {
|
||||
"type": "number",
|
||||
"format": "double",
|
||||
"example": 97.96
|
||||
},
|
||||
"consistency": {
|
||||
"type": "number",
|
||||
"format": "double",
|
||||
"example": 83.29
|
||||
},
|
||||
"lazyMode": {
|
||||
"type": "boolean",
|
||||
"example": false
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"example": "Miodec"
|
||||
},
|
||||
"punctuation": {
|
||||
"type": "boolean",
|
||||
"example": false
|
||||
},
|
||||
"rank": {
|
||||
"type": "integer",
|
||||
"example": 3506
|
||||
},
|
||||
"raw": {
|
||||
"type": "number",
|
||||
"format": "double",
|
||||
"example": 145.18
|
||||
},
|
||||
"wpm": {
|
||||
"type": "number",
|
||||
"format": "double",
|
||||
"example": 141.18
|
||||
},
|
||||
"timestamp": {
|
||||
"type": "integer",
|
||||
"example": 1644438189583
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
54
backend/package-lock.json
generated
54
backend/package-lock.json
generated
|
@ -28,6 +28,7 @@
|
|||
"simple-git": "2.45.1",
|
||||
"string-similarity": "4.0.4",
|
||||
"swagger-stats": "0.99.2",
|
||||
"swagger-ui-express": "4.3.0",
|
||||
"ua-parser-js": "0.7.28",
|
||||
"uuid": "8.3.2"
|
||||
},
|
||||
|
@ -39,6 +40,7 @@
|
|||
"@types/node": "17.0.18",
|
||||
"@types/node-fetch": "2.6.1",
|
||||
"@types/swagger-stats": "0.95.4",
|
||||
"@types/swagger-ui-express": "4.1.3",
|
||||
"@types/ua-parser-js": "0.7.36",
|
||||
"@types/uuid": "8.3.4"
|
||||
},
|
||||
|
@ -796,6 +798,16 @@
|
|||
"prom-client": ">=11.5.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/swagger-ui-express": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/swagger-ui-express/-/swagger-ui-express-4.1.3.tgz",
|
||||
"integrity": "sha512-jqCjGU/tGEaqIplPy3WyQg+Nrp6y80DCFnDEAvVKWkJyv0VivSSDCChkppHRHAablvInZe6pijDFMnavtN0vqA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/express": "*",
|
||||
"@types/serve-static": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/ua-parser-js": {
|
||||
"version": "0.7.36",
|
||||
"resolved": "https://registry.npmjs.org/@types/ua-parser-js/-/ua-parser-js-0.7.36.tgz",
|
||||
|
@ -4474,6 +4486,25 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/swagger-ui-dist": {
|
||||
"version": "4.6.1",
|
||||
"resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-4.6.1.tgz",
|
||||
"integrity": "sha512-GyP8Hx9qGs7cN6gIK5rTG/NX9CmDDHjq1wzIYlRyJVWZir/D5xarkAroZDYTf4j13ontCQSUZ4Jw83XQoVbB+g=="
|
||||
},
|
||||
"node_modules/swagger-ui-express": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-4.3.0.tgz",
|
||||
"integrity": "sha512-jN46SEEe9EoXa3ZgZoKgnSF6z0w3tnM1yqhO4Y+Q4iZVc8JOQB960EZpIAz6rNROrDApVDwcMHR0mhlnc/5Omw==",
|
||||
"dependencies": {
|
||||
"swagger-ui-dist": ">=4.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= v0.10.32"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"express": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tar": {
|
||||
"version": "6.1.11",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-6.1.11.tgz",
|
||||
|
@ -5606,6 +5637,16 @@
|
|||
"prom-client": ">=11.5.3"
|
||||
}
|
||||
},
|
||||
"@types/swagger-ui-express": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/swagger-ui-express/-/swagger-ui-express-4.1.3.tgz",
|
||||
"integrity": "sha512-jqCjGU/tGEaqIplPy3WyQg+Nrp6y80DCFnDEAvVKWkJyv0VivSSDCChkppHRHAablvInZe6pijDFMnavtN0vqA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/express": "*",
|
||||
"@types/serve-static": "*"
|
||||
}
|
||||
},
|
||||
"@types/ua-parser-js": {
|
||||
"version": "0.7.36",
|
||||
"resolved": "https://registry.npmjs.org/@types/ua-parser-js/-/ua-parser-js-0.7.36.tgz",
|
||||
|
@ -8449,6 +8490,19 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"swagger-ui-dist": {
|
||||
"version": "4.6.1",
|
||||
"resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-4.6.1.tgz",
|
||||
"integrity": "sha512-GyP8Hx9qGs7cN6gIK5rTG/NX9CmDDHjq1wzIYlRyJVWZir/D5xarkAroZDYTf4j13ontCQSUZ4Jw83XQoVbB+g=="
|
||||
},
|
||||
"swagger-ui-express": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-4.3.0.tgz",
|
||||
"integrity": "sha512-jN46SEEe9EoXa3ZgZoKgnSF6z0w3tnM1yqhO4Y+Q4iZVc8JOQB960EZpIAz6rNROrDApVDwcMHR0mhlnc/5Omw==",
|
||||
"requires": {
|
||||
"swagger-ui-dist": ">=4.1.3"
|
||||
}
|
||||
},
|
||||
"tar": {
|
||||
"version": "6.1.11",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-6.1.11.tgz",
|
||||
|
|
|
@ -35,6 +35,7 @@
|
|||
"simple-git": "2.45.1",
|
||||
"string-similarity": "4.0.4",
|
||||
"swagger-stats": "0.99.2",
|
||||
"swagger-ui-express": "4.3.0",
|
||||
"ua-parser-js": "0.7.28",
|
||||
"uuid": "8.3.2"
|
||||
},
|
||||
|
@ -46,6 +47,7 @@
|
|||
"@types/node": "17.0.18",
|
||||
"@types/node-fetch": "2.6.1",
|
||||
"@types/swagger-stats": "0.95.4",
|
||||
"@types/swagger-ui-express": "4.1.3",
|
||||
"@types/ua-parser-js": "0.7.36",
|
||||
"@types/uuid": "8.3.4"
|
||||
}
|
||||
|
|
|
@ -13,5 +13,5 @@
|
|||
"esModuleInterop": true,
|
||||
"strictNullChecks": true
|
||||
},
|
||||
"exclude": ["node_modules", "build"]
|
||||
"exclude": ["node_modules", "build", "worker.js"]
|
||||
}
|
||||
|
|
|
@ -4159,10 +4159,18 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- <div class="section apeKeys needsAccount">
|
||||
<div class="section apeKeys needsAccount">
|
||||
<h1>ape keys</h1>
|
||||
<div class="text">
|
||||
Generate Ape Keys to access certain API endpoints.
|
||||
Documentation is available
|
||||
<a
|
||||
href="https://api.monkeytype.com/documentation"
|
||||
target="_blank"
|
||||
>
|
||||
here.
|
||||
</a>
|
||||
More endpoints will be added in the future.
|
||||
</div>
|
||||
<div class="buttons">
|
||||
<div
|
||||
|
@ -4174,7 +4182,7 @@
|
|||
open
|
||||
</div>
|
||||
</div>
|
||||
</div> -->
|
||||
</div>
|
||||
<div class="section resetSettings">
|
||||
<h1>reset settings</h1>
|
||||
<div class="text">
|
||||
|
|
Loading…
Add table
Reference in a new issue