mirror of
https://github.com/logto-io/logto.git
synced 2024-12-30 20:33:54 -05:00
refactor: integrate zod in schemas (#90)
This commit is contained in:
parent
27ec6fcb00
commit
4973053fda
17 changed files with 208 additions and 108 deletions
6
.gitignore
vendored
6
.gitignore
vendored
|
@ -14,11 +14,7 @@ node_modules
|
|||
|
||||
# logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
*.log*
|
||||
|
||||
# misc
|
||||
cache
|
||||
|
|
|
@ -39,7 +39,7 @@
|
|||
"oidc-provider": "^7.4.1",
|
||||
"slonik": "^23.8.3",
|
||||
"slonik-interceptor-preset": "^1.2.10",
|
||||
"zod": "^3.2.0"
|
||||
"zod": "^3.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@logto/eslint-config": "^0.1.0-rc.18",
|
||||
|
|
|
@ -5,7 +5,7 @@ import { DatabasePoolType, IdentifierSqlTokenType, sql } from 'slonik';
|
|||
import {
|
||||
conditionalSql,
|
||||
convertToIdentifiers,
|
||||
convertToPrimitive,
|
||||
convertToPrimitiveOrSql,
|
||||
excludeAutoSetFields,
|
||||
OmitAutoSetFields,
|
||||
} from './utils';
|
||||
|
@ -61,7 +61,7 @@ export const buildInsertInto: BuildInsertInto = <Schema extends SchemaLike<strin
|
|||
sql`, `
|
||||
)})
|
||||
values (${sql.join(
|
||||
keys.map((key) => convertToPrimitive(data[key] ?? null)),
|
||||
keys.map((key) => convertToPrimitiveOrSql(key, data[key] ?? null)),
|
||||
sql`, `
|
||||
)})
|
||||
${conditionalSql(returning, () => sql`returning *`)}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { Falsy, notFalsy } from '@logto/essentials';
|
||||
import { SchemaValuePrimitive, SchemaValue } from '@logto/schemas';
|
||||
import { sql, SqlSqlTokenType } from 'slonik';
|
||||
import dayjs from 'dayjs';
|
||||
import { sql, SqlSqlTokenType, SqlTokenType } from 'slonik';
|
||||
import { FieldIdentifiers, Table } from './types';
|
||||
|
||||
export const conditionalSql = <T>(
|
||||
|
@ -25,12 +26,14 @@ export const excludeAutoSetFields = <T extends string>(fields: readonly T[]) =>
|
|||
* Note `undefined` is removed from the acceptable list,
|
||||
* since you should NOT call this function if ignoring the field is the desired behavior.
|
||||
* Calling this function with `null` means an explicit `null` setting in database is expected.
|
||||
* @param key The key of value. Will treat as `timestamp` if it ends with `_at` or 'At' AND value is a number;
|
||||
* @param value The value to convert.
|
||||
* @returns A primitive that can be saved into database.
|
||||
*/
|
||||
export const convertToPrimitive = (
|
||||
export const convertToPrimitiveOrSql = (
|
||||
key: string,
|
||||
value: NonNullable<SchemaValue> | null
|
||||
): NonNullable<SchemaValuePrimitive> | null => {
|
||||
): NonNullable<SchemaValuePrimitive> | SqlTokenType | null => {
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
|
@ -39,6 +42,10 @@ export const convertToPrimitive = (
|
|||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
if (['_at', 'At'].some((value) => key.endsWith(value)) && typeof value === 'number') {
|
||||
return sql`to_timestamp(${value / 1000})`;
|
||||
}
|
||||
|
||||
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
|
@ -60,3 +67,5 @@ export const convertToIdentifiers = <T extends Table>(
|
|||
{} as FieldIdentifiers<keyof T['fields']>
|
||||
),
|
||||
});
|
||||
|
||||
export const convertToTimestamp = (time = dayjs()) => sql`to_timestamp(${time.valueOf() / 1000})`;
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { OidcClientMetadata } from '@logto/schemas';
|
||||
|
||||
export const generateOidcClientMetadata = (): OidcClientMetadata => ({
|
||||
redirect_uris: [],
|
||||
post_logout_redirect_uris: [],
|
||||
redirectUris: [],
|
||||
postLogoutRedirectUris: [],
|
||||
});
|
||||
|
|
|
@ -1,13 +1,12 @@
|
|||
import { buildInsertInto } from '@/database/insert';
|
||||
import pool from '@/database/pool';
|
||||
import { convertToIdentifiers } from '@/database/utils';
|
||||
import { convertToIdentifiers, convertToTimestamp } from '@/database/utils';
|
||||
import { conditional } from '@logto/essentials';
|
||||
import {
|
||||
OidcModelInstanceDBEntry,
|
||||
OidcModelInstancePayload,
|
||||
OidcModelInstances,
|
||||
} from '@logto/schemas';
|
||||
import dayjs from 'dayjs';
|
||||
import { sql, ValueExpressionType } from 'slonik';
|
||||
|
||||
export type WithConsumed<T> = T & { consumed?: boolean };
|
||||
|
@ -64,7 +63,7 @@ export const findPayloadByPayloadField = async <
|
|||
export const consumeInstanceById = async (modelName: string, id: string) => {
|
||||
await pool.query(sql`
|
||||
update ${table}
|
||||
set ${fields.consumedAt}=${dayjs().valueOf()}
|
||||
set ${fields.consumedAt}=${convertToTimestamp()}
|
||||
where ${fields.modelName}=${modelName}
|
||||
and ${fields.id}=${id}
|
||||
`);
|
||||
|
|
|
@ -32,7 +32,8 @@
|
|||
"pluralize": "^8.0.0",
|
||||
"prettier": "^2.3.2",
|
||||
"ts-node": "^10.0.0",
|
||||
"typescript": "^4.3.5"
|
||||
"typescript": "^4.3.5",
|
||||
"zod": "^3.8.1"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": "@logto"
|
||||
|
@ -40,5 +41,8 @@
|
|||
"prettier": "@logto/eslint-config/.prettierrc",
|
||||
"dependencies": {
|
||||
"@logto/phrases": "^0.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^3.8.1"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,13 @@
|
|||
// THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
|
||||
|
||||
import { OidcClientMetadata, GeneratedSchema } from '../foundations';
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
OidcClientMetadata,
|
||||
oidcClientMetadataGuard,
|
||||
GeneratedSchema,
|
||||
Guard,
|
||||
} from '../foundations';
|
||||
|
||||
import { ApplicationType } from './custom-types';
|
||||
|
||||
|
@ -12,6 +19,14 @@ export type ApplicationDBEntry = {
|
|||
createdAt: number;
|
||||
};
|
||||
|
||||
const guard: Guard<ApplicationDBEntry> = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
type: z.nativeEnum(ApplicationType),
|
||||
oidcClientMetadata: oidcClientMetadataGuard,
|
||||
createdAt: z.number(),
|
||||
});
|
||||
|
||||
export const Applications: GeneratedSchema<ApplicationDBEntry> = Object.freeze({
|
||||
table: 'applications',
|
||||
tableSingular: 'application',
|
||||
|
@ -23,4 +38,5 @@ export const Applications: GeneratedSchema<ApplicationDBEntry> = Object.freeze({
|
|||
createdAt: 'created_at',
|
||||
},
|
||||
fieldKeys: ['id', 'name', 'type', 'oidcClientMetadata', 'createdAt'],
|
||||
guard,
|
||||
});
|
||||
|
|
|
@ -1,6 +1,13 @@
|
|||
// THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
|
||||
|
||||
import { OidcModelInstancePayload, GeneratedSchema } from '../foundations';
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
OidcModelInstancePayload,
|
||||
oidcModelInstancePayloadGuard,
|
||||
GeneratedSchema,
|
||||
Guard,
|
||||
} from '../foundations';
|
||||
|
||||
export type OidcModelInstanceDBEntry = {
|
||||
modelName: string;
|
||||
|
@ -10,6 +17,14 @@ export type OidcModelInstanceDBEntry = {
|
|||
consumedAt?: number;
|
||||
};
|
||||
|
||||
const guard: Guard<OidcModelInstanceDBEntry> = z.object({
|
||||
modelName: z.string(),
|
||||
id: z.string(),
|
||||
payload: oidcModelInstancePayloadGuard,
|
||||
expiresAt: z.number(),
|
||||
consumedAt: z.number().optional(),
|
||||
});
|
||||
|
||||
export const OidcModelInstances: GeneratedSchema<OidcModelInstanceDBEntry> = Object.freeze({
|
||||
table: 'oidc_model_instances',
|
||||
tableSingular: 'oidc_model_instance',
|
||||
|
@ -21,4 +36,5 @@ export const OidcModelInstances: GeneratedSchema<OidcModelInstanceDBEntry> = Obj
|
|||
consumedAt: 'consumed_at',
|
||||
},
|
||||
fieldKeys: ['modelName', 'id', 'payload', 'expiresAt', 'consumedAt'],
|
||||
guard,
|
||||
});
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
// THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
|
||||
|
||||
import { GeneratedSchema } from '../foundations';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { GeneratedSchema, Guard } from '../foundations';
|
||||
|
||||
import { PasswordEncryptionMethod } from './custom-types';
|
||||
|
||||
|
@ -14,6 +16,16 @@ export type UserDBEntry = {
|
|||
passwordEncryptionSalt?: string;
|
||||
};
|
||||
|
||||
const guard: Guard<UserDBEntry> = z.object({
|
||||
id: z.string(),
|
||||
username: z.string().optional(),
|
||||
primaryEmail: z.string().optional(),
|
||||
primaryPhone: z.string().optional(),
|
||||
passwordEncrypted: z.string().optional(),
|
||||
passwordEncryptionMethod: z.nativeEnum(PasswordEncryptionMethod).optional(),
|
||||
passwordEncryptionSalt: z.string().optional(),
|
||||
});
|
||||
|
||||
export const Users: GeneratedSchema<UserDBEntry> = Object.freeze({
|
||||
table: 'users',
|
||||
tableSingular: 'user',
|
||||
|
@ -35,4 +47,5 @@ export const Users: GeneratedSchema<UserDBEntry> = Object.freeze({
|
|||
'passwordEncryptionMethod',
|
||||
'passwordEncryptionSalt',
|
||||
],
|
||||
guard,
|
||||
});
|
||||
|
|
2
packages/schemas/src/foundations/index.ts
Normal file
2
packages/schemas/src/foundations/index.ts
Normal file
|
@ -0,0 +1,2 @@
|
|||
export * from './schemas';
|
||||
export * from './jsonb-types';
|
22
packages/schemas/src/foundations/jsonb-types.ts
Normal file
22
packages/schemas/src/foundations/jsonb-types.ts
Normal file
|
@ -0,0 +1,22 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
export const oidcModelInstancePayloadGuard = z
|
||||
.object({
|
||||
userCode: z.string().optional(),
|
||||
uid: z.string().optional(),
|
||||
grantId: z.string().optional(),
|
||||
})
|
||||
/**
|
||||
* Try to use `.passthrough()` if type has been fixed.
|
||||
* https://github.com/colinhacks/zod/issues/452
|
||||
*/
|
||||
.catchall(z.unknown());
|
||||
|
||||
export type OidcModelInstancePayload = z.infer<typeof oidcModelInstancePayloadGuard>;
|
||||
|
||||
export const oidcClientMetadataGuard = z.object({
|
||||
redirectUris: z.string().array(),
|
||||
postLogoutRedirectUris: z.string().array(),
|
||||
});
|
||||
|
||||
export type OidcClientMetadata = z.infer<typeof oidcClientMetadataGuard>;
|
|
@ -1,3 +1,11 @@
|
|||
import { ZodObject, ZodType } from 'zod';
|
||||
|
||||
export type Guard<T extends Record<string, unknown>> = ZodObject<
|
||||
{
|
||||
[key in keyof T]: ZodType<T[key]>;
|
||||
}
|
||||
>;
|
||||
|
||||
export type SchemaValuePrimitive = string | number | boolean | undefined;
|
||||
export type SchemaValue = SchemaValuePrimitive | Record<string, unknown>;
|
||||
export type SchemaLike<Key extends string> = {
|
||||
|
@ -12,16 +20,6 @@ export type GeneratedSchema<Schema extends SchemaLike<string>> = keyof Schema ex
|
|||
[key in keyof Schema]: string;
|
||||
};
|
||||
fieldKeys: ReadonlyArray<keyof Schema>;
|
||||
guard: Guard<Schema>;
|
||||
}>
|
||||
: never;
|
||||
|
||||
export type OidcModelInstancePayload = Record<string, unknown> & {
|
||||
userCode?: string;
|
||||
uid?: string;
|
||||
grantId?: string;
|
||||
};
|
||||
|
||||
export type OidcClientMetadata = {
|
||||
redirect_uris: string[];
|
||||
post_logout_redirect_uris: string[];
|
||||
};
|
|
@ -7,43 +7,8 @@ import uniq from 'lodash.uniq';
|
|||
import { conditional, conditionalString } from '@logto/essentials';
|
||||
|
||||
import { findFirstParentheses, getType, normalizeWhitespaces, removeParentheses } from './utils';
|
||||
|
||||
type Field = {
|
||||
name: string;
|
||||
type?: string;
|
||||
customType?: string;
|
||||
tsType?: string;
|
||||
required: boolean;
|
||||
isArray: boolean;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
type FieldWithType = Omit<Field, 'type' | 'customType'> & { type: string };
|
||||
|
||||
type Type = {
|
||||
name: string;
|
||||
type: 'enum';
|
||||
values: string[];
|
||||
};
|
||||
|
||||
type GeneratedType = Type & {
|
||||
tsName: string;
|
||||
};
|
||||
|
||||
type Table = {
|
||||
name: string;
|
||||
fields: Field[];
|
||||
};
|
||||
|
||||
type TableWithType = {
|
||||
name: string;
|
||||
fields: FieldWithType[];
|
||||
};
|
||||
|
||||
type FileData = {
|
||||
types: Type[];
|
||||
tables: Table[];
|
||||
};
|
||||
import { FileData, Table, Field, Type, GeneratedType, TableWithType } from './types';
|
||||
import { generateSchema } from './schema';
|
||||
|
||||
const directory = 'tables';
|
||||
|
||||
|
@ -175,19 +140,23 @@ const generate = async () => {
|
|||
assert(finalType, `Type ${customType ?? 'N/A'} not found`);
|
||||
|
||||
if (tsType) {
|
||||
tsTypes.push(tsType);
|
||||
} else if (type === undefined) {
|
||||
tsTypes.push(tsType, `${camelcase(tsType)}Guard`);
|
||||
} else if (!type) {
|
||||
customTypes.push(finalType);
|
||||
}
|
||||
|
||||
return { ...rest, type: finalType };
|
||||
return { ...rest, tsType, type: finalType, isEnum: !tsType && !type };
|
||||
}),
|
||||
}));
|
||||
|
||||
if (tableWithTypes.length > 0) {
|
||||
tsTypes.push('GeneratedSchema');
|
||||
tsTypes.push('GeneratedSchema', 'Guard');
|
||||
}
|
||||
|
||||
const importZod = conditionalString(
|
||||
tableWithTypes.length > 0 && "import { z } from 'zod';\n\n"
|
||||
);
|
||||
|
||||
const importTsTypes = conditionalString(
|
||||
tsTypes.length > 0 &&
|
||||
[
|
||||
|
@ -212,39 +181,10 @@ const generate = async () => {
|
|||
|
||||
const content =
|
||||
header +
|
||||
importZod +
|
||||
importTsTypes +
|
||||
importTypes +
|
||||
tableWithTypes
|
||||
.map(({ name, fields }) => {
|
||||
const databaseEntryType = `${pluralize(
|
||||
camelcase(name, { pascalCase: true }),
|
||||
1
|
||||
)}DBEntry`;
|
||||
return [
|
||||
`export type ${databaseEntryType} = {`,
|
||||
...fields.map(
|
||||
({ name, type, isArray, required }) =>
|
||||
` ${camelcase(name)}${conditionalString(
|
||||
!required && '?'
|
||||
)}: ${type}${conditionalString(isArray && '[]')};`
|
||||
),
|
||||
'};',
|
||||
'',
|
||||
`export const ${camelcase(name, {
|
||||
pascalCase: true,
|
||||
})}: GeneratedSchema<${databaseEntryType}> = Object.freeze({`,
|
||||
` table: '${name}',`,
|
||||
` tableSingular: '${pluralize(name, 1)}',`,
|
||||
' fields: {',
|
||||
...fields.map(({ name }) => ` ${camelcase(name)}: '${name}',`),
|
||||
' },',
|
||||
' fieldKeys: [',
|
||||
...fields.map(({ name }) => ` '${camelcase(name)}',`),
|
||||
' ],',
|
||||
'});',
|
||||
].join('\n');
|
||||
})
|
||||
.join('\n') +
|
||||
tableWithTypes.map((table) => generateSchema(table)).join('\n') +
|
||||
'\n';
|
||||
await fs.writeFile(path.join(generatedDirectory, getOutputFileName(file) + '.ts'), content);
|
||||
})
|
||||
|
|
48
packages/schemas/src/gen/schema.ts
Normal file
48
packages/schemas/src/gen/schema.ts
Normal file
|
@ -0,0 +1,48 @@
|
|||
import pluralize from 'pluralize';
|
||||
import camelcase from 'camelcase';
|
||||
import { conditionalString } from '@logto/essentials';
|
||||
import { TableWithType } from './types';
|
||||
|
||||
export const generateSchema = ({ name, fields }: TableWithType) => {
|
||||
const databaseEntryType = `${pluralize(camelcase(name, { pascalCase: true }), 1)}DBEntry`;
|
||||
return [
|
||||
`export type ${databaseEntryType} = {`,
|
||||
...fields.map(
|
||||
({ name, type, isArray, required }) =>
|
||||
` ${camelcase(name)}${conditionalString(!required && '?')}: ${type}${conditionalString(
|
||||
isArray && '[]'
|
||||
)};`
|
||||
),
|
||||
'};',
|
||||
'',
|
||||
`const guard: Guard<${databaseEntryType}> = z.object({`,
|
||||
...fields.map(({ name, type, isArray, isEnum, required, tsType }) => {
|
||||
if (tsType) {
|
||||
return ` ${camelcase(name)}: ${camelcase(tsType)}Guard${conditionalString(
|
||||
!required && '.optional()'
|
||||
)},`;
|
||||
}
|
||||
|
||||
return ` ${camelcase(name)}: z.${
|
||||
isEnum ? `nativeEnum(${type})` : `${type}()`
|
||||
}${conditionalString(isArray && '.array()')}${conditionalString(
|
||||
!required && '.optional()'
|
||||
)},`;
|
||||
}),
|
||||
' });',
|
||||
'',
|
||||
`export const ${camelcase(name, {
|
||||
pascalCase: true,
|
||||
})}: GeneratedSchema<${databaseEntryType}> = Object.freeze({`,
|
||||
` table: '${name}',`,
|
||||
` tableSingular: '${pluralize(name, 1)}',`,
|
||||
' fields: {',
|
||||
...fields.map(({ name }) => ` ${camelcase(name)}: '${name}',`),
|
||||
' },',
|
||||
' fieldKeys: [',
|
||||
...fields.map(({ name }) => ` '${camelcase(name)}',`),
|
||||
' ],',
|
||||
' guard,',
|
||||
'});',
|
||||
].join('\n');
|
||||
};
|
36
packages/schemas/src/gen/types.ts
Normal file
36
packages/schemas/src/gen/types.ts
Normal file
|
@ -0,0 +1,36 @@
|
|||
export type Field = {
|
||||
name: string;
|
||||
type?: string;
|
||||
customType?: string;
|
||||
tsType?: string;
|
||||
required: boolean;
|
||||
isArray: boolean;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
export type FieldWithType = Omit<Field, 'type' | 'customType'> & { type: string; isEnum: boolean };
|
||||
|
||||
export type Type = {
|
||||
name: string;
|
||||
type: 'enum';
|
||||
values: string[];
|
||||
};
|
||||
|
||||
export type GeneratedType = Type & {
|
||||
tsName: string;
|
||||
};
|
||||
|
||||
export type Table = {
|
||||
name: string;
|
||||
fields: Field[];
|
||||
};
|
||||
|
||||
export type TableWithType = {
|
||||
name: string;
|
||||
fields: FieldWithType[];
|
||||
};
|
||||
|
||||
export type FileData = {
|
||||
types: Type[];
|
||||
tables: Table[];
|
||||
};
|
|
@ -62,7 +62,7 @@ importers:
|
|||
ts-jest: ^27.0.5
|
||||
tsc-watch: ^4.4.0
|
||||
typescript: ^4.3.5
|
||||
zod: ^3.2.0
|
||||
zod: ^3.8.1
|
||||
dependencies:
|
||||
'@logto/essentials': 1.1.0-rc.2
|
||||
'@logto/phrases': link:../phrases
|
||||
|
@ -87,7 +87,7 @@ importers:
|
|||
oidc-provider: 7.5.4
|
||||
slonik: 23.8.5
|
||||
slonik-interceptor-preset: 1.2.10
|
||||
zod: 3.5.1
|
||||
zod: 3.8.1
|
||||
devDependencies:
|
||||
'@logto/eslint-config': 0.1.0-rc.18_2055f56ab8dafa07df5c7ad406c8a4ab
|
||||
'@logto/ts-config': 0.1.0-rc.18_54a571252e94826a463f89cda755e2f0
|
||||
|
@ -139,6 +139,7 @@ importers:
|
|||
prettier: ^2.3.2
|
||||
ts-node: ^10.0.0
|
||||
typescript: ^4.3.5
|
||||
zod: ^3.8.1
|
||||
dependencies:
|
||||
'@logto/phrases': link:../phrases
|
||||
devDependencies:
|
||||
|
@ -155,6 +156,7 @@ importers:
|
|||
prettier: 2.3.2
|
||||
ts-node: 10.1.0_13403c2f2d9ddab699dd2f492f123cbf
|
||||
typescript: 4.3.5
|
||||
zod: 3.8.1
|
||||
|
||||
packages/ui:
|
||||
specifiers:
|
||||
|
@ -16664,9 +16666,8 @@ packages:
|
|||
engines: {node: '>=10'}
|
||||
dev: true
|
||||
|
||||
/zod/3.5.1:
|
||||
resolution: {integrity: sha512-Gg9GTai0iDHowuYM9VNhdFMmesgt44ufzqaE5CPHshpuK5fCzbibdqCnrWuYH6ZmOn/N+BlGmwZtVSijhKmhKw==}
|
||||
dev: false
|
||||
/zod/3.8.1:
|
||||
resolution: {integrity: sha512-u4Uodl7dLh8nXZwqXL1SM5FAl5b4lXYHOxMUVb9lqhlEAZhA2znX+0oW480m0emGFMxpoRHzUncAqRkc4h8ZJA==}
|
||||
|
||||
/zwitch/1.0.5:
|
||||
resolution: {integrity: sha512-V50KMwwzqJV0NpZIZFwfOD5/lyny3WlSzRiXgA0G7VUnRlqttta1L6UQIHzd6EuBY/cHGfwTIck7w1yH6Q5zUw==}
|
||||
|
|
Loading…
Reference in a new issue