add basic gatewayt
This commit is contained in:
parent
3bc166bfb0
commit
a9162c245e
11 changed files with 185 additions and 9 deletions
|
@ -11,13 +11,16 @@
|
||||||
"express": "^4.17.3",
|
"express": "^4.17.3",
|
||||||
"express-validator": "^6.14.0",
|
"express-validator": "^6.14.0",
|
||||||
"jsonwebtoken": "^8.5.1",
|
"jsonwebtoken": "^8.5.1",
|
||||||
"pg": "^8.7.3"
|
"pg": "^8.7.3",
|
||||||
|
"ws": "^8.5.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bcrypt": "^5.0.0",
|
"@types/bcrypt": "^5.0.0",
|
||||||
"@types/express": "^4.17.13",
|
"@types/express": "^4.17.13",
|
||||||
"@types/jsonwebtoken": "^8.5.8",
|
"@types/jsonwebtoken": "^8.5.8",
|
||||||
|
"@types/node": "^17.0.23",
|
||||||
"@types/pg": "^8.6.5",
|
"@types/pg": "^8.6.5",
|
||||||
|
"@types/ws": "^8.5.3",
|
||||||
"typescript": "^4.6.3"
|
"typescript": "^4.6.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,3 +6,10 @@ export const errors = {
|
||||||
FORBIDDEN_DUE_TO_MISSING_PERMISSIONS: { code: 6005, message: "Forbidden due to missing permission(s)" },
|
FORBIDDEN_DUE_TO_MISSING_PERMISSIONS: { code: 6005, message: "Forbidden due to missing permission(s)" },
|
||||||
GOT_NO_DATABASE_DATA: { code: 7001, message: "Unexpectedly got no data from database" }
|
GOT_NO_DATABASE_DATA: { code: 7001, message: "Unexpectedly got no data from database" }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const gatewayErrors = {
|
||||||
|
BAD_PAYLOAD: { code: 4001, message: "Bad payload" },
|
||||||
|
BAD_AUTH: { code: 4002, message: "Bad authentication" },
|
||||||
|
AUTHENTICATION_TIMEOUT: { code: 4003, message: "Authentication timeout" },
|
||||||
|
NO_PING: { code: 4004, message: "No ping" },
|
||||||
|
};
|
||||||
|
|
5
src/gateway/gatewaypayloadtype.ts
Normal file
5
src/gateway/gatewaypayloadtype.ts
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
export enum GatewayPayloadType {
|
||||||
|
Hello = 0,
|
||||||
|
Authenticate,
|
||||||
|
Ready
|
||||||
|
}
|
123
src/gateway/index.ts
Normal file
123
src/gateway/index.ts
Normal file
|
@ -0,0 +1,123 @@
|
||||||
|
import { Server } from "node:http";
|
||||||
|
import { performance } from "node:perf_hooks";
|
||||||
|
import WebSocket, { WebSocketServer } from "ws";
|
||||||
|
import { decodeTokenOrNull } from "../auth";
|
||||||
|
import { gatewayErrors } from "../errors";
|
||||||
|
import { GatewayPayload } from "../types/gatewaypayload";
|
||||||
|
import { GatewayPayloadType } from "./gatewaypayloadtype";
|
||||||
|
|
||||||
|
const GATEWAY_BATCH_INTERVAL = 25000 || process.env.GATEWAY_BATCH_INTERVAL;
|
||||||
|
const GATEWAY_PING_INTERVAL = 20000 || process.env.GATEWAY_PING_INTERVAL;
|
||||||
|
|
||||||
|
function closeWithError(ws: WebSocket, { code, message }: { code: number, message: string }) {
|
||||||
|
return ws.close(1000, `(${code}) ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeWithBadPayload(ws: WebSocket, hint: string) {
|
||||||
|
return ws.close(gatewayErrors.BAD_PAYLOAD.code, `${gatewayErrors.BAD_PAYLOAD.message}: ${hint}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonOrNull(payload: string): any {
|
||||||
|
try {
|
||||||
|
return JSON.parse(payload);
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The function below ensures `payload` is of the GatewayPayload
|
||||||
|
// interface payload. If it does not match, null is returned.
|
||||||
|
function ensureFormattedGatewayPayload(payload: any): GatewayPayload | null {
|
||||||
|
if (!payload) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let foundT = false;
|
||||||
|
let foundD = false;
|
||||||
|
for (const [k, v] of Object.entries(payload)) {
|
||||||
|
if (k === "t" && typeof v === "number") {
|
||||||
|
foundT = true;
|
||||||
|
} else if (k === "d") {
|
||||||
|
foundD = true;
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!foundT || !foundD) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const asPayload = payload as GatewayPayload;
|
||||||
|
return asPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendPayload(ws: WebSocket, payload: GatewayPayload) {
|
||||||
|
ws.send(JSON.stringify(payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function(server: Server) {
|
||||||
|
const wss = new WebSocketServer({ server });
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
wss.clients.forEach((e) => {
|
||||||
|
const now = performance.now();
|
||||||
|
if (e.state && (now - e.state.lastAliveCheck) >= GATEWAY_PING_INTERVAL) {
|
||||||
|
if (!e.state.ready) {
|
||||||
|
return closeWithError(e, gatewayErrors.AUTHENTICATION_TIMEOUT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, GATEWAY_BATCH_INTERVAL);
|
||||||
|
|
||||||
|
wss.on("connection", (ws) => {
|
||||||
|
ws.state = {
|
||||||
|
user: undefined,
|
||||||
|
alive: false,
|
||||||
|
ready: false,
|
||||||
|
lastAliveCheck: performance.now()
|
||||||
|
};
|
||||||
|
|
||||||
|
sendPayload(ws, {
|
||||||
|
t: GatewayPayloadType.Hello,
|
||||||
|
d: {
|
||||||
|
pingInterval: GATEWAY_PING_INTERVAL
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on("message", async (rawData, isBinary) => {
|
||||||
|
if (isBinary) {
|
||||||
|
return closeWithBadPayload(ws, "Binary messages are not supported");
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = ensureFormattedGatewayPayload(parseJsonOrNull(rawData.toString()));
|
||||||
|
if (!payload) {
|
||||||
|
return closeWithBadPayload(ws, "Invalid JSON or message does not match schema");
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (payload.t) {
|
||||||
|
case GatewayPayloadType.Authenticate: {
|
||||||
|
const token = payload.d;
|
||||||
|
if (typeof token !== "string") {
|
||||||
|
return closeWithBadPayload(ws, "d: expected string");
|
||||||
|
}
|
||||||
|
const user = await decodeTokenOrNull(token);
|
||||||
|
if (!user) {
|
||||||
|
return closeWithError(ws, gatewayErrors.BAD_AUTH);
|
||||||
|
}
|
||||||
|
ws.state.user = user;
|
||||||
|
ws.state.ready = true;
|
||||||
|
|
||||||
|
sendPayload(ws, {
|
||||||
|
t: GatewayPayloadType.Ready,
|
||||||
|
d: {
|
||||||
|
user: ws.state.user,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
return closeWithBadPayload(ws, "t: unknown type");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
|
@ -1,15 +1,20 @@
|
||||||
import "dotenv/config";
|
import "dotenv/config";
|
||||||
import express from "express";
|
import express from "express";
|
||||||
|
import { createServer } from "node:http";
|
||||||
import databaseInit from "./database/init";
|
import databaseInit from "./database/init";
|
||||||
|
import gateway from "./gateway";
|
||||||
import server from "./server";
|
import server from "./server";
|
||||||
|
|
||||||
const port = process.env.PORT || 3000;
|
const port = process.env.PORT || 3000;
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
server(app);
|
server(app);
|
||||||
|
const httpServer = createServer(app);
|
||||||
|
gateway(httpServer);
|
||||||
|
|
||||||
|
|
||||||
function serve() {
|
function serve() {
|
||||||
app.listen(port, () => console.log(`listening on port ${port}`));
|
httpServer.listen(port, () => console.log(`listening on port ${port}`));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
|
|
|
@ -3,6 +3,8 @@ import usersRouter from "./routes/api/v1/users";
|
||||||
import channelsRouter from "./routes/api/v1/channels";
|
import channelsRouter from "./routes/api/v1/channels";
|
||||||
|
|
||||||
export default function(app: Application) {
|
export default function(app: Application) {
|
||||||
|
app.get("/", (req, res) => res.send("hello!"));
|
||||||
|
|
||||||
app.use(json());
|
app.use(json());
|
||||||
app.use("/api/v1/users", usersRouter);
|
app.use("/api/v1/users", usersRouter);
|
||||||
app.use("/api/v1/channels", channelsRouter);
|
app.use("/api/v1/channels", channelsRouter);
|
||||||
|
|
6
src/types/gatewayclientstate.d.ts
vendored
Normal file
6
src/types/gatewayclientstate.d.ts
vendored
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
interface GatewayClientState {
|
||||||
|
user?: User;
|
||||||
|
ready: boolean,
|
||||||
|
alive: boolean,
|
||||||
|
lastAliveCheck: number,
|
||||||
|
}
|
6
src/types/gatewaypayload.d.ts
vendored
Normal file
6
src/types/gatewaypayload.d.ts
vendored
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
import { GatewayPayloadType } from "../gateway/gatewaypayloadtype";
|
||||||
|
|
||||||
|
declare interface GatewayPayload {
|
||||||
|
t: GatewayPayloadType;
|
||||||
|
d: any;
|
||||||
|
}
|
7
src/types/ws.d.ts
vendored
Normal file
7
src/types/ws.d.ts
vendored
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
import ws from 'ws';
|
||||||
|
|
||||||
|
declare module 'ws' {
|
||||||
|
export interface WebSocket extends ws {
|
||||||
|
state: GatewayClientState;
|
||||||
|
}
|
||||||
|
}
|
12
test.rest
12
test.rest
|
@ -19,13 +19,13 @@ content-type: application/json
|
||||||
###
|
###
|
||||||
|
|
||||||
GET http://localhost:3000/api/v1/users/self HTTP/1.1
|
GET http://localhost:3000/api/v1/users/self HTTP/1.1
|
||||||
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwidHlwZSI6MSwiaWF0IjoxNjQ5MTg3MDc5LCJleHAiOjE2NDkzNTk4Nzl9.tVzJWnBP7IFhA88XRwByKGXQ4cihWdJSoxUkrWHkIVU
|
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MiwidHlwZSI6MSwiaWF0IjoxNjQ5NTI0NDA1LCJleHAiOjE2NDk2OTcyMDV9.4nIDs0K8MCT18GsdlKdicT_GK2KbEqi_P7cND3_aZvE
|
||||||
|
|
||||||
###
|
###
|
||||||
|
|
||||||
POST http://localhost:3000/api/v1/channels HTTP/1.1
|
POST http://localhost:3000/api/v1/channels HTTP/1.1
|
||||||
content-type: application/json
|
content-type: application/json
|
||||||
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwidHlwZSI6MSwiaWF0IjoxNjQ5MTg3MDc5LCJleHAiOjE2NDkzNTk4Nzl9.tVzJWnBP7IFhA88XRwByKGXQ4cihWdJSoxUkrWHkIVU
|
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MiwidHlwZSI6MSwiaWF0IjoxNjQ5NTI0NDA1LCJleHAiOjE2NDk2OTcyMDV9.4nIDs0K8MCT18GsdlKdicT_GK2KbEqi_P7cND3_aZvE
|
||||||
|
|
||||||
{
|
{
|
||||||
"name": "another channel"
|
"name": "another channel"
|
||||||
|
@ -35,7 +35,7 @@ Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwidHlwZSI6M
|
||||||
|
|
||||||
PUT http://localhost:3000/api/v1/channels/2 HTTP/1.1
|
PUT http://localhost:3000/api/v1/channels/2 HTTP/1.1
|
||||||
content-type: application/json
|
content-type: application/json
|
||||||
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwidHlwZSI6MSwiaWF0IjoxNjQ5MTg3MDc5LCJleHAiOjE2NDkzNTk4Nzl9.tVzJWnBP7IFhA88XRwByKGXQ4cihWdJSoxUkrWHkIVU
|
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MiwidHlwZSI6MSwiaWF0IjoxNjQ5NTI0NDA1LCJleHAiOjE2NDk2OTcyMDV9.4nIDs0K8MCT18GsdlKdicT_GK2KbEqi_P7cND3_aZvE
|
||||||
#Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MiwidHlwZSI6MSwiaWF0IjoxNjQ5MjU5NDUwLCJleHAiOjE2NDk0MzIyNTB9.JmF9NujFZnln7A-ynNpeyayGBqmR5poAyACYV6RnSQY
|
#Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MiwidHlwZSI6MSwiaWF0IjoxNjQ5MjU5NDUwLCJleHAiOjE2NDk0MzIyNTB9.JmF9NujFZnln7A-ynNpeyayGBqmR5poAyACYV6RnSQY
|
||||||
|
|
||||||
{
|
{
|
||||||
|
@ -45,15 +45,15 @@ Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwidHlwZSI6M
|
||||||
###
|
###
|
||||||
|
|
||||||
DELETE http://localhost:3000/api/v1/channels/1 HTTP/1.1
|
DELETE http://localhost:3000/api/v1/channels/1 HTTP/1.1
|
||||||
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwidHlwZSI6MSwiaWF0IjoxNjQ5MTg3MDc5LCJleHAiOjE2NDkzNTk4Nzl9.tVzJWnBP7IFhA88XRwByKGXQ4cihWdJSoxUkrWHkIVU
|
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MiwidHlwZSI6MSwiaWF0IjoxNjQ5NTI0NDA1LCJleHAiOjE2NDk2OTcyMDV9.4nIDs0K8MCT18GsdlKdicT_GK2KbEqi_P7cND3_aZvE
|
||||||
#Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MiwidHlwZSI6MSwiaWF0IjoxNjQ5MjU5NDUwLCJleHAiOjE2NDk0MzIyNTB9.JmF9NujFZnln7A-ynNpeyayGBqmR5poAyACYV6RnSQY
|
#Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MiwidHlwZSI6MSwiaWF0IjoxNjQ5MjU5NDUwLCJleHAiOjE2NDk0MzIyNTB9.JmF9NujFZnln7A-ynNpeyayGBqmR5poAyACYV6RnSQY
|
||||||
|
|
||||||
###
|
###
|
||||||
|
|
||||||
GET http://localhost:3000/api/v1/channels/1 HTTP/1.1
|
GET http://localhost:3000/api/v1/channels/1 HTTP/1.1
|
||||||
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwidHlwZSI6MSwiaWF0IjoxNjQ5MTg3MDc5LCJleHAiOjE2NDkzNTk4Nzl9.tVzJWnBP7IFhA88XRwByKGXQ4cihWdJSoxUkrWHkIVU
|
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MiwidHlwZSI6MSwiaWF0IjoxNjQ5NTI0NDA1LCJleHAiOjE2NDk2OTcyMDV9.4nIDs0K8MCT18GsdlKdicT_GK2KbEqi_P7cND3_aZvE
|
||||||
|
|
||||||
###
|
###
|
||||||
|
|
||||||
GET http://localhost:3000/api/v1/channels HTTP/1.1
|
GET http://localhost:3000/api/v1/channels HTTP/1.1
|
||||||
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwidHlwZSI6MSwiaWF0IjoxNjQ5MTg3MDc5LCJleHAiOjE2NDkzNTk4Nzl9.tVzJWnBP7IFhA88XRwByKGXQ4cihWdJSoxUkrWHkIVU
|
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MiwidHlwZSI6MSwiaWF0IjoxNjQ5NTI0NDA1LCJleHAiOjE2NDk2OTcyMDV9.4nIDs0K8MCT18GsdlKdicT_GK2KbEqi_P7cND3_aZvE
|
||||||
|
|
14
yarn.lock
14
yarn.lock
|
@ -70,7 +70,7 @@
|
||||||
resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.2.tgz#93e25bf9ee75fe0fd80b594bc4feb0e862111b5a"
|
resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.2.tgz#93e25bf9ee75fe0fd80b594bc4feb0e862111b5a"
|
||||||
integrity sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==
|
integrity sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==
|
||||||
|
|
||||||
"@types/node@*":
|
"@types/node@*", "@types/node@^17.0.23":
|
||||||
version "17.0.23"
|
version "17.0.23"
|
||||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-17.0.23.tgz#3b41a6e643589ac6442bdbd7a4a3ded62f33f7da"
|
resolved "https://registry.yarnpkg.com/@types/node/-/node-17.0.23.tgz#3b41a6e643589ac6442bdbd7a4a3ded62f33f7da"
|
||||||
integrity sha512-UxDxWn7dl97rKVeVS61vErvw086aCYhDLyvRQZ5Rk65rZKepaFdm53GeqXaKBuOhED4e9uWq34IC3TdSdJJ2Gw==
|
integrity sha512-UxDxWn7dl97rKVeVS61vErvw086aCYhDLyvRQZ5Rk65rZKepaFdm53GeqXaKBuOhED4e9uWq34IC3TdSdJJ2Gw==
|
||||||
|
@ -102,6 +102,13 @@
|
||||||
"@types/mime" "^1"
|
"@types/mime" "^1"
|
||||||
"@types/node" "*"
|
"@types/node" "*"
|
||||||
|
|
||||||
|
"@types/ws@^8.5.3":
|
||||||
|
version "8.5.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.5.3.tgz#7d25a1ffbecd3c4f2d35068d0b283c037003274d"
|
||||||
|
integrity sha512-6YOoWjruKj1uLf3INHH7D3qTXwFfEsg1kf3c0uDdSBJwfa/llkwIjrAGV7j7mVgGNbzTQ3HiHKKDXl6bJPD97w==
|
||||||
|
dependencies:
|
||||||
|
"@types/node" "*"
|
||||||
|
|
||||||
abbrev@1:
|
abbrev@1:
|
||||||
version "1.1.1"
|
version "1.1.1"
|
||||||
resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8"
|
resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8"
|
||||||
|
@ -1010,6 +1017,11 @@ wrappy@1:
|
||||||
resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
|
resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
|
||||||
integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=
|
integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=
|
||||||
|
|
||||||
|
ws@^8.5.0:
|
||||||
|
version "8.5.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/ws/-/ws-8.5.0.tgz#bfb4be96600757fe5382de12c670dab984a1ed4f"
|
||||||
|
integrity sha512-BWX0SWVgLPzYwF8lTzEy1egjhS4S4OEAHfsO8o65WOVsrnSRGaSiUaa9e0ggGlkMTtBlmOpEXiie9RUcBO86qg==
|
||||||
|
|
||||||
xtend@^4.0.0:
|
xtend@^4.0.0:
|
||||||
version "4.0.2"
|
version "4.0.2"
|
||||||
resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54"
|
resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54"
|
||||||
|
|
Loading…
Reference in a new issue