switched to imports, debugged sessions
This commit is contained in:
+1
-2
@@ -1,4 +1,4 @@
|
|||||||
function corsResolver(req, res, next) {
|
export function corsResolver(req, res, next) {
|
||||||
|
|
||||||
if(1==0) { // allow browser / postman / world
|
if(1==0) { // allow browser / postman / world
|
||||||
// Allow only from Mike & Nike devs
|
// Allow only from Mike & Nike devs
|
||||||
@@ -22,4 +22,3 @@ function corsResolver(req, res, next) {
|
|||||||
next();
|
next();
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = corsResolver;
|
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import argon2 from 'argon2'
|
const argon2 = require('argon2')
|
||||||
|
|
||||||
// --- Hash a password (e.g. at signup) ---
|
// --- Hash a password (e.g. at signup) ---
|
||||||
export async function hashPassword(plainPassword) {
|
export async function hashPassword(plainPassword) {
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
export class Utils {
|
||||||
|
constructor(options){
|
||||||
|
}
|
||||||
|
|
||||||
|
isValidIsoTimestamp(val) {
|
||||||
|
const isoTimestampRegex = /^(-?(?:[1-9][0-9]*)?[0-9]{4})-(1[0-2]|0[1-9])-(3[01]|0[1-9]|[12][0-9])T(2[0-3]|[01][0-9]):([0-5][0-9]):([0-5][0-9]|60)(\.\d+)?(Z|([+-])(0[0-9]|1[0-3]):([0-5][0-9])|(14):00)?$/
|
||||||
|
return(isoTimestampRegex.test(val))
|
||||||
|
}
|
||||||
|
|
||||||
|
isValidMail(val) {
|
||||||
|
const mailRFC5322Regex = /^([^\x00-\x20\x22\x28\x29\x2c\x2e\x3a-\x3c\x3e\x40\x5b-\x5d\x7f-\xff]+|\x22([^\x0d\x22\x5c\x80-\xff]|\x5c[\x00-\x7f])*\x22)(\x2e([^\x00-\x20\x22\x28\x29\x2c\x2e\x3a-\x3c\x3e\x40\x5b-\x5d\x7f-\xff]+|\x22([^\x0d\x22\x5c\x80-\xff]|\x5c[\x00-\x7f])*\x22))*\x40([^\x00-\x20\x22\x28\x29\x2c\x2e\x3a-\x3c\x3e\x40\x5b-\x5d\x7f-\xff]+|\x5b([^\x0d\x5b-\x5d\x80-\xff]|\x5c[\x00-\x7f])*\x5d)(\x2e([^\x00-\x20\x22\x28\x29\x2c\x2e\x3a-\x3c\x3e\x40\x5b-\x5d\x7f-\xff]+|\x5b([^\x0d\x5b-\x5d\x80-\xff]|\x5c[\x00-\x7f])*\x5d))*$/
|
||||||
|
return(mailRFC5322Regex.test(val))
|
||||||
|
}
|
||||||
|
|
||||||
|
isValidUUID(val) {
|
||||||
|
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
||||||
|
return(uuidRegex.test(val))
|
||||||
|
}
|
||||||
|
|
||||||
|
isIterable(obj) {
|
||||||
|
if (obj == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return typeof obj[Symbol.iterator] === "function";
|
||||||
|
}
|
||||||
|
|
||||||
|
FilterMapArray(rows, condition) {
|
||||||
|
if(!rows || (typeof(rows[Symbol.iterator]) != 'function')) return([])
|
||||||
|
let filteredRows = []
|
||||||
|
for(let row of rows) {
|
||||||
|
if(condition(row)) filteredRows.push(row)
|
||||||
|
}
|
||||||
|
return(filteredRows)
|
||||||
|
}
|
||||||
|
|
||||||
|
CheckMapArray(rows, remap, transformers) {
|
||||||
|
if(!rows || (typeof(rows[Symbol.iterator]) != 'function')) return([])
|
||||||
|
let filteredRows = []
|
||||||
|
for(let row of rows) {
|
||||||
|
filteredRows.push(this.CheckMapObject(row, remap, transformers))
|
||||||
|
}
|
||||||
|
return(filteredRows)
|
||||||
|
}
|
||||||
|
|
||||||
|
CheckMapObject(row, remap, transformers) {
|
||||||
|
let filteredRow = {}
|
||||||
|
let tempTransformers = { ...transformers }
|
||||||
|
for(let key of Object.keys(row)){
|
||||||
|
if(Object.keys(remap).indexOf(key)>-1) {
|
||||||
|
if(tempTransformers && tempTransformers[key] && (typeof(tempTransformers[key])=='function')) {
|
||||||
|
this.remap(filteredRow, remap[key], tempTransformers[key](row))
|
||||||
|
delete(tempTransformers[remap[key]])
|
||||||
|
} else this.remap(filteredRow, remap[key], row[key]) //filteredRow[remap[key]] = row[key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// now use remaining transformers for other virtual, non-original keys
|
||||||
|
for(let virtkey of Object.keys(tempTransformers)){
|
||||||
|
filteredRow[virtkey] = tempTransformers[virtkey](row)
|
||||||
|
}
|
||||||
|
|
||||||
|
return(filteredRow)
|
||||||
|
}
|
||||||
|
|
||||||
|
validateMapObject(row, validators, remap){
|
||||||
|
let isok = true
|
||||||
|
let badkeys = []
|
||||||
|
|
||||||
|
for(let key of Object.keys(validators)){
|
||||||
|
if(typeof(validators[key])=='function') {
|
||||||
|
let result = validators[key](row[key], row)
|
||||||
|
if(result!==true) {
|
||||||
|
isok = false
|
||||||
|
badkeys.push(key + ((typeof(result)=='string') ? ' - '+result : '') )
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let remappedRow = {}
|
||||||
|
if(!isok) return([false, {}, badkeys])
|
||||||
|
|
||||||
|
for(let key of Object.keys(row)){
|
||||||
|
if(Object.keys(remap).indexOf(key)>-1){
|
||||||
|
this.remap(remappedRow, remap[key], row[key])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return([true, remappedRow, badkeys])
|
||||||
|
}
|
||||||
|
|
||||||
|
validateMapArray(rows, validators, remap){
|
||||||
|
if(!rows || (typeof(rows[Symbol.iterator]) != 'function')) return([false, [], ['First parameter of validateMapArray is not an array']])
|
||||||
|
let remappedRows = []
|
||||||
|
let allErrors = []
|
||||||
|
let isOk = true
|
||||||
|
for(let row of rows){
|
||||||
|
let isValid,remappedRow, errors
|
||||||
|
[isValid, remappedRow, errors] = this.validateMapObject(row, validators, remap)
|
||||||
|
isOk = isOk && isValid
|
||||||
|
remappedRows.push(remappedRow)
|
||||||
|
allErrors = allErrors.concat(errors)
|
||||||
|
}
|
||||||
|
return([isOk, remappedRows, allErrors])
|
||||||
|
}
|
||||||
|
|
||||||
|
remap(obj, newKey, value){
|
||||||
|
let ref = obj
|
||||||
|
// create sub-struct as needed
|
||||||
|
newKey.split('.').slice(0,-1).forEach( (keyPart) => {
|
||||||
|
if(!ref.hasOwnProperty(keyPart)) ref[keyPart]={}
|
||||||
|
ref = ref[keyPart]
|
||||||
|
})
|
||||||
|
ref[newKey.split('.').slice(-1)[0]] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
+57
-144
@@ -1,29 +1,21 @@
|
|||||||
const mysql = require('mysql2/promise');
|
import { Utils } from './helpers/utils.js'
|
||||||
|
export class P42ApiEndpoints{
|
||||||
class P42ApiEndpoints{
|
constructor(app, db) {
|
||||||
constructor(app) {
|
this.db = db
|
||||||
this.db = null
|
|
||||||
this.app = app
|
this.app = app
|
||||||
this.userinfos = null
|
this.userinfos = null
|
||||||
|
this.utils = new Utils()
|
||||||
this.registerPaths()
|
this.registerPaths()
|
||||||
|
setInterval(() => {
|
||||||
|
this.db.query('SELECT 1');
|
||||||
|
}, 5000);
|
||||||
}
|
}
|
||||||
|
|
||||||
registerPaths(){
|
registerPaths(){
|
||||||
this.app.get('/hw', this.hw.bind(this))
|
this.app.get('/hw', this.hw.bind(this))
|
||||||
}
|
this.app.get('/checkauth', this.checkauth.bind(this))
|
||||||
|
this.app.post('/login', this.login.bind(this))
|
||||||
|
|
||||||
async connectDB(mysqlCreds) {
|
|
||||||
this.db = await mysql.createConnection({
|
|
||||||
host: mysqlCreds.host,
|
|
||||||
port: mysqlCreds.port,
|
|
||||||
socketPath: mysqlCreds.socketPath,
|
|
||||||
database: mysqlCreds.database,
|
|
||||||
user: mysqlCreds.user,
|
|
||||||
password: mysqlCreds.password
|
|
||||||
});
|
|
||||||
setInterval(() => {
|
|
||||||
this.db.query('SELECT 1');
|
|
||||||
}, 5000);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
err(req, res, msg, debug, status=500) {
|
err(req, res, msg, debug, status=500) {
|
||||||
@@ -56,55 +48,7 @@ class P42ApiEndpoints{
|
|||||||
}
|
}
|
||||||
|
|
||||||
getSession(req, res) {
|
getSession(req, res) {
|
||||||
this.userinfos = {
|
|
||||||
"at_hash": "fhaNqJbWprmseino7D7vQhdEIWzlss6a08DvgY_Y7ik",
|
|
||||||
"sub": "steinic",
|
|
||||||
"amr": [
|
|
||||||
"pwd"
|
|
||||||
],
|
|
||||||
"iss": "https://ecas.acceptance.ec.europa.eu/cas/oauth2",
|
|
||||||
|
|
||||||
// Impersonate here
|
|
||||||
"preferred_username": "fallimi", //"steinic",
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
"locale": "en",
|
|
||||||
"https://ecas.ec.europa.eu/claims/domain": "eu.europa.ec",
|
|
||||||
"acr": "https://ecas.ec.europa.eu/loa/basic",
|
|
||||||
"auth_time": 1686415198,
|
|
||||||
"nickname": "steinic",
|
|
||||||
"https://ecas.ec.europa.eu/claims/teleworking_priority": false,
|
|
||||||
"exp": 1686415501,
|
|
||||||
"iat": 1686415201,
|
|
||||||
"email": "Nicolas.STEIN@ext.ec.europa.eu",
|
|
||||||
"https://ecas.ec.europa.eu/claims/employee_number": "90218167",
|
|
||||||
"email_verified": true,
|
|
||||||
"https://ecas.ec.europa.eu/claims/department_number": "EISMEA.C.02.2",
|
|
||||||
"https://ecas.ec.europa.eu/claims/employee_type": "x",
|
|
||||||
"given_name": "Nicolas",
|
|
||||||
"https://ecas.ec.europa.eu/claims/org_id": "232619",
|
|
||||||
"aud": "zjDAOobFg2JJzMxhzfoTyPg1BrOzPzG4EMUJOoqUbF1mYTkwddaZwL4o9YzzK3unIZAEunze7fQAfOoOgXnq9Xhr-NaAc23CqASenqizgfAeUl6",
|
|
||||||
"c_hash": "8pzkBbmGEZW48yLZYoEoR_H3QC0GIeWYxlzUCfRMElg",
|
|
||||||
"https://ecas.ec.europa.eu/claims/sso": false,
|
|
||||||
"https://ecas.ec.europa.eu/claims/authentication_factors": [
|
|
||||||
{
|
|
||||||
"username": "steinic"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"name": "Nicolas STEIN",
|
|
||||||
"https://ecas.ec.europa.eu/claims/uid": "steinic",
|
|
||||||
"family_name": "STEIN",
|
|
||||||
"userRoles": [
|
|
||||||
"BP_PO",
|
|
||||||
"APPLICANT",
|
|
||||||
]
|
|
||||||
}
|
|
||||||
return(true)
|
|
||||||
|
|
||||||
|
|
||||||
if((!req.session.userinfo) || (!req.session.userinfo.isAuthenticated)) {
|
if((!req.session.userinfo) || (!req.session.userinfo.isAuthenticated)) {
|
||||||
this.err(req, res, 'Not authenticated !')
|
|
||||||
this.userinfos = null
|
this.userinfos = null
|
||||||
return(false)
|
return(false)
|
||||||
} else {
|
} else {
|
||||||
@@ -125,87 +69,56 @@ class P42ApiEndpoints{
|
|||||||
return(false)
|
return(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
CheckMapOutput(data, remap, transformers) {
|
|
||||||
if(!data) return(null)
|
|
||||||
let rows = Array.isArray(data) ? data : [data]
|
|
||||||
let filteredRows = []
|
|
||||||
for(let row of rows) {
|
|
||||||
let filteredRow = {}
|
|
||||||
Object.keys(row).forEach((key, index) => {
|
|
||||||
if(Object.keys(remap).indexOf(key)>-1) {
|
|
||||||
if(transformers && transformers[key] && (typeof(transformers[key])=='function')) {
|
|
||||||
filteredRow[remap[key]] = transformers[key](row[key])
|
|
||||||
} else filteredRow[remap[key]] = row[key]
|
|
||||||
}
|
|
||||||
});
|
|
||||||
filteredRows.push(filteredRow)
|
|
||||||
}
|
|
||||||
if(Array.isArray(data)) return(filteredRows)
|
|
||||||
else return(filteredRows[0])
|
|
||||||
}
|
|
||||||
|
|
||||||
CheckMapInput(dataIn, remap, checks) {
|
|
||||||
let dataOut = {}
|
|
||||||
for(let field in checks) {
|
|
||||||
let dbName = checks[field](dataIn[field])
|
|
||||||
if(dbName && (dataIn[field]!=null)) dataOut[remap[field]] = dataIn[field]
|
|
||||||
}
|
|
||||||
return(dataOut)
|
|
||||||
}
|
|
||||||
|
|
||||||
async isMemberOf(pic) {
|
|
||||||
let [rows, fields] = await this.db.query(`
|
|
||||||
SELECT count(*) as cnt FROM organisation_members
|
|
||||||
WHERE (om_pic=?)
|
|
||||||
AND (om_uid=?)
|
|
||||||
`,
|
|
||||||
[pic, this.userinfos.preferred_username]);
|
|
||||||
return(rows[0]['cnt']>0)
|
|
||||||
}
|
|
||||||
|
|
||||||
async isOrgAdminOf(pic) {
|
|
||||||
let [rows, fields] = await this.db.query(`
|
|
||||||
SELECT count(*) as cnt FROM organisation_members
|
|
||||||
WHERE (om_pic=?)
|
|
||||||
AND (om_uid=?)
|
|
||||||
AND om_administrator=1
|
|
||||||
`,
|
|
||||||
[pic, this.userinfos.preferred_username]);
|
|
||||||
return(rows[0]['cnt']>0)
|
|
||||||
}
|
|
||||||
|
|
||||||
async isPropAdminOf(pid) {
|
|
||||||
let [rows, fields] = await this.db.query(`
|
|
||||||
SELECT count(*) as cnt FROM shortprops_members
|
|
||||||
WHERE (spm_prop_id=?)
|
|
||||||
AND (spm_uid=?)
|
|
||||||
AND spm_administrator=1
|
|
||||||
`,
|
|
||||||
[pid, this.userinfos.preferred_username]);
|
|
||||||
return(rows[0]['cnt']>0)
|
|
||||||
}
|
|
||||||
|
|
||||||
async isPropMemberOf(pid) {
|
|
||||||
let [rows, fields] = await this.db.query(`
|
|
||||||
SELECT count(*) as cnt FROM shortprops_members
|
|
||||||
WHERE (spm_prop_id=?)
|
|
||||||
AND (spm_uid=?)
|
|
||||||
`,
|
|
||||||
[pid, this.userinfos.preferred_username]);
|
|
||||||
return(rows[0]['cnt']>0)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async merge(table, where, whereVals, data) {
|
|
||||||
let [rows, field] = await this.db.query(`SELECT * FROM ${table} WHERE ${where}`, whereVals)
|
|
||||||
if(rows.length==0) return(data)
|
|
||||||
else return(Object.assign(rows[0], data))
|
|
||||||
}
|
|
||||||
|
|
||||||
///////////////////////////API starts here.../////////////////////////////
|
///////////////////////////API starts here.../////////////////////////////
|
||||||
async hw(req, res) {
|
async hw(req, res) {
|
||||||
this.ok(req, res, {hello:'world'})
|
this.ok(req, res, {hello:'world'})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async checkauth(req, res) {
|
||||||
|
if(this.getSession(req, res)) {
|
||||||
|
this.ok(req, res, {
|
||||||
|
authenticated: true,
|
||||||
|
userInfos: this.userinfos,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
this.ok(req, res, {
|
||||||
|
authenticated: false,
|
||||||
|
userInfos: null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
async login(req, res) {
|
||||||
|
console.log('====>req.json', req.body)
|
||||||
|
let [isValid, payload, errors] = this.utils.validateMapObject(req.body, {
|
||||||
|
username: ((val, obj) => (typeof(val)=='string') && (val.length>3) ),
|
||||||
|
passwd: ((val, obj) => (typeof(val)=='string') && (val.length>7) ),
|
||||||
|
},{
|
||||||
|
'username': 'username',
|
||||||
|
'passwd': 'passwd',
|
||||||
|
})
|
||||||
|
|
||||||
|
if((!isValid)){
|
||||||
|
this.err(req, res, `Invalid request', 'Invalid login payload:: ${errors}`, 401)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if((payload.username=='toto') && (payload.passwd=='azertyuiop')){
|
||||||
|
req.session.userinfo = {
|
||||||
|
username: payload.username,
|
||||||
|
roles: ['admin']
|
||||||
|
}
|
||||||
|
this.ok(req, res, {
|
||||||
|
authenticated: true,
|
||||||
|
userInfos: req.session.userinfo,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
this.ok(req, res, {
|
||||||
|
authenticated: false,
|
||||||
|
userInfos: null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
module.exports = P42ApiEndpoints;
|
|
||||||
@@ -1,25 +1,46 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
'use strict'
|
'use strict'
|
||||||
const p42apiConfig = require("./p42api.json");
|
import p42apiConfig from './p42api.json' with { type: 'json' }
|
||||||
const http = require('http');
|
import mysql from 'mysql2/promise'
|
||||||
const express = require("express");
|
import http from 'http'
|
||||||
const bodyParser = require('body-parser');
|
import express from 'express'
|
||||||
const session = require('express-session')
|
import session from 'express-session'
|
||||||
const MySQLStore = require('express-mysql-session')(session);
|
import connectMySQL from 'express-mysql-session'
|
||||||
|
import { corsResolver } from './corsMiddleware.js'
|
||||||
|
import { P42ApiEndpoints } from './p42ApiEndpoints.js'
|
||||||
|
|
||||||
const corsResolver = require('./corsMiddleware')
|
const MySQLStore = connectMySQL(session)
|
||||||
const P42ApiEndpoints = require('./p42ApiEndpoints')
|
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.set('trust proxy', 1) // trust first proxy (nginx), so we serve http to nginx, but we still behave as if we're in https
|
||||||
|
app.use(express.json())
|
||||||
|
app.use(corsResolver)
|
||||||
|
|
||||||
|
//TOTO: kick this
|
||||||
const mysqlCreds = {
|
const mysqlCreds = {
|
||||||
// host: '127.0.0.1',
|
// host: '127.0.0.1',
|
||||||
// port: 3306,
|
// port: 3306,
|
||||||
socketPath: '/var/run/mysqld/mysqld.sock',
|
socketPath: '/var/run/mysqld/mysqld.sock',
|
||||||
user: 'p42',
|
user: 'p42',
|
||||||
password: 'C3h=V9!r>Mvc>skxPf9?W2P3duJTk',
|
password: 'C3h=V9!r>Mvc>skxPf9?W2P3duJTk',
|
||||||
database: 'p42'
|
database: 'p42',
|
||||||
|
waitForConnections: true,
|
||||||
|
connectionLimit: 10,
|
||||||
|
queueLimit: 0
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionStore = new MySQLStore({ ...mysqlCreds,
|
|
||||||
|
const db = await mysql.createConnection(mysqlCreds)
|
||||||
|
// {
|
||||||
|
// host: mysqlCreds.host,
|
||||||
|
// port: mysqlCreds.port,
|
||||||
|
// socketPath: mysqlCreds.socketPath,
|
||||||
|
// database: mysqlCreds.database,
|
||||||
|
// user: mysqlCreds.user,
|
||||||
|
// password: mysqlCreds.password
|
||||||
|
// });
|
||||||
|
|
||||||
|
const sessionStore = new MySQLStore({
|
||||||
createDatabaseTable: false,
|
createDatabaseTable: false,
|
||||||
clearExpired: true,
|
clearExpired: true,
|
||||||
schema: {
|
schema: {
|
||||||
@@ -30,15 +51,9 @@ const sessionStore = new MySQLStore({ ...mysqlCreds,
|
|||||||
data: 'data'
|
data: 'data'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
}, db)
|
||||||
|
|
||||||
|
|
||||||
const app = express();
|
|
||||||
app.set('trust proxy', 1) // trust first proxy (nginx), so we serve http to nginx, but we still behave as if we're in https
|
|
||||||
app.use(bodyParser.urlencoded({ extended: false }));
|
|
||||||
app.use(bodyParser.json())
|
|
||||||
app.use(corsResolver);
|
|
||||||
|
|
||||||
app.use(session({
|
app.use(session({
|
||||||
name: 'p42.api.sid',
|
name: 'p42.api.sid',
|
||||||
secret: 'qNhy555Y9vyxj?!3yaYA=aKfgk+Wy5eymNtP*?4i',
|
secret: 'qNhy555Y9vyxj?!3yaYA=aKfgk+Wy5eymNtP*?4i',
|
||||||
@@ -53,8 +68,8 @@ app.use(session({
|
|||||||
}
|
}
|
||||||
))
|
))
|
||||||
|
|
||||||
let eps = new P42ApiEndpoints(app)
|
|
||||||
eps.connectDB(mysqlCreds)
|
let eps = new P42ApiEndpoints(app, db)
|
||||||
|
|
||||||
const server = http.createServer(app)
|
const server = http.createServer(app)
|
||||||
.listen(p42apiConfig.listenPort, p42apiConfig.listenHost, function (req, res) {
|
.listen(p42apiConfig.listenPort, p42apiConfig.listenHost, function (req, res) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"argon2": "^0.44.0",
|
"argon2": "^0.44.0",
|
||||||
"body-parser": "^2.2.0",
|
"body-parser": "^2.2.0",
|
||||||
|
|||||||
Reference in New Issue
Block a user