mirror of
https://github.com/Expand-sys/ccash-client-js
synced 2026-03-22 12:27:09 +11:00
feat: add CCashClient methods
This commit is contained in:
parent
145c9507b4
commit
9e3a08f307
18 changed files with 328 additions and 15 deletions
|
|
@ -15,9 +15,11 @@ npm install ccash-client-js
|
||||||
```js
|
```js
|
||||||
import { CCashClient } from 'ccash-client-js';
|
import { CCashClient } from 'ccash-client-js';
|
||||||
|
|
||||||
|
process.env.CCASH_API_BASE_URL = 'https://your.ccash.api';
|
||||||
|
|
||||||
const client = new CCashClient();
|
const client = new CCashClient();
|
||||||
|
|
||||||
client.balance('twix');
|
console.log(await client.balance('blinkblinko'));
|
||||||
```
|
```
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
|
||||||
1
examples/node/.env
Normal file
1
examples/node/.env
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
CCASH_API_BASE_URL=https://wtfisthis.tech/BankF
|
||||||
|
|
@ -1,10 +1,20 @@
|
||||||
|
require('dotenv').config();
|
||||||
const { CCashClient } = require('ccash-client-js');
|
const { CCashClient } = require('ccash-client-js');
|
||||||
|
|
||||||
const client = new CCashClient()
|
const user = 'blinkblinko';
|
||||||
|
const pass = 'TestPassword';
|
||||||
|
|
||||||
|
const client = new CCashClient();
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const balance = await client.balance('twix');
|
const createdUser = await client.addUser(user, pass);
|
||||||
console.log(`Balance: ${balance}`)
|
console.log('User created', createdUser);
|
||||||
|
|
||||||
|
const balance = await client.balance(user);
|
||||||
|
console.log(`Balance: ${balance}`);
|
||||||
|
|
||||||
|
const deletedUser = await client.deleteUser(user, pass);
|
||||||
|
console.log('User deleated', deletedUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
main()
|
main();
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
"start": "node index.js"
|
"start": "node index.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ccash-client-js": "file:../.."
|
"ccash-client-js": "file:../..",
|
||||||
|
"dotenv": "^10.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,11 @@ axios@^0.21.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
axios "^0.21.1"
|
axios "^0.21.1"
|
||||||
|
|
||||||
|
dotenv@^10.0.0:
|
||||||
|
version "10.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-10.0.0.tgz#3d4227b8fb95f81096cdd2b66653fb2c7085ba81"
|
||||||
|
integrity sha512-rlBi9d8jpv9Sf1klPjNfFAuWDjKLwTIJJ/VxtoTwIR6hnZxcEOQCZg2oIL3MWBYw5GpUDKOEnND7LXTbIpQ03Q==
|
||||||
|
|
||||||
follow-redirects@^1.10.0:
|
follow-redirects@^1.10.0:
|
||||||
version "1.14.1"
|
version "1.14.1"
|
||||||
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.1.tgz#d9114ded0a1cfdd334e164e6662ad02bfd91ff43"
|
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.1.tgz#d9114ded0a1cfdd334e164e6662ad02bfd91ff43"
|
||||||
|
|
|
||||||
2
examples/web/.env
Normal file
2
examples/web/.env
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
SKIP_PREFLIGHT_CHECK=true
|
||||||
|
CCASH_API_BASE_URL=https://wtfisthis.tech/BankF
|
||||||
|
|
@ -2,14 +2,16 @@ import React, { useEffect, useState } from 'react';
|
||||||
import { CCashClient } from 'ccash-client-js';
|
import { CCashClient } from 'ccash-client-js';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
|
||||||
const client = new CCashClient();
|
const client = new CCashClient(
|
||||||
|
process.env.CCASH_API_BASE_URL || 'https://wtfisthis.tech/BankF'
|
||||||
|
);
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [balance, setBalance] = useState(0);
|
const [balance, setBalance] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async function getBalance() {
|
(async function getBalance() {
|
||||||
setBalance(await client.balance('twix'));
|
setBalance(await client.balance('blinkblinko'));
|
||||||
})();
|
})();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3163,6 +3163,7 @@ case-sensitive-paths-webpack-plugin@2.3.0:
|
||||||
version "0.0.0"
|
version "0.0.0"
|
||||||
dependencies:
|
dependencies:
|
||||||
axios "^0.21.1"
|
axios "^0.21.1"
|
||||||
|
class-transformer "^0.4.0"
|
||||||
|
|
||||||
chalk@2.4.2, chalk@^2.0.0, chalk@^2.4.1, chalk@^2.4.2:
|
chalk@2.4.2, chalk@^2.0.0, chalk@^2.4.1, chalk@^2.4.2:
|
||||||
version "2.4.2"
|
version "2.4.2"
|
||||||
|
|
@ -3266,6 +3267,11 @@ cjs-module-lexer@^0.6.0:
|
||||||
resolved "https://registry.yarnpkg.com/cjs-module-lexer/-/cjs-module-lexer-0.6.0.tgz#4186fcca0eae175970aee870b9fe2d6cf8d5655f"
|
resolved "https://registry.yarnpkg.com/cjs-module-lexer/-/cjs-module-lexer-0.6.0.tgz#4186fcca0eae175970aee870b9fe2d6cf8d5655f"
|
||||||
integrity sha512-uc2Vix1frTfnuzxxu1Hp4ktSvM3QaI4oXl4ZUqL1wjTu/BGki9TrCWoqLTg/drR1KwAEarXuRFCG2Svr1GxPFw==
|
integrity sha512-uc2Vix1frTfnuzxxu1Hp4ktSvM3QaI4oXl4ZUqL1wjTu/BGki9TrCWoqLTg/drR1KwAEarXuRFCG2Svr1GxPFw==
|
||||||
|
|
||||||
|
class-transformer@^0.4.0:
|
||||||
|
version "0.4.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/class-transformer/-/class-transformer-0.4.0.tgz#b52144117b423c516afb44cc1c76dbad31c2165b"
|
||||||
|
integrity sha512-ETWD/H2TbWbKEi7m9N4Km5+cw1hNcqJSxlSYhsLsNjQzWWiZIYA1zafxpK9PwVfaZ6AqR5rrjPVUBGESm5tQUA==
|
||||||
|
|
||||||
class-utils@^0.3.5:
|
class-utils@^0.3.5:
|
||||||
version "0.3.6"
|
version "0.3.6"
|
||||||
resolved "https://registry.yarnpkg.com/class-utils/-/class-utils-0.3.6.tgz#f93369ae8b9a7ce02fd41faad0ca83033190c463"
|
resolved "https://registry.yarnpkg.com/class-utils/-/class-utils-0.3.6.tgz#f93369ae8b9a7ce02fd41faad0ca83033190c463"
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,8 @@
|
||||||
"test": "jest"
|
"test": "jest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^0.21.1"
|
"axios": "^0.21.1",
|
||||||
|
"class-transformer": "^0.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.14.5",
|
"@babel/core": "^7.14.5",
|
||||||
|
|
|
||||||
69
src/CCashClient.exceptions.ts
Normal file
69
src/CCashClient.exceptions.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
import { Exception } from './Exception';
|
||||||
|
|
||||||
|
export class BaseUrlMissingException extends Exception {
|
||||||
|
constructor() {
|
||||||
|
super('base url missing');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UserNotFoundException extends Exception {
|
||||||
|
constructor() {
|
||||||
|
super('user not found');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WrongPasswordException extends Exception {
|
||||||
|
constructor() {
|
||||||
|
super('wrong password');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InvalidRequestException extends Exception {
|
||||||
|
constructor() {
|
||||||
|
super('invalid request');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WrongAdminPasswordException extends Exception {
|
||||||
|
constructor() {
|
||||||
|
super('wrong admin password');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NameTooLongException extends Exception {
|
||||||
|
constructor() {
|
||||||
|
super('name too long');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UserAlreadyExistsException extends Exception {
|
||||||
|
constructor() {
|
||||||
|
super('user already exists');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InsufficientFundsException extends Exception {
|
||||||
|
constructor() {
|
||||||
|
super('insufficient funds');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ErrorCodes {
|
||||||
|
UserNotFound = -1,
|
||||||
|
WrongPassword = -2,
|
||||||
|
InvalidRequest = -3,
|
||||||
|
WrongAdminPassword = -4,
|
||||||
|
NameTooLong = -5,
|
||||||
|
UserAlreadyExists = -6,
|
||||||
|
InsufficientFunds = -7,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ExceptionMap = {
|
||||||
|
[ErrorCodes.UserNotFound]: UserNotFoundException,
|
||||||
|
[ErrorCodes.WrongPassword]: WrongPasswordException,
|
||||||
|
[ErrorCodes.InvalidRequest]: InvalidRequestException,
|
||||||
|
[ErrorCodes.WrongAdminPassword]: WrongAdminPasswordException,
|
||||||
|
[ErrorCodes.NameTooLong]: NameTooLongException,
|
||||||
|
[ErrorCodes.UserAlreadyExists]: UserAlreadyExistsException,
|
||||||
|
[ErrorCodes.InsufficientFunds]: InsufficientFundsException,
|
||||||
|
};
|
||||||
|
|
@ -1,5 +1,166 @@
|
||||||
export class CCashClient {
|
import axios, { AxiosInstance, AxiosResponse } from 'axios';
|
||||||
|
import { plainToClass } from 'class-transformer';
|
||||||
|
import { ICCashClient, User } from './CCashClient.types';
|
||||||
|
import {
|
||||||
|
BaseUrlMissingException,
|
||||||
|
ExceptionMap,
|
||||||
|
ErrorCodes,
|
||||||
|
} from './CCashClient.exceptions';
|
||||||
|
|
||||||
|
export class CCashClient implements Partial<ICCashClient> {
|
||||||
|
/** TODO: not partial **/
|
||||||
|
http: AxiosInstance;
|
||||||
|
|
||||||
|
constructor(baseURL: string | undefined = process.env.CCASH_API_BASE_URL) {
|
||||||
|
if (!baseURL) {
|
||||||
|
throw new BaseUrlMissingException();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.http = axios.create({
|
||||||
|
baseURL,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
balance(user: string): Promise<number> {
|
balance(user: string): Promise<number> {
|
||||||
return Promise.resolve(10);
|
return this.http
|
||||||
|
.get(`/${user}/bal`)
|
||||||
|
.then((response) => this.handleError(response) || response.data.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log(
|
||||||
|
user: string,
|
||||||
|
pass: string,
|
||||||
|
transactionCount: number = 10
|
||||||
|
): Promise<number[]> {
|
||||||
|
return this.http
|
||||||
|
.get(`/${user}/bal`, {
|
||||||
|
headers: { Password: pass },
|
||||||
|
params: { n: transactionCount },
|
||||||
|
})
|
||||||
|
.then((response) => this.handleError(response) || response.data.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
sendFunds(
|
||||||
|
user: string,
|
||||||
|
pass: string,
|
||||||
|
to: string,
|
||||||
|
amount: number
|
||||||
|
): Promise<number> {
|
||||||
|
return this.http
|
||||||
|
.post(`/${user}/send/${to}`, {
|
||||||
|
headers: { Password: pass },
|
||||||
|
params: { amount },
|
||||||
|
})
|
||||||
|
.then((response) => this.handleError(response) || amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
verifyPassword(user: string, pass: string): Promise<boolean> {
|
||||||
|
return this.http
|
||||||
|
.get(`/${user}/pass/verify`, { headers: { Password: pass } })
|
||||||
|
.then((response) => this.handleError(response) || response.data.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
changePassword(user: string, pass: string, newPass: string): Promise<User> {
|
||||||
|
return this.http
|
||||||
|
.patch(
|
||||||
|
`/${user}/pass/change`,
|
||||||
|
{ password: newPass },
|
||||||
|
{ headers: { Password: pass } }
|
||||||
|
)
|
||||||
|
.then(
|
||||||
|
(response) =>
|
||||||
|
this.handleError(response) || this.serialize(User, { user })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setBal(user: string, pass: string, amount: number): Promise<number> {
|
||||||
|
return this.http
|
||||||
|
.patch(`/admin/${user}/bal`, undefined, {
|
||||||
|
headers: { Password: pass },
|
||||||
|
params: { amount },
|
||||||
|
})
|
||||||
|
.then((response) => this.handleError(response) || amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
help(): Promise<string> {
|
||||||
|
return this.http.get('/help').then((response) => response.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
close(pass: string): Promise<boolean> {
|
||||||
|
return this.http
|
||||||
|
.post('/close', undefined, { headers: { Password: pass } })
|
||||||
|
.then((response) => this.handleError(response) || true);
|
||||||
|
}
|
||||||
|
|
||||||
|
contains(user: string): Promise<boolean> {
|
||||||
|
return this.http
|
||||||
|
.get(`/contains/${user}`)
|
||||||
|
.then(
|
||||||
|
(response) => this.handleError(response) || response.data.value || false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
adminVerifyPass(pass: string): Promise<boolean> {
|
||||||
|
return this.http
|
||||||
|
.get('/admin/verify')
|
||||||
|
.then(
|
||||||
|
(response) => this.handleError(response) || response.data.value || false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
addUser(user: string, pass: string): Promise<User> {
|
||||||
|
return this.http
|
||||||
|
.post(`/user/${user}`, undefined, { headers: { Password: pass } })
|
||||||
|
.then(
|
||||||
|
(response) =>
|
||||||
|
this.handleError(response) || this.serialize(User, { user })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
adminAddUser(
|
||||||
|
user: string,
|
||||||
|
pass: string,
|
||||||
|
initialBalance: number
|
||||||
|
): Promise<User> {
|
||||||
|
return this.http
|
||||||
|
.post(`/user/${user}`, undefined, {
|
||||||
|
headers: { Password: pass },
|
||||||
|
params: { init_bal: initialBalance },
|
||||||
|
})
|
||||||
|
.then(
|
||||||
|
(response) =>
|
||||||
|
this.handleError(response) || this.serialize(User, { user })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteUser(user: string, pass: string): Promise<User> {
|
||||||
|
return this.http
|
||||||
|
.delete(`/user/${user}`, { headers: { Password: pass } })
|
||||||
|
.then(
|
||||||
|
(response) =>
|
||||||
|
this.handleError(response) || this.serialize(User, { user })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
adminDeleteUser(user: string, pass: string): Promise<User> {
|
||||||
|
return this.http
|
||||||
|
.delete(`/user/${user}`, { headers: { Password: pass } })
|
||||||
|
.then(
|
||||||
|
(response) =>
|
||||||
|
this.handleError(response) || this.serialize(User, { user })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleError(response: AxiosResponse<any>): false {
|
||||||
|
if (
|
||||||
|
response.data.value &&
|
||||||
|
Object.values(ErrorCodes).includes(response.data.value)
|
||||||
|
) {
|
||||||
|
throw new ExceptionMap[response.data.value as ErrorCodes]();
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private serialize = plainToClass;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
36
src/CCashClient.types.ts
Normal file
36
src/CCashClient.types.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
import { User } from './User';
|
||||||
|
|
||||||
|
export { User };
|
||||||
|
|
||||||
|
export interface ICCashClient {
|
||||||
|
// Usage
|
||||||
|
balance(user: string): Promise<number>;
|
||||||
|
log(user: string, pass: string, transactionCount?: number): Promise<number[]>;
|
||||||
|
sendFunds(
|
||||||
|
user: string,
|
||||||
|
pass: string,
|
||||||
|
to: string,
|
||||||
|
amount: number
|
||||||
|
): Promise<number>;
|
||||||
|
verifyPassword(user: string, pass: string): Promise<boolean>;
|
||||||
|
|
||||||
|
// Meta usage
|
||||||
|
changePassword(user: string, pass: string, newPass: string): Promise<User>;
|
||||||
|
setBal(user: string, pass: string, amount: number): Promise<number>;
|
||||||
|
|
||||||
|
// System usage
|
||||||
|
help(): Promise<string>;
|
||||||
|
close(pass: string): Promise<boolean>;
|
||||||
|
contains(user: string): Promise<boolean>;
|
||||||
|
adminVerifyPass(pass: string): Promise<boolean>;
|
||||||
|
|
||||||
|
// User management
|
||||||
|
addUser(user: string, pass: string): Promise<User>;
|
||||||
|
adminAddUser(
|
||||||
|
user: string,
|
||||||
|
pass: string,
|
||||||
|
initialBalance: number
|
||||||
|
): Promise<User>;
|
||||||
|
deleteUser(user: string, pass: string): Promise<User>;
|
||||||
|
adminDeleteUser(user: string, pass: string): Promise<User>;
|
||||||
|
}
|
||||||
1
src/Exception.ts
Normal file
1
src/Exception.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export class Exception extends Error {}
|
||||||
7
src/User.ts
Normal file
7
src/User.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { Exclude, Expose } from 'class-transformer';
|
||||||
|
|
||||||
|
@Exclude()
|
||||||
|
export class User {
|
||||||
|
@Expose()
|
||||||
|
user!: string;
|
||||||
|
}
|
||||||
|
|
@ -1 +1,3 @@
|
||||||
export { CCashClient } from './CCashClient';
|
export * from './CCashClient';
|
||||||
|
export * from './CCashClient.types';
|
||||||
|
export * from './CCashClient.exceptions';
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ describe('CCashClient', () => {
|
||||||
|
|
||||||
describe('balance', () => {
|
describe('balance', () => {
|
||||||
it('returns an integer', async () => {
|
it('returns an integer', async () => {
|
||||||
expect(await client.balance('twix')).toEqual(10);
|
expect(await client.balance('blinkblinko')).toEqual(10);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,13 @@
|
||||||
"outDir": "./dist/cjs",
|
"outDir": "./dist/cjs",
|
||||||
"target": "es2015",
|
"target": "es2015",
|
||||||
"module": "commonjs",
|
"module": "commonjs",
|
||||||
|
"declaration": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noImplicitAny": true,
|
"noImplicitAny": true,
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "node",
|
||||||
"allowSyntheticDefaultImports": true,
|
"allowSyntheticDefaultImports": true,
|
||||||
"forceConsistentCasingInFileNames": true
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"experimentalDecorators": true
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
"include": ["src/**/*"],
|
||||||
"paths": {
|
"paths": {
|
||||||
|
|
|
||||||
|
|
@ -1546,6 +1546,11 @@ cjs-module-lexer@^1.0.0:
|
||||||
resolved "https://registry.yarnpkg.com/cjs-module-lexer/-/cjs-module-lexer-1.2.1.tgz#2fd46d9906a126965aa541345c499aaa18e8cd73"
|
resolved "https://registry.yarnpkg.com/cjs-module-lexer/-/cjs-module-lexer-1.2.1.tgz#2fd46d9906a126965aa541345c499aaa18e8cd73"
|
||||||
integrity sha512-jVamGdJPDeuQilKhvVn1h3knuMOZzr8QDnpk+M9aMlCaMkTDd6fBWPhiDqFvFZ07pL0liqabAiuy8SY4jGHeaw==
|
integrity sha512-jVamGdJPDeuQilKhvVn1h3knuMOZzr8QDnpk+M9aMlCaMkTDd6fBWPhiDqFvFZ07pL0liqabAiuy8SY4jGHeaw==
|
||||||
|
|
||||||
|
class-transformer@^0.4.0:
|
||||||
|
version "0.4.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/class-transformer/-/class-transformer-0.4.0.tgz#b52144117b423c516afb44cc1c76dbad31c2165b"
|
||||||
|
integrity sha512-ETWD/H2TbWbKEi7m9N4Km5+cw1hNcqJSxlSYhsLsNjQzWWiZIYA1zafxpK9PwVfaZ6AqR5rrjPVUBGESm5tQUA==
|
||||||
|
|
||||||
cliui@^7.0.2:
|
cliui@^7.0.2:
|
||||||
version "7.0.4"
|
version "7.0.4"
|
||||||
resolved "https://registry.yarnpkg.com/cliui/-/cliui-7.0.4.tgz#a0265ee655476fc807aea9df3df8df7783808b4f"
|
resolved "https://registry.yarnpkg.com/cliui/-/cliui-7.0.4.tgz#a0265ee655476fc807aea9df3df8df7783808b4f"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue