mirror of
https://github.com/computernewb/collab-vm-1.2-webapp.git
synced 2025-01-22 10:52:05 -05:00
Add support for more CAPTCHA providers
This commit is contained in:
parent
576bdfb13c
commit
92a1d492c1
6 changed files with 185 additions and 12 deletions
|
@ -26,7 +26,9 @@
|
|||
"devDependencies": {
|
||||
"@hcaptcha/types": "^1.0.3",
|
||||
"@types/bootstrap": "^5.2.10",
|
||||
"@types/cloudflare-turnstile": "^0.2.2",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/grecaptcha": "^3.0.9",
|
||||
"@types/jest": "^29.5.12",
|
||||
"buffer": "^5.5.0||^6.0.0",
|
||||
"jest": "^29.7.0",
|
||||
|
|
|
@ -10,7 +10,7 @@
|
|||
<script src="../../node_modules/@fortawesome/fontawesome-free/js/all.min.js" crossorigin="anonymous"></script>
|
||||
<link rel="icon" href="../assets/favicon.ico">
|
||||
<meta name="description" content="A website that lets you take turns controlling online virtual machines with complete strangers!"/>
|
||||
<!-- Opengraph shit -->
|
||||
<!-- Opengraph -->
|
||||
<meta property="og:type" content="website"/>
|
||||
<meta property="og:title" content="CollabVM"/>
|
||||
<meta property="og:url" content="https://computernewb.com/collab-vm/"/>
|
||||
|
@ -97,6 +97,8 @@
|
|||
<label for="accountLoginPassword" id="accountLoginPasswordLabel"></label><br/>
|
||||
<input id="accountLoginPassword" type="password" class="form-control" name="password" required><br>
|
||||
<div id="accountLoginCaptcha"></div>
|
||||
<div id="accountLoginReCaptcha"></div>
|
||||
<div id="accountLoginTurnstile"></div>
|
||||
<button type="submit" class="btn btn-primary" id="accountModalLoginBtn"></button> <button type="button" class="btn btn-secondary" id="accountForgotPasswordButton"></button>
|
||||
</form>
|
||||
</div>
|
||||
|
@ -113,6 +115,8 @@
|
|||
<label for="accountRegisterDateOfBirth" id="accountRegisterDateOfBirthLabel"></label><br/>
|
||||
<input id="accountRegisterDateOfBirth" type="date" class="form-control" name="dateofbirth" required><br/>
|
||||
<div id="accountRegisterCaptcha"></div>
|
||||
<div id="accountRegisterReCaptcha"></div>
|
||||
<div id="accountRegisterTurnstile"></div>
|
||||
<button type="submit" class="btn btn-primary" id="accountModalRegisterBtn"></button>
|
||||
</form>
|
||||
</div>
|
||||
|
@ -152,6 +156,8 @@
|
|||
<label for="accountResetPasswordUsername" id="accountResetPasswordUsernameLabel"></label>
|
||||
<input id="accountResetPasswordUsername" type="text" class="form-control" name="username" required/><br/>
|
||||
<div id="accountResetPasswordCaptcha"></div>
|
||||
<div id="accountResetPasswordReCaptcha"></div>
|
||||
<div id="accountResetPasswordTurnstile"></div>
|
||||
<button type="submit" class="btn btn-primary" id="accountResetPasswordBtn"></button>
|
||||
</div>
|
||||
<div id="accountResetPasswordVerifySection">
|
||||
|
@ -306,7 +312,9 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="https://js.hcaptcha.com/1/api.js"></script>
|
||||
<script src="https://js.hcaptcha.com/1/api.js?render=explicit&recaptchacompat=off"></script>
|
||||
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"></script>
|
||||
<script src="https://www.google.com/recaptcha/api.js?render=explicit"></script>
|
||||
<script type="module" src="../ts/main.ts" type="application/javascript"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
@ -18,10 +18,12 @@ export default class AuthManager {
|
|||
})
|
||||
}
|
||||
|
||||
login(username : string, password : string, captchaToken : string | undefined) : Promise<AccountLoginResult> {
|
||||
login(username : string, password : string, captchaToken : string | undefined, turnstileToken : string | undefined, recaptchaToken : string | undefined) : Promise<AccountLoginResult> {
|
||||
return new Promise(async (res,rej) => {
|
||||
if (!this.info) throw new Error("Cannot login before fetching API information.");
|
||||
if (!captchaToken && this.info.hcaptcha.required) throw new Error("This API requires a valid hCaptcha token.");
|
||||
if (!turnstileToken && this.info.turnstile.required) throw new Error("This API requires a valid Turnstile token.");
|
||||
if (!recaptchaToken && this.info.recaptcha.required) throw new Error("This API requires a valid reCAPTCHA token.");
|
||||
var data = await fetch(this.apiEndpoint + "/api/v1/login", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
|
@ -30,7 +32,9 @@ export default class AuthManager {
|
|||
body: JSON.stringify({
|
||||
username: username,
|
||||
password: password,
|
||||
captchaToken: captchaToken
|
||||
captchaToken: captchaToken,
|
||||
turnstileToken: turnstileToken,
|
||||
recaptchaToken: recaptchaToken
|
||||
})
|
||||
});
|
||||
var json = await data.json() as AccountLoginResult;
|
||||
|
@ -69,10 +73,12 @@ export default class AuthManager {
|
|||
})
|
||||
}
|
||||
|
||||
register(username : string, password : string, email : string, dateOfBirth : dayjs.Dayjs, captchaToken : string | undefined) : Promise<AccountRegisterResult> {
|
||||
register(username : string, password : string, email : string, dateOfBirth : dayjs.Dayjs, captchaToken : string | undefined, turnstileToken: string | undefined, recaptchaToken : string | undefined) : Promise<AccountRegisterResult> {
|
||||
return new Promise(async (res, rej) => {
|
||||
if (!this.info) throw new Error("Cannot login before fetching API information.");
|
||||
if (!captchaToken && this.info.hcaptcha.required) throw new Error("This API requires a valid hCaptcha token.");
|
||||
if (!turnstileToken && this.info.turnstile.required) throw new Error("This API requires a valid Turnstile token.");
|
||||
if (!recaptchaToken && this.info.recaptcha.required) throw new Error("This API requires a valid reCAPTCHA token.");
|
||||
var data = await fetch(this.apiEndpoint + "/api/v1/register", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
|
@ -83,7 +89,9 @@ export default class AuthManager {
|
|||
password: password,
|
||||
email: email,
|
||||
dateOfBirth: dateOfBirth.format("YYYY-MM-DD"),
|
||||
captchatoken: captchaToken
|
||||
captchatoken: captchaToken,
|
||||
turnstiletoken: turnstileToken,
|
||||
recaptchaToken: recaptchaToken
|
||||
})
|
||||
});
|
||||
res(await data.json() as AccountRegisterResult);
|
||||
|
@ -154,10 +162,12 @@ export default class AuthManager {
|
|||
});
|
||||
}
|
||||
|
||||
sendPasswordResetEmail(username : string, email : string, captchaToken : string | undefined) {
|
||||
sendPasswordResetEmail(username : string, email : string, captchaToken : string | undefined, turnstileToken : string | undefined, recaptchaToken : string | undefined) {
|
||||
return new Promise<PasswordResetResult>(async res => {
|
||||
if (!this.info) throw new Error("Cannot send password reset email without fetching API information.");
|
||||
if (!captchaToken && this.info.hcaptcha.required) throw new Error("This API requires a valid hCaptcha token.");
|
||||
if (!turnstileToken && this.info.turnstile.required) throw new Error("This API requires a valid Turnstile token.");
|
||||
if (!recaptchaToken && this.info.recaptcha.required) throw new Error("This API requires a valid reCAPTCHA token.");
|
||||
var data = await fetch(this.apiEndpoint + "/api/v1/sendreset", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
|
@ -166,7 +176,9 @@ export default class AuthManager {
|
|||
body: JSON.stringify({
|
||||
username: username,
|
||||
email: email,
|
||||
captchaToken: captchaToken
|
||||
captchaToken: captchaToken,
|
||||
turnstileToken: turnstileToken,
|
||||
recaptchaToken: recaptchaToken
|
||||
})
|
||||
});
|
||||
res(await data.json() as PasswordResetResult);
|
||||
|
@ -198,6 +210,14 @@ export interface AuthServerInformation {
|
|||
required : boolean;
|
||||
siteKey : string | undefined;
|
||||
};
|
||||
turnstile : {
|
||||
required : boolean;
|
||||
siteKey : string | undefined;
|
||||
};
|
||||
recaptcha : {
|
||||
required : boolean;
|
||||
siteKey : string | undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export interface AccountRegisterResult {
|
||||
|
|
138
src/ts/main.ts
138
src/ts/main.ts
|
@ -97,7 +97,11 @@ const elements = {
|
|||
accountRegisterForm: document.getElementById("accountRegisterForm") as HTMLFormElement,
|
||||
accountVerifyEmailForm: document.getElementById("accountVerifyEmailForm") as HTMLFormElement,
|
||||
accountLoginCaptcha: document.getElementById("accountLoginCaptcha") as HTMLDivElement,
|
||||
accountLoginRecaptcha: document.getElementById("accountLoginReCaptcha") as HTMLDivElement,
|
||||
accountLoginTurnstile: document.getElementById("accountLoginTurnstile") as HTMLDivElement,
|
||||
accountRegisterCaptcha: document.getElementById("accountRegisterCaptcha") as HTMLDivElement,
|
||||
accountRegisterRecaptcha: document.getElementById("accountRegisterReCaptcha") as HTMLDivElement,
|
||||
accountRegisterTurnstile: document.getElementById("accountRegisterTurnstile") as HTMLDivElement,
|
||||
|
||||
accountLoginUsername: document.getElementById("accountLoginUsername") as HTMLInputElement,
|
||||
accountLoginPassword: document.getElementById("accountLoginPassword") as HTMLInputElement,
|
||||
|
@ -123,6 +127,8 @@ const elements = {
|
|||
accountResetPasswordEmail: document.getElementById("accountResetPasswordEmail") as HTMLInputElement,
|
||||
accountResetPasswordUsername: document.getElementById("accountResetPasswordUsername") as HTMLInputElement,
|
||||
accountResetPasswordCaptcha: document.getElementById("accountResetPasswordCaptcha") as HTMLDivElement,
|
||||
accountResetPasswordRecaptcha: document.getElementById("accountResetPasswordReCaptcha") as HTMLDivElement,
|
||||
accountResetPasswordTurnstile: document.getElementById("accountResetPasswordTurnstile") as HTMLDivElement,
|
||||
|
||||
accountResetPasswordVerifySection: document.getElementById("accountResetPasswordVerifySection") as HTMLDivElement,
|
||||
accountVerifyPasswordResetText: document.getElementById("accountVerifyPasswordResetText") as HTMLParagraphElement,
|
||||
|
@ -943,12 +949,45 @@ async function renderAuth() {
|
|||
elements.accountRegisterCaptcha.innerHTML = "";
|
||||
elements.accountLoginCaptcha.innerHTML = "";
|
||||
elements.accountResetPasswordCaptcha.innerHTML = "";
|
||||
elements.accountRegisterTurnstile.innerHTML = "";
|
||||
elements.accountLoginTurnstile.innerHTML = "";
|
||||
elements.accountResetPasswordTurnstile.innerHTML = "";
|
||||
elements.accountRegisterRecaptcha.innerHTML = "";
|
||||
elements.accountLoginRecaptcha.innerHTML = "";
|
||||
elements.accountResetPasswordRecaptcha.innerHTML = "";
|
||||
if (auth!.info!.hcaptcha.required) {
|
||||
var hconfig = {sitekey: auth!.info!.hcaptcha.siteKey!};
|
||||
hcaptcha.render(elements.accountRegisterCaptcha, hconfig);
|
||||
hcaptcha.render(elements.accountLoginCaptcha, hconfig);
|
||||
hcaptcha.render(elements.accountResetPasswordCaptcha, hconfig);
|
||||
}
|
||||
|
||||
if(auth!.info?.turnstile.required) {
|
||||
var turnstileconfig = {sitekey: auth!.info!.turnstile.siteKey!};
|
||||
|
||||
// hCaptcha does this automatically, but Turnstile doesn't, oh well.
|
||||
var turnstileRegisterWidgetId = turnstile.render(elements.accountRegisterTurnstile, turnstileconfig);
|
||||
var turnstileLoginWidgetId = turnstile.render(elements.accountLoginTurnstile, turnstileconfig);
|
||||
var turnstileResetPasswordWidgetId = turnstile.render(elements.accountResetPasswordTurnstile, turnstileconfig);
|
||||
|
||||
elements.accountRegisterTurnstile.children[0].setAttribute("data-turnstile-widget-id", turnstileRegisterWidgetId!);
|
||||
elements.accountLoginTurnstile.children[0].setAttribute("data-turnstile-widget-id", turnstileLoginWidgetId!);
|
||||
elements.accountResetPasswordTurnstile.children[0].setAttribute("data-turnstile-widget-id", turnstileResetPasswordWidgetId!);
|
||||
}
|
||||
|
||||
if(auth!.info?.recaptcha.required) {
|
||||
var recaptchaconfig = {sitekey: auth!.info!.recaptcha.siteKey!};
|
||||
|
||||
// Same deal as with Turnstile
|
||||
var RecaptchaRegisterWidgetId = grecaptcha.render(elements.accountRegisterRecaptcha, recaptchaconfig);
|
||||
var RecaptchaLoginWidgetId = grecaptcha.render(elements.accountLoginRecaptcha, recaptchaconfig);
|
||||
var RecaptchaResetPasswordWidgetId = grecaptcha.render(elements.accountResetPasswordRecaptcha, recaptchaconfig);
|
||||
|
||||
elements.accountRegisterRecaptcha.children[0].setAttribute("data-recaptcha-widget-id", RecaptchaRegisterWidgetId!.toString());
|
||||
elements.accountLoginRecaptcha.children[0].setAttribute("data-recaptcha-widget-id", RecaptchaLoginWidgetId!.toString());
|
||||
elements.accountResetPasswordRecaptcha.children[0].setAttribute("data-recaptcha-widget-id", RecaptchaResetPasswordWidgetId!.toString());
|
||||
}
|
||||
|
||||
var token = localStorage.getItem("collabvm_session_" + new URL(auth!.apiEndpoint).host);
|
||||
if (token) {
|
||||
var result = await auth!.loadSession(token);
|
||||
|
@ -1032,10 +1071,41 @@ elements.accountLoginForm.addEventListener('submit', async (e) => {
|
|||
}
|
||||
hcaptchaToken = response;
|
||||
}
|
||||
|
||||
var turnstileToken = undefined;
|
||||
var turnstileID = undefined;
|
||||
|
||||
if (auth!.info!.turnstile.required) {
|
||||
turnstileID = elements.accountLoginTurnstile.children[0].getAttribute("data-turnstile-widget-id")!
|
||||
var response: string = turnstile.getResponse(turnstileID) || "";
|
||||
if (response === "") {
|
||||
elements.accountModalErrorText.innerHTML = TheI18n.GetString(I18nStringKey.kMissingCaptcha);
|
||||
elements.accountModalError.style.display = "block";
|
||||
return false;
|
||||
}
|
||||
turnstileToken = response;
|
||||
}
|
||||
|
||||
var recaptchaToken = undefined;
|
||||
var recaptchaID = undefined;
|
||||
|
||||
if (auth!.info!.recaptcha.required) {
|
||||
recaptchaID = parseInt(elements.accountLoginRecaptcha.children[0].getAttribute("data-recaptcha-widget-id")!)
|
||||
var response = grecaptcha.getResponse(recaptchaID);
|
||||
if (response === "") {
|
||||
elements.accountModalErrorText.innerHTML = TheI18n.GetString(I18nStringKey.kMissingCaptcha);
|
||||
elements.accountModalError.style.display = "block";
|
||||
return false;
|
||||
}
|
||||
recaptchaToken = response;
|
||||
}
|
||||
|
||||
var username = elements.accountLoginUsername.value;
|
||||
var password = elements.accountLoginPassword.value;
|
||||
var result = await auth!.login(username, password, hcaptchaToken);
|
||||
var result = await auth!.login(username, password, hcaptchaToken, turnstileToken, recaptchaToken);
|
||||
if (auth!.info!.hcaptcha.required) hcaptcha.reset(hcaptchaID);
|
||||
if (auth!.info!.turnstile.required) turnstile.reset(turnstileID);
|
||||
if (auth!.info!.recaptcha.required) grecaptcha.reset(recaptchaID);
|
||||
if (result.success) {
|
||||
elements.accountLoginUsername.value = "";
|
||||
elements.accountLoginPassword.value = "";
|
||||
|
@ -1069,6 +1139,35 @@ elements.accountRegisterForm.addEventListener('submit', async (e) => {
|
|||
}
|
||||
hcaptchaToken = response;
|
||||
}
|
||||
|
||||
var turnstileToken = undefined;
|
||||
var turnstileID = undefined;
|
||||
|
||||
if (auth!.info!.turnstile.required) {
|
||||
turnstileID = elements.accountRegisterTurnstile.children[0].getAttribute("data-turnstile-widget-id")!
|
||||
var response: string = turnstile.getResponse(turnstileID) || "";
|
||||
if (response === "") {
|
||||
elements.accountModalErrorText.innerHTML = TheI18n.GetString(I18nStringKey.kMissingCaptcha);
|
||||
elements.accountModalError.style.display = "block";
|
||||
return false;
|
||||
}
|
||||
turnstileToken = response;
|
||||
}
|
||||
|
||||
var recaptchaToken = undefined;
|
||||
var recaptchaID = undefined;
|
||||
|
||||
if (auth!.info!.recaptcha.required) {
|
||||
recaptchaID = parseInt(elements.accountRegisterRecaptcha.children[0].getAttribute("data-recaptcha-widget-id")!)
|
||||
var response = grecaptcha.getResponse(recaptchaID);
|
||||
if (response === "") {
|
||||
elements.accountModalErrorText.innerHTML = TheI18n.GetString(I18nStringKey.kMissingCaptcha);
|
||||
elements.accountModalError.style.display = "block";
|
||||
return false;
|
||||
}
|
||||
recaptchaToken = response;
|
||||
}
|
||||
|
||||
var username = elements.accountRegisterUsername.value;
|
||||
var password = elements.accountRegisterPassword.value;
|
||||
var email = elements.accountRegisterEmail.value;
|
||||
|
@ -1078,8 +1177,10 @@ elements.accountRegisterForm.addEventListener('submit', async (e) => {
|
|||
elements.accountModalError.style.display = "block";
|
||||
return false;
|
||||
}
|
||||
var result = await auth!.register(username, password, email, dob, hcaptchaToken);
|
||||
var result = await auth!.register(username, password, email, dob, hcaptchaToken, turnstileToken, recaptchaToken);
|
||||
if (auth!.info!.hcaptcha.required) hcaptcha.reset(hcaptchaID);
|
||||
if (auth!.info!.turnstile.required) turnstile.reset(turnstileID);
|
||||
if (auth!.info!.recaptcha.required) grecaptcha.reset(recaptchaID);
|
||||
if (result.success) {
|
||||
elements.accountRegisterUsername.value = "";
|
||||
elements.accountRegisterEmail.value = "";
|
||||
|
@ -1182,10 +1283,41 @@ elements.accountResetPasswordForm.addEventListener('submit', async e => {
|
|||
}
|
||||
hcaptchaToken = response;
|
||||
}
|
||||
|
||||
var turnstileToken = undefined;
|
||||
var turnstileID = undefined;
|
||||
|
||||
if (auth!.info!.turnstile.required) {
|
||||
turnstileID = elements.accountResetPasswordTurnstile.children[0].getAttribute("data-turnstile-widget-id")!
|
||||
var response: string = turnstile.getResponse(turnstileID) || "";
|
||||
if (response === "") {
|
||||
elements.accountModalErrorText.innerHTML = TheI18n.GetString(I18nStringKey.kMissingCaptcha);
|
||||
elements.accountModalError.style.display = "block";
|
||||
return false;
|
||||
}
|
||||
turnstileToken = response;
|
||||
}
|
||||
|
||||
var recaptchaToken = undefined;
|
||||
var recaptchaID = undefined;
|
||||
|
||||
if (auth!.info!.recaptcha.required) {
|
||||
recaptchaID = parseInt(elements.accountResetPasswordRecaptcha.children[0].getAttribute("data-recaptcha-widget-id")!)
|
||||
var response = grecaptcha.getResponse(recaptchaID);
|
||||
if (response === "") {
|
||||
elements.accountModalErrorText.innerHTML = TheI18n.GetString(I18nStringKey.kMissingCaptcha);
|
||||
elements.accountModalError.style.display = "block";
|
||||
return false;
|
||||
}
|
||||
recaptchaToken = response;
|
||||
}
|
||||
|
||||
var username = elements.accountResetPasswordUsername.value;
|
||||
var email = elements.accountResetPasswordEmail.value;
|
||||
var result = await auth!.sendPasswordResetEmail(username, email, hcaptchaToken);
|
||||
var result = await auth!.sendPasswordResetEmail(username, email, hcaptchaToken, turnstileToken, recaptchaToken);
|
||||
if (auth!.info!.hcaptcha.required) hcaptcha.reset(hcaptchaID);
|
||||
if (auth!.info!.turnstile.required) turnstile.reset(turnstileID);
|
||||
if (auth!.info!.recaptcha.required) grecaptcha.reset(recaptchaID);
|
||||
if (result.success) {
|
||||
resetPasswordUsername = username;
|
||||
resetPasswordEmail = email;
|
||||
|
|
|
@ -32,7 +32,8 @@
|
|||
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
|
||||
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
|
||||
"typeRoots": [
|
||||
"node_modules/@hcaptcha"
|
||||
"node_modules/@hcaptcha",
|
||||
"node_modules/@types"
|
||||
], /* Specify multiple folders that act like './node_modules/@types'. */
|
||||
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
|
||||
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
|
||||
|
|
10
yarn.lock
10
yarn.lock
|
@ -1446,6 +1446,11 @@
|
|||
dependencies:
|
||||
"@popperjs/core" "^2.9.2"
|
||||
|
||||
"@types/cloudflare-turnstile@^0.2.2":
|
||||
version "0.2.2"
|
||||
resolved "https://registry.yarnpkg.com/@types/cloudflare-turnstile/-/cloudflare-turnstile-0.2.2.tgz#3364d65b00f03376f4e555820db270173807a52c"
|
||||
integrity sha512-3Yf7b1Glci+V2bFWwWBbZkRgTuegp7RDgNTOG4U0UNPB9RV4AWvwqg2/qqLff8G+SwKFNXoXvTkqaRBZrAFdKA==
|
||||
|
||||
"@types/dompurify@^3.0.5":
|
||||
version "3.0.5"
|
||||
resolved "https://registry.yarnpkg.com/@types/dompurify/-/dompurify-3.0.5.tgz#02069a2fcb89a163bacf1a788f73cb415dd75cb7"
|
||||
|
@ -1460,6 +1465,11 @@
|
|||
dependencies:
|
||||
"@types/node" "*"
|
||||
|
||||
"@types/grecaptcha@^3.0.9":
|
||||
version "3.0.9"
|
||||
resolved "https://registry.yarnpkg.com/@types/grecaptcha/-/grecaptcha-3.0.9.tgz#9f3b07ec06c8fff221aa6fc124fe5b8a0e2c3349"
|
||||
integrity sha512-fFxMtjAvXXMYTzDFK5NpcVB7WHnrHVLl00QzEGpuFxSAC789io6M+vjcn+g5FTEamIJtJr/IHkCDsqvJxeWDyw==
|
||||
|
||||
"@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0", "@types/istanbul-lib-coverage@^2.0.1":
|
||||
version "2.0.6"
|
||||
resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz#7739c232a1fee9b4d3ce8985f314c0c6d33549d7"
|
||||
|
|
Loading…
Reference in a new issue