mirror of
https://github.com/hackclub/nest.git
synced 2025-01-05 02:56:38 +00:00
merge yatcm & nest cli
This commit is contained in:
parent
85458fdf1c
commit
5b551f8a69
7 changed files with 392 additions and 16 deletions
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
node_modules
|
||||||
|
.env
|
||||||
|
*.sqlite
|
2
cli/.whitelist
Normal file
2
cli/.whitelist
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
*.$USERNAME.hackclub.app
|
||||||
|
$USERNAME.hackclub.app
|
95
cli/index.js
95
cli/index.js
|
@ -4,6 +4,13 @@ const program = new Command();
|
||||||
const { execSync, spawn } = require('child_process');
|
const { execSync, spawn } = require('child_process');
|
||||||
const net = require('net');
|
const net = require('net');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
const utils = require("./utils")
|
||||||
|
const { PrismaClient } = require("@prisma/client");
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
const os = require("os")
|
||||||
|
var username = os.userInfo().username
|
||||||
|
const isAdmin = !process.getuid() || os.userInfo().username == "nest-internal"
|
||||||
|
const validator = require('validator');
|
||||||
|
|
||||||
function run(command) {
|
function run(command) {
|
||||||
try {
|
try {
|
||||||
|
@ -29,21 +36,7 @@ program
|
||||||
server.close();
|
server.close();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
program
|
|
||||||
.command('caddy [cmd] [args...]')
|
|
||||||
.description('Manages caddy')
|
|
||||||
.action((cmd, args) => {
|
|
||||||
const yatcmArgs = cmd ? [cmd, ...args] : args;
|
|
||||||
const yatcm = spawn('yatcm', yatcmArgs);
|
|
||||||
|
|
||||||
yatcm.stdout.on('data', (data) => {
|
|
||||||
console.log(data.toString());
|
|
||||||
});
|
|
||||||
|
|
||||||
yatcm.stderr.on('data', (data) => {
|
|
||||||
console.error(data.toString());
|
|
||||||
});
|
|
||||||
});
|
|
||||||
program
|
program
|
||||||
.command('resources')
|
.command('resources')
|
||||||
.description('See your Nest resource usage and limits')
|
.description('See your Nest resource usage and limits')
|
||||||
|
@ -89,5 +82,79 @@ db
|
||||||
run(`sudo -u postgres /usr/local/nest/cli/helpers/create_db.sh ${name}`);
|
run(`sudo -u postgres /usr/local/nest/cli/helpers/create_db.sh ${name}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const caddy = program.command("caddy")
|
||||||
|
caddy
|
||||||
|
.command('list')
|
||||||
|
.description('lists all domains you have configured in caddy')
|
||||||
|
.option('--user', 'allows you to add a domain on behalf of a user (requires sudo)')
|
||||||
|
.action(async (options) => {
|
||||||
|
if (options?.user && isAdmin) username = options.user
|
||||||
|
var domains = await utils.getDomains(username)
|
||||||
|
domains = domains.map(domain => `- ${domain.domain} (${domain.proxy})`).join("\n")
|
||||||
|
console.log(domains)
|
||||||
|
});
|
||||||
|
caddy
|
||||||
|
.command('add <domain>')
|
||||||
|
.description('adds a domain to caddy')
|
||||||
|
.option('--proxy', 'changes where the domain should be proxied to (advanced)')
|
||||||
|
.option('--user', 'allows you to add a domain on behalf of a user (requires sudo)')
|
||||||
|
.action(async (domain, options) => {
|
||||||
|
if (options?.user && isAdmin) username = options.user
|
||||||
|
if (!validator.isFQDN(domain)) {
|
||||||
|
console.error("This domain is not a valid domain name. Please choose a valid domain name.")
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
if (await utils.domainExists(domain)) {
|
||||||
|
console.error("This domain already has already been taken by you or someone else. Pick another one!")
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
if (utils.checkWhitelist(domain, username)) {
|
||||||
|
await prisma.domain.create({
|
||||||
|
data: {
|
||||||
|
domain, username, proxy: options?.proxy || `unix//home/${username}/.${domain}.webserver.sock`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await utils.reloadCaddy()
|
||||||
|
return console.log(`${domain} added. (${options?.proxy || `unix//home/${username}/.${domain}.webserver.sock`})`)
|
||||||
|
|
||||||
|
}
|
||||||
|
// Proceed as a regular domain
|
||||||
|
if (!await utils.checkVerification(domain, username)) {
|
||||||
|
console.error(`Please set the TXT record for domain-verification to your username (${username}). You can remove it after it is added.`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
await prisma.domain.create({
|
||||||
|
data: {
|
||||||
|
domain, username, proxy: options?.proxy || `unix//home/${username}/.${domain}.webserver.sock`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await utils.reloadCaddy()
|
||||||
|
return console.log(`${domain} added. (${options?.proxy || `unix//home/${username}/.${domain}.webserver.sock`})`)
|
||||||
|
});
|
||||||
|
caddy
|
||||||
|
.command('rm <domain>')
|
||||||
|
.description('removes a domain from caddy')
|
||||||
|
.option('--user', 'allows you to add a domain on behalf of a user (requires sudo)')
|
||||||
|
.action(async (domain, options) => {
|
||||||
|
if (options?.user && isAdmin) username = options.user
|
||||||
|
if (!validator.isFQDN(domain)) {
|
||||||
|
console.error("This domain is not a valid domain name. Please choose a valid domain name.")
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
if (!await utils.domainExists(domain)) {
|
||||||
|
console.error("This domain is not in Caddy.")
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
if (!await utils.domainOwnership(domain, username)) {
|
||||||
|
console.error("You do not own the domain, so you cannot remove it.")
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
await prisma.domain.delete({
|
||||||
|
where: {
|
||||||
|
domain, username
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await utils.reloadCaddy()
|
||||||
|
console.log(`${domain} removed.`)
|
||||||
|
});
|
||||||
program.parse(process.argv);
|
program.parse(process.argv);
|
|
@ -7,6 +7,11 @@
|
||||||
"nest": "./index.js"
|
"nest": "./index.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"commander": "^12.1.0"
|
"@prisma/client": "5.18.0",
|
||||||
|
"commander": "12.1.0",
|
||||||
|
"dotenv": "16.4.5",
|
||||||
|
"minimatch": "10.0.1",
|
||||||
|
"prisma": "5.18.0",
|
||||||
|
"validator": "13.12.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
16
cli/prisma/schema.prisma
Normal file
16
cli/prisma/schema.prisma
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
binaryTargets = ["native"]
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "sqlite"
|
||||||
|
url = "file:./database.sqlite"
|
||||||
|
}
|
||||||
|
|
||||||
|
model Domain {
|
||||||
|
domain String @id @unique
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
username String
|
||||||
|
proxy String
|
||||||
|
}
|
206
cli/utils.js
Normal file
206
cli/utils.js
Normal file
|
@ -0,0 +1,206 @@
|
||||||
|
const fs = require("node:fs")
|
||||||
|
const { minimatch } = require('minimatch')
|
||||||
|
const { PrismaClient } = require("@prisma/client");
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
const dns = require('node:dns').promises;
|
||||||
|
const os = require("os")
|
||||||
|
const isAdmin = !process.getuid() || os.userInfo().username == "nest-internal"
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
checkWhitelist: function (domain, username) {
|
||||||
|
if (!fs.existsSync(".whitelist")) return
|
||||||
|
const whitelist = fs.readFileSync(".whitelist", "utf8").split("\n")
|
||||||
|
|
||||||
|
whitelist
|
||||||
|
var i = 0
|
||||||
|
while (i < whitelist.length) {
|
||||||
|
if (minimatch(domain, whitelist[i].replace("$USERNAME", username))) return true
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
async getDomains(username) {
|
||||||
|
const domains = await prisma.domain.findMany({
|
||||||
|
where: {
|
||||||
|
username
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return domains
|
||||||
|
},
|
||||||
|
async domainExists(domain) {
|
||||||
|
const d = await prisma.domain.findFirst({
|
||||||
|
where: {
|
||||||
|
domain
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (!d) return false
|
||||||
|
else return true
|
||||||
|
},
|
||||||
|
async domainOwnership(domain, username) {
|
||||||
|
if (isAdmin) return true // If sudo, skip.
|
||||||
|
const d = await prisma.domain.findFirst({
|
||||||
|
where: {
|
||||||
|
domain,
|
||||||
|
username
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (!d) return false
|
||||||
|
else return true
|
||||||
|
},
|
||||||
|
async checkVerification(domain, username) {
|
||||||
|
if (isAdmin) return true // If sudo, skip.
|
||||||
|
try {
|
||||||
|
const records = await dns.resolveTxt(domain);
|
||||||
|
|
||||||
|
for (const record of records) {
|
||||||
|
for (const entry of record) {
|
||||||
|
if (entry.includes('domain-verification=' + username)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error:', err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async reloadCaddy() {
|
||||||
|
const domains = await prisma.domain.findMany({
|
||||||
|
where: {
|
||||||
|
|
||||||
|
}
|
||||||
|
})
|
||||||
|
var caddy = {
|
||||||
|
apps: {
|
||||||
|
http: {
|
||||||
|
servers: {
|
||||||
|
srv0: {
|
||||||
|
listen: [":443", ":80"],
|
||||||
|
routes: [], // normal routes
|
||||||
|
errors: {
|
||||||
|
routes: [] // error routing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tls: {
|
||||||
|
automation: {
|
||||||
|
policies: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// dn42 certification support
|
||||||
|
|
||||||
|
const dn42Domains = domains.filter(domain => domain.domain.endsWith(".dn42")).map(domain => domain.domain)
|
||||||
|
if (dn42Domains.length > 1) {
|
||||||
|
caddy.apps.tls.automation.policies.push({
|
||||||
|
"subjects": dn42Domains,
|
||||||
|
"issuers": [
|
||||||
|
{
|
||||||
|
"ca": "https://acme.burble.dn42/v1/dn42/acme/directory",
|
||||||
|
"module": "acme"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
domains.forEach(domain => {
|
||||||
|
caddy.apps.http.servers.srv0.routes.push({
|
||||||
|
"match": [
|
||||||
|
{
|
||||||
|
"host": [
|
||||||
|
domain.domain
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"handle": [
|
||||||
|
{
|
||||||
|
"handler": "subroute",
|
||||||
|
"routes": [
|
||||||
|
{
|
||||||
|
"handle": [
|
||||||
|
{
|
||||||
|
"handler": "reverse_proxy",
|
||||||
|
"health_checks": {
|
||||||
|
"active": {
|
||||||
|
"expect_status": 2,
|
||||||
|
"interval": 5000000000,
|
||||||
|
"timeout": 60000000000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"upstreams": [
|
||||||
|
{
|
||||||
|
"dial": domain.proxy
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"terminal": true
|
||||||
|
})
|
||||||
|
|
||||||
|
caddy.apps.http.servers.srv0.errors.routes.push({
|
||||||
|
"match": [
|
||||||
|
{
|
||||||
|
"host": [
|
||||||
|
domain.domain
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"handle": [
|
||||||
|
{
|
||||||
|
"handler": "subroute",
|
||||||
|
"routes": [
|
||||||
|
{
|
||||||
|
"handle": [
|
||||||
|
{
|
||||||
|
"handler": "subroute",
|
||||||
|
"routes": [
|
||||||
|
{
|
||||||
|
"handle": [
|
||||||
|
{
|
||||||
|
"body": "This site is either down or does not exist.\nIf this site really does exist, please make sure your Caddy is running. Try systemctl --user start caddy. It is also possible you have a >\n",
|
||||||
|
"handler": "static_response",
|
||||||
|
"status_code": 502
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"match": [
|
||||||
|
{
|
||||||
|
"expression": "{err.status_code} == 502"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"handle": [
|
||||||
|
{
|
||||||
|
"body": "{err.status_code} | {err.status_text} (on {http.regexp.host.1})",
|
||||||
|
"close": true,
|
||||||
|
"handler": "static_response"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"terminal": true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
await fetch(process.env.CADDY_ADMIN_PATH || 'http://localhost:2019/load', {
|
||||||
|
unix: process.env.CADDY_SOCKET_PATH,
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(caddy)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -2,7 +2,84 @@
|
||||||
# yarn lockfile v1
|
# yarn lockfile v1
|
||||||
|
|
||||||
|
|
||||||
commander@^12.1.0:
|
"@prisma/client@5.18.0":
|
||||||
|
version "5.18.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@prisma/client/-/client-5.18.0.tgz#526e4281a448f214c0ff81d65c39243608c98294"
|
||||||
|
integrity sha512-BWivkLh+af1kqC89zCJYkHsRcyWsM8/JHpsDMM76DjP3ZdEquJhXa4IeX+HkWPnwJ5FanxEJFZZDTWiDs/Kvyw==
|
||||||
|
|
||||||
|
"@prisma/debug@5.18.0":
|
||||||
|
version "5.18.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@prisma/debug/-/debug-5.18.0.tgz#527799e044d2903a35945e61ac2d8916e4b61ead"
|
||||||
|
integrity sha512-f+ZvpTLidSo3LMJxQPVgAxdAjzv5OpzAo/eF8qZqbwvgi2F5cTOI9XCpdRzJYA0iGfajjwjOKKrVq64vkxEfUw==
|
||||||
|
|
||||||
|
"@prisma/engines-version@5.18.0-25.4c784e32044a8a016d99474bd02a3b6123742169":
|
||||||
|
version "5.18.0-25.4c784e32044a8a016d99474bd02a3b6123742169"
|
||||||
|
resolved "https://registry.yarnpkg.com/@prisma/engines-version/-/engines-version-5.18.0-25.4c784e32044a8a016d99474bd02a3b6123742169.tgz#203426ebf4ec4e1acce7da4a59ec8f0df92b29e7"
|
||||||
|
integrity sha512-a/+LpJj8vYU3nmtkg+N3X51ddbt35yYrRe8wqHTJtYQt7l1f8kjIBcCs6sHJvodW/EK5XGvboOiwm47fmNrbgg==
|
||||||
|
|
||||||
|
"@prisma/engines@5.18.0":
|
||||||
|
version "5.18.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@prisma/engines/-/engines-5.18.0.tgz#26ea46e26498be622407cf95663d7fb4c39c895b"
|
||||||
|
integrity sha512-ofmpGLeJ2q2P0wa/XaEgTnX/IsLnvSp/gZts0zjgLNdBhfuj2lowOOPmDcfKljLQUXMvAek3lw5T01kHmCG8rg==
|
||||||
|
dependencies:
|
||||||
|
"@prisma/debug" "5.18.0"
|
||||||
|
"@prisma/engines-version" "5.18.0-25.4c784e32044a8a016d99474bd02a3b6123742169"
|
||||||
|
"@prisma/fetch-engine" "5.18.0"
|
||||||
|
"@prisma/get-platform" "5.18.0"
|
||||||
|
|
||||||
|
"@prisma/fetch-engine@5.18.0":
|
||||||
|
version "5.18.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@prisma/fetch-engine/-/fetch-engine-5.18.0.tgz#5b343e2b36b27e2713901ddd032ddd6932b3d55f"
|
||||||
|
integrity sha512-I/3u0x2n31rGaAuBRx2YK4eB7R/1zCuayo2DGwSpGyrJWsZesrV7QVw7ND0/Suxeo/vLkJ5OwuBqHoCxvTHpOg==
|
||||||
|
dependencies:
|
||||||
|
"@prisma/debug" "5.18.0"
|
||||||
|
"@prisma/engines-version" "5.18.0-25.4c784e32044a8a016d99474bd02a3b6123742169"
|
||||||
|
"@prisma/get-platform" "5.18.0"
|
||||||
|
|
||||||
|
"@prisma/get-platform@5.18.0":
|
||||||
|
version "5.18.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@prisma/get-platform/-/get-platform-5.18.0.tgz#0dc4c82fe9a4971f4519a57cb2dd69d8e0df4b71"
|
||||||
|
integrity sha512-Tk+m7+uhqcKDgnMnFN0lRiH7Ewea0OEsZZs9pqXa7i3+7svS3FSCqDBCaM9x5fmhhkufiG0BtunJVDka+46DlA==
|
||||||
|
dependencies:
|
||||||
|
"@prisma/debug" "5.18.0"
|
||||||
|
|
||||||
|
balanced-match@^1.0.0:
|
||||||
|
version "1.0.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
|
||||||
|
integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
|
||||||
|
|
||||||
|
brace-expansion@^2.0.1:
|
||||||
|
version "2.0.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.1.tgz#1edc459e0f0c548486ecf9fc99f2221364b9a0ae"
|
||||||
|
integrity sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==
|
||||||
|
dependencies:
|
||||||
|
balanced-match "^1.0.0"
|
||||||
|
|
||||||
|
commander@12.1.0:
|
||||||
version "12.1.0"
|
version "12.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/commander/-/commander-12.1.0.tgz#01423b36f501259fdaac4d0e4d60c96c991585d3"
|
resolved "https://registry.yarnpkg.com/commander/-/commander-12.1.0.tgz#01423b36f501259fdaac4d0e4d60c96c991585d3"
|
||||||
integrity sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==
|
integrity sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==
|
||||||
|
|
||||||
|
dotenv@16.4.5:
|
||||||
|
version "16.4.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.4.5.tgz#cdd3b3b604cb327e286b4762e13502f717cb099f"
|
||||||
|
integrity sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==
|
||||||
|
|
||||||
|
minimatch@10.0.1:
|
||||||
|
version "10.0.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.0.1.tgz#ce0521856b453c86e25f2c4c0d03e6ff7ddc440b"
|
||||||
|
integrity sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ==
|
||||||
|
dependencies:
|
||||||
|
brace-expansion "^2.0.1"
|
||||||
|
|
||||||
|
prisma@5.18.0:
|
||||||
|
version "5.18.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/prisma/-/prisma-5.18.0.tgz#5ef69c802a075b7596231ea57003496873610b9e"
|
||||||
|
integrity sha512-+TrSIxZsh64OPOmaSgVPH7ALL9dfU0jceYaMJXsNrTkFHO7/3RANi5K2ZiPB1De9+KDxCWn7jvRq8y8pvk+o9g==
|
||||||
|
dependencies:
|
||||||
|
"@prisma/engines" "5.18.0"
|
||||||
|
|
||||||
|
validator@13.12.0:
|
||||||
|
version "13.12.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/validator/-/validator-13.12.0.tgz#7d78e76ba85504da3fee4fd1922b385914d4b35f"
|
||||||
|
integrity sha512-c1Q0mCiPlgdTVVVIJIrBuxNicYE+t/7oKeI9MWLj3fh/uq2Pxh/3eeWbVZ4OcGW1TUf53At0njHw5SMdA3tmMg==
|
||||||
|
|
Loading…
Reference in a new issue