mirror of
https://github.com/logto-io/logto.git
synced 2024-12-16 20:26:19 -05:00
refactor(core): align next()
usage and add prod mode for ui proxy
This commit is contained in:
parent
eff73ffaa2
commit
e5d49504ac
13 changed files with 146 additions and 61 deletions
|
@ -10,7 +10,8 @@
|
|||
"precommit": "lint-staged",
|
||||
"build": "rm -rf build/ && tsc",
|
||||
"lint": "eslint --format pretty --ext .ts src",
|
||||
"dev": "rm -rf build/ && tsc-watch --preserveWatchOutput --onSuccess \"node ./build/index.js\""
|
||||
"dev": "rm -rf build/ && tsc-watch --preserveWatchOutput --onSuccess \"node ./build/index.js\"",
|
||||
"start": "NODE_ENV=production node build/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@logto/essentials": "^1.1.0-rc.2",
|
||||
|
@ -29,6 +30,7 @@
|
|||
"koa-mount": "^4.0.0",
|
||||
"koa-proxies": "^0.12.1",
|
||||
"koa-router": "^10.0.0",
|
||||
"koa-static": "^5.0.0",
|
||||
"lodash.pick": "^4.4.0",
|
||||
"module-alias": "^2.2.2",
|
||||
"nanoid": "^3.1.23",
|
||||
|
@ -44,6 +46,7 @@
|
|||
"@types/koa-logger": "^3.1.1",
|
||||
"@types/koa-mount": "^4.0.0",
|
||||
"@types/koa-router": "^7.4.2",
|
||||
"@types/koa-static": "^4.0.2",
|
||||
"@types/lodash.pick": "^4.4.6",
|
||||
"@types/node": "^16.3.1",
|
||||
"@types/oidc-provider": "^7.4.1",
|
||||
|
|
|
@ -9,5 +9,7 @@ export const routes = Object.freeze({
|
|||
},
|
||||
});
|
||||
|
||||
export const isProduction = getEnv('NODE_ENV') === 'production';
|
||||
export const port = Number(getEnv('PORT', '3001'));
|
||||
export const oidcIssuer = getEnv('OIDC_ISSUER', `http://localhost:${port}/oidc`);
|
||||
export const mountedApps = Object.freeze(['api', 'oidc']);
|
||||
|
|
4
packages/core/src/include.d/koa-body.d.ts
vendored
4
packages/core/src/include.d/koa-body.d.ts
vendored
|
@ -1,12 +1,12 @@
|
|||
declare module 'koa-body' {
|
||||
import { IKoaBodyOptions } from 'node_modules/koa-body';
|
||||
import { Middleware } from 'koa';
|
||||
import { MiddlewareType } from 'koa';
|
||||
|
||||
declare function koaBody<
|
||||
StateT = Record<string, unknown>,
|
||||
ContextT = Record<string, unknown>,
|
||||
ResponseBodyT = any
|
||||
>(options?: IKoaBodyOptions): Middleware<StateT, ContextT, ResponseBodyT>;
|
||||
>(options?: IKoaBodyOptions): MiddlewareType<StateT, ContextT, ResponseBodyT>;
|
||||
|
||||
export = koaBody;
|
||||
}
|
||||
|
|
13
packages/core/src/include.d/koa.d.ts
vendored
Normal file
13
packages/core/src/include.d/koa.d.ts
vendored
Normal file
|
@ -0,0 +1,13 @@
|
|||
import { DefaultState, DefaultContext, ParameterizedContext, Next } from 'koa';
|
||||
|
||||
declare module 'koa' {
|
||||
// Have to do this patch since `compose.Middleware` returns `any`.
|
||||
export type KoaNext<T> = () => Promise<T>;
|
||||
export type KoaMiddleware<T, R> = (context: T, next: KoaNext<R>) => Promise<void>;
|
||||
export type MiddlewareType<
|
||||
StateT = DefaultState,
|
||||
ContextT = DefaultContext,
|
||||
ResponseBodyT = any,
|
||||
NextT = void
|
||||
> = KoaMiddleware<ParameterizedContext<StateT, ContextT, ResponseBodyT>, NextT>;
|
||||
}
|
|
@ -3,21 +3,22 @@ import Router from 'koa-router';
|
|||
import { Provider } from 'oidc-provider';
|
||||
import signInRoutes from '@/routes/sign-in';
|
||||
import registerRoutes from '@/routes/register';
|
||||
import uiProxy from '@/proxies/ui';
|
||||
import swaggerRoutes from '@/routes/swagger';
|
||||
import mount from 'koa-mount';
|
||||
|
||||
const createRouter = (provider: Provider): Router => {
|
||||
const router = new Router({ prefix: '/api' });
|
||||
const router = new Router();
|
||||
|
||||
router.use(signInRoutes(provider));
|
||||
router.use(registerRoutes());
|
||||
router.use(swaggerRoutes());
|
||||
signInRoutes(router, provider);
|
||||
registerRoutes(router);
|
||||
swaggerRoutes(router);
|
||||
|
||||
return router;
|
||||
};
|
||||
|
||||
export default function initRouter(app: Koa, provider: Provider): Router {
|
||||
export default function initRouter(app: Koa, provider: Provider) {
|
||||
const router = createRouter(provider);
|
||||
app.use(router.routes()).use(uiProxy()).use(router.allowedMethods());
|
||||
return router;
|
||||
const apisApp = new Koa().use(router.routes()).use(router.allowedMethods());
|
||||
|
||||
app.use(mount('/api', apisApp));
|
||||
}
|
|
@ -3,8 +3,9 @@ import koaLogger from 'koa-logger';
|
|||
|
||||
import koaErrorHandler from '@/middleware/koa-error-handler';
|
||||
import { port } from '@/consts';
|
||||
import koaUIProxy from '@/middleware/koa-ui-proxy';
|
||||
import initOidc from './oidc';
|
||||
import initRouter from './router';
|
||||
import initRouter from './apis';
|
||||
|
||||
export default async function initApp(app: Koa): Promise<void> {
|
||||
app.use(koaErrorHandler());
|
||||
|
@ -13,6 +14,8 @@ export default async function initApp(app: Koa): Promise<void> {
|
|||
const provider = await initOidc(app);
|
||||
initRouter(app, provider);
|
||||
|
||||
app.use(koaUIProxy());
|
||||
|
||||
app.listen(port, () => {
|
||||
console.log(`App is listening on port ${port}`);
|
||||
});
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import RequestError from '@/errors/RequestError';
|
||||
import { has } from '@logto/essentials';
|
||||
import { Middleware } from 'koa';
|
||||
import { MiddlewareType } from 'koa';
|
||||
import koaBody from 'koa-body';
|
||||
import { IMiddleware, IRouterParamContext } from 'koa-router';
|
||||
import { ZodType } from 'zod';
|
||||
|
@ -51,12 +51,12 @@ export default function koaGuard<
|
|||
query,
|
||||
body,
|
||||
params,
|
||||
}: GuardConfig<GuardQueryT, GuardBodyT, GuardParametersT>): Middleware<
|
||||
}: GuardConfig<GuardQueryT, GuardBodyT, GuardParametersT>): MiddlewareType<
|
||||
StateT,
|
||||
WithGuarded<ContextT, GuardQueryT, GuardBodyT, GuardParametersT>,
|
||||
ResponseBodyT
|
||||
> {
|
||||
const guard: Middleware<
|
||||
const guard: MiddlewareType<
|
||||
StateT,
|
||||
WithGuarded<ContextT, GuardQueryT, GuardBodyT, GuardParametersT>,
|
||||
ResponseBodyT
|
||||
|
@ -72,24 +72,21 @@ export default function koaGuard<
|
|||
throw new RequestError('guard.invalid_input', error);
|
||||
}
|
||||
|
||||
await next();
|
||||
return next();
|
||||
};
|
||||
|
||||
const guardMiddleware: WithGuardConfig<
|
||||
Middleware<
|
||||
MiddlewareType<
|
||||
StateT,
|
||||
WithGuarded<ContextT, GuardQueryT, GuardBodyT, GuardParametersT>,
|
||||
ResponseBodyT
|
||||
>
|
||||
> = async function (ctx, next) {
|
||||
if (body) {
|
||||
await koaBody<StateT, ContextT>()(ctx, async () => {
|
||||
await guard(ctx, next);
|
||||
});
|
||||
return;
|
||||
return koaBody<StateT, ContextT>()(ctx, async () => guard(ctx, next));
|
||||
}
|
||||
|
||||
await guard(ctx, next);
|
||||
return guard(ctx, next);
|
||||
};
|
||||
|
||||
guardMiddleware.config = { query, body, params };
|
||||
|
|
41
packages/core/src/middleware/koa-ui-proxy.ts
Normal file
41
packages/core/src/middleware/koa-ui-proxy.ts
Normal file
|
@ -0,0 +1,41 @@
|
|||
import fs from 'fs';
|
||||
import { Middleware } from 'koa';
|
||||
import proxy from 'koa-proxies';
|
||||
import serveStatic from 'koa-static';
|
||||
import { IRouterParamContext } from 'koa-router';
|
||||
import { isProduction, mountedApps } from '@/consts';
|
||||
|
||||
const PATH_TO_UI_DIST = '../ui/build/public';
|
||||
const uiDistFiles = fs.readdirSync(PATH_TO_UI_DIST);
|
||||
|
||||
export default function koaUIProxy<
|
||||
StateT,
|
||||
ContextT extends IRouterParamContext,
|
||||
ResponseBodyT
|
||||
>(): Middleware<StateT, ContextT, ResponseBodyT> {
|
||||
const developmentProxy = proxy('*', {
|
||||
target: 'http://localhost:5000',
|
||||
changeOrigin: true,
|
||||
logs: true,
|
||||
});
|
||||
const staticProxy = serveStatic(PATH_TO_UI_DIST);
|
||||
|
||||
return async (context, next) => {
|
||||
// Route has been handled by one of mounted apps
|
||||
if (mountedApps.some((app) => context.request.path.startsWith(`/${app}`))) {
|
||||
return next();
|
||||
}
|
||||
|
||||
if (!isProduction) {
|
||||
await developmentProxy(context, next);
|
||||
return next();
|
||||
}
|
||||
|
||||
if (!uiDistFiles.some((file) => context.request.path.startsWith(`/${file}`))) {
|
||||
context.request.path = '/';
|
||||
}
|
||||
|
||||
await staticProxy(context, next);
|
||||
return next();
|
||||
};
|
||||
}
|
|
@ -1,10 +0,0 @@
|
|||
import proxy from 'koa-proxies';
|
||||
|
||||
// CAUTION: this is for testing only
|
||||
export default function uiProxy() {
|
||||
return proxy(/^\/(?!api|oidc).*$/, {
|
||||
target: 'http://localhost:5000',
|
||||
changeOrigin: true,
|
||||
logs: true,
|
||||
});
|
||||
}
|
|
@ -22,9 +22,7 @@ const generateUserId = async (maxRetries = 500) => {
|
|||
throw new Error('Cannot generate user ID in reasonable retries');
|
||||
};
|
||||
|
||||
export default function registerRoutes() {
|
||||
const router = new Router();
|
||||
|
||||
export default function registerRoutes(router: Router) {
|
||||
router.post(
|
||||
'/register',
|
||||
koaGuard({
|
||||
|
@ -33,7 +31,7 @@ export default function registerRoutes() {
|
|||
password: string().min(6),
|
||||
}),
|
||||
}),
|
||||
async (ctx) => {
|
||||
async (ctx, next) => {
|
||||
const { username, password } = ctx.guard.body;
|
||||
|
||||
if (await hasUser(username)) {
|
||||
|
@ -59,8 +57,7 @@ export default function registerRoutes() {
|
|||
});
|
||||
|
||||
ctx.body = { id };
|
||||
return next();
|
||||
}
|
||||
);
|
||||
|
||||
return router.routes();
|
||||
}
|
||||
|
|
|
@ -9,13 +9,11 @@ import koaGuard from '@/middleware/koa-guard';
|
|||
import RequestError from '@/errors/RequestError';
|
||||
import { LogtoErrorCode } from '@logto/phrases';
|
||||
|
||||
export default function signInRoutes(provider: Provider) {
|
||||
const router = new Router();
|
||||
|
||||
export default function signInRoutes(router: Router, provider: Provider) {
|
||||
router.post(
|
||||
'/sign-in',
|
||||
koaGuard({ body: object({ username: string().optional(), password: string().optional() }) }),
|
||||
async (ctx) => {
|
||||
async (ctx, next) => {
|
||||
const {
|
||||
prompt: { name },
|
||||
} = await provider.interactionDetails(ctx.req, ctx.res);
|
||||
|
@ -60,10 +58,12 @@ export default function signInRoutes(provider: Provider) {
|
|||
} else {
|
||||
throw new Error(`Prompt not supported: ${name}`);
|
||||
}
|
||||
|
||||
return next();
|
||||
}
|
||||
);
|
||||
|
||||
router.post('/sign-in/consent', async (ctx) => {
|
||||
router.post('/sign-in/consent', async (ctx, next) => {
|
||||
const { session, grantId, params, prompt } = await provider.interactionDetails(
|
||||
ctx.req,
|
||||
ctx.res
|
||||
|
@ -95,16 +95,17 @@ export default function signInRoutes(provider: Provider) {
|
|||
{ mergeWithLastSubmission: true }
|
||||
);
|
||||
ctx.body = { redirectTo };
|
||||
|
||||
return next();
|
||||
});
|
||||
|
||||
router.post('/sign-in/abort', async (ctx) => {
|
||||
router.post('/sign-in/abort', async (ctx, next) => {
|
||||
await provider.interactionDetails(ctx.req, ctx.res);
|
||||
const error: LogtoErrorCode = 'oidc.aborted';
|
||||
const redirectTo = await provider.interactionResult(ctx.req, ctx.res, {
|
||||
error,
|
||||
});
|
||||
ctx.body = { redirectTo };
|
||||
return next();
|
||||
});
|
||||
|
||||
return router.routes();
|
||||
}
|
||||
|
|
|
@ -4,10 +4,8 @@ import { isGuardMiddleware, WithGuardConfig } from '@/middleware/koa-guard';
|
|||
import { toTitle } from '@/utils/string';
|
||||
import { zodTypeToSwagger } from '@/utils/zod';
|
||||
|
||||
export default function swaggerRoutes() {
|
||||
const router = new Router();
|
||||
|
||||
router.get('/swagger.json', async (ctx) => {
|
||||
export default function swaggerRoutes(router: Router) {
|
||||
router.get('/swagger.json', async (ctx, next) => {
|
||||
const routes = ctx.router.stack.map(({ path, stack, methods }) => {
|
||||
const guard = stack.find((function_): function_ is WithGuardConfig<IMiddleware> =>
|
||||
isGuardMiddleware(function_)
|
||||
|
@ -17,16 +15,15 @@ export default function swaggerRoutes() {
|
|||
|
||||
const paths = Object.fromEntries(
|
||||
routes.map<[string, OpenAPIV3.PathItemObject]>(({ path, methods, guard }) => {
|
||||
const trimmedPath = path.slice(4);
|
||||
const body = guard?.config.body;
|
||||
|
||||
return [
|
||||
trimmedPath,
|
||||
`/api${path}`,
|
||||
Object.fromEntries(
|
||||
methods.map<[string, OpenAPIV3.OperationObject]>((method) => [
|
||||
method.toLowerCase(),
|
||||
{
|
||||
tags: [toTitle(trimmedPath.split('/')[1] ?? 'General')],
|
||||
tags: [toTitle(path.split('/')[1] ?? 'General')],
|
||||
requestBody: body && {
|
||||
required: true,
|
||||
content: {
|
||||
|
@ -57,7 +54,7 @@ export default function swaggerRoutes() {
|
|||
};
|
||||
|
||||
ctx.body = document;
|
||||
});
|
||||
|
||||
return router.routes();
|
||||
return next();
|
||||
});
|
||||
}
|
||||
|
|
|
@ -27,6 +27,7 @@ importers:
|
|||
'@types/koa-logger': ^3.1.1
|
||||
'@types/koa-mount': ^4.0.0
|
||||
'@types/koa-router': ^7.4.2
|
||||
'@types/koa-static': ^4.0.2
|
||||
'@types/lodash.pick': ^4.4.6
|
||||
'@types/node': ^16.3.1
|
||||
'@types/oidc-provider': ^7.4.1
|
||||
|
@ -45,6 +46,7 @@ importers:
|
|||
koa-mount: ^4.0.0
|
||||
koa-proxies: ^0.12.1
|
||||
koa-router: ^10.0.0
|
||||
koa-static: ^5.0.0
|
||||
lint-staged: ^11.1.1
|
||||
lodash.pick: ^4.4.0
|
||||
module-alias: ^2.2.2
|
||||
|
@ -74,6 +76,7 @@ importers:
|
|||
koa-mount: 4.0.0
|
||||
koa-proxies: 0.12.1_koa@2.13.1
|
||||
koa-router: 10.0.0
|
||||
koa-static: 5.0.0
|
||||
lodash.pick: 4.4.0
|
||||
module-alias: 2.2.2
|
||||
nanoid: 3.1.23
|
||||
|
@ -88,6 +91,7 @@ importers:
|
|||
'@types/koa-logger': 3.1.1
|
||||
'@types/koa-mount': 4.0.0
|
||||
'@types/koa-router': 7.4.4
|
||||
'@types/koa-static': 4.0.2
|
||||
'@types/lodash.pick': 4.4.6
|
||||
'@types/node': 16.4.6
|
||||
'@types/oidc-provider': 7.4.2
|
||||
|
@ -3142,6 +3146,19 @@ packages:
|
|||
'@types/koa': 2.13.4
|
||||
dev: true
|
||||
|
||||
/@types/koa-send/4.1.3:
|
||||
resolution: {integrity: sha512-daaTqPZlgjIJycSTNjKpHYuKhXYP30atFc1pBcy6HHqB9+vcymDgYTguPdx9tO4HMOqNyz6bz/zqpxt5eLR+VA==}
|
||||
dependencies:
|
||||
'@types/koa': 2.13.4
|
||||
dev: true
|
||||
|
||||
/@types/koa-static/4.0.2:
|
||||
resolution: {integrity: sha512-ns/zHg+K6XVPMuohjpOlpkR1WLa4VJ9czgUP9bxkCDn0JZBtUWbD/wKDZzPGDclkQK1bpAEScufCHOy8cbfL0w==}
|
||||
dependencies:
|
||||
'@types/koa': 2.13.4
|
||||
'@types/koa-send': 4.1.3
|
||||
dev: true
|
||||
|
||||
/@types/koa/2.13.4:
|
||||
resolution: {integrity: sha512-dfHYMfU+z/vKtQB7NUrthdAEiSvnLebvBjwHtfFmpZmB7em2N3WVQdHgnFq+xvyVgxW5jKDmjWfLD3lw4g4uTw==}
|
||||
dependencies:
|
||||
|
@ -5832,7 +5849,6 @@ packages:
|
|||
resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==}
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
dev: true
|
||||
|
||||
/debug/4.3.2:
|
||||
resolution: {integrity: sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw==}
|
||||
|
@ -8029,7 +8045,6 @@ packages:
|
|||
inherits: 2.0.3
|
||||
setprototypeof: 1.1.0
|
||||
statuses: 1.5.0
|
||||
dev: true
|
||||
|
||||
/http-errors/1.7.2:
|
||||
resolution: {integrity: sha512-uUQBt3H/cSIVfch6i1EuPNy/YsRSOUBXTVfZ+yR7Zjez3qjBz6i9+i4zjNaoqcoFVI4lQJ5plg63TvGfRSDCRg==}
|
||||
|
@ -8325,7 +8340,6 @@ packages:
|
|||
|
||||
/inherits/2.0.3:
|
||||
resolution: {integrity: sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=}
|
||||
dev: true
|
||||
|
||||
/inherits/2.0.4:
|
||||
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
|
||||
|
@ -9664,6 +9678,27 @@ packages:
|
|||
- supports-color
|
||||
dev: false
|
||||
|
||||
/koa-send/5.0.1:
|
||||
resolution: {integrity: sha512-tmcyQ/wXXuxpDxyNXv5yNNkdAMdFRqwtegBXUaowiQzUKqJehttS0x2j0eOZDQAyloAth5w6wwBImnFzkUz3pQ==}
|
||||
engines: {node: '>= 8'}
|
||||
dependencies:
|
||||
debug: 4.3.2
|
||||
http-errors: 1.8.0
|
||||
resolve-path: 1.4.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
dev: false
|
||||
|
||||
/koa-static/5.0.0:
|
||||
resolution: {integrity: sha512-UqyYyH5YEXaJrf9S8E23GoJFQZXkBVJ9zYYMPGz919MSX1KuvAcycIuS0ci150HCoPf4XQVhQ84Qf8xRPWxFaQ==}
|
||||
engines: {node: '>= 7.6.0'}
|
||||
dependencies:
|
||||
debug: 3.2.7
|
||||
koa-send: 5.0.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
dev: false
|
||||
|
||||
/koa/2.13.1:
|
||||
resolution: {integrity: sha512-Lb2Dloc72auj5vK4X4qqL7B5jyDPQaZucc9sR/71byg7ryoD1NCaCm63CShk9ID9quQvDEi1bGR/iGjCG7As3w==}
|
||||
engines: {node: ^4.8.4 || ^6.10.1 || ^7.10.1 || >= 8.1.4}
|
||||
|
@ -10556,7 +10591,6 @@ packages:
|
|||
|
||||
/ms/2.1.3:
|
||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||
dev: true
|
||||
|
||||
/multi-fork/0.0.2:
|
||||
resolution: {integrity: sha1-gFiuxGFBJMftqhWBm4juiJ0+tOA=}
|
||||
|
@ -11514,7 +11548,6 @@ packages:
|
|||
/path-is-absolute/1.0.1:
|
||||
resolution: {integrity: sha1-F0uSaHNVNP+8es5r9TpanhtcX18=}
|
||||
engines: {node: '>=0.10.0'}
|
||||
dev: true
|
||||
|
||||
/path-is-inside/1.0.2:
|
||||
resolution: {integrity: sha1-NlQX3t5EQw0cEa9hAn+s8HS9/FM=}
|
||||
|
@ -13206,6 +13239,14 @@ packages:
|
|||
global-dirs: 0.1.1
|
||||
dev: true
|
||||
|
||||
/resolve-path/1.4.0:
|
||||
resolution: {integrity: sha1-xL2p9e+y/OZSR4c6s2u02DT+Fvc=}
|
||||
engines: {node: '>= 0.8'}
|
||||
dependencies:
|
||||
http-errors: 1.6.3
|
||||
path-is-absolute: 1.0.1
|
||||
dev: false
|
||||
|
||||
/resolve-pathname/3.0.0:
|
||||
resolution: {integrity: sha512-C7rARubxI8bXFNB/hqcp/4iUeIXJhJZvFPFPiSPRnhU5UPxzMFIl+2E6yY6c4k9giDJAhtV+enfA+G89N6Csng==}
|
||||
dev: false
|
||||
|
@ -13632,7 +13673,6 @@ packages:
|
|||
|
||||
/setprototypeof/1.1.0:
|
||||
resolution: {integrity: sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==}
|
||||
dev: true
|
||||
|
||||
/setprototypeof/1.1.1:
|
||||
resolution: {integrity: sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==}
|
||||
|
|
Loading…
Reference in a new issue