* Better permisson Fix #2341 * add kinds.ts * test * fix * v11 * fix
This commit is contained in:
parent
b357afa30a
commit
94f8a145ec
|
@ -74,10 +74,26 @@ common:
|
||||||
favorites: "お気に入り"
|
favorites: "お気に入り"
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
'read:account': "アカウントの情報を見る"
|
"read:account": "アカウントの情報を見る"
|
||||||
'write:account': "アカウントの情報を変更する"
|
"write:account": "アカウントの情報を変更する"
|
||||||
'read:drive': "ドライブを見る"
|
"read:blocks": "ブロックを見る"
|
||||||
'write:drive': "ドライブを操作する"
|
"write:blocks": "ブロックを操作する"
|
||||||
|
"read:drive": "ドライブを見る"
|
||||||
|
"write:drive": "ドライブを操作する"
|
||||||
|
"read:favorites": "お気に入りを見る"
|
||||||
|
"write:favorites": "お気に入りを操作する"
|
||||||
|
"read:following": "フォローの情報を見る"
|
||||||
|
"write:following": "フォロー・フォロー解除する"
|
||||||
|
"read:messaging": "トークを見る"
|
||||||
|
"write:messaging": "トークを操作する"
|
||||||
|
"read:mutes": "ミュートを見る"
|
||||||
|
"write:mutes": "ミュートを操作する"
|
||||||
|
"write:notes": "投稿を作成・削除する"
|
||||||
|
"read:notifications": "通知を見る"
|
||||||
|
"write:notifications": "通知を操作する"
|
||||||
|
"read:reactions": "リアクションを見る"
|
||||||
|
"write:reactions": "リアクションを操作する"
|
||||||
|
"write:votes": "投票する"
|
||||||
|
|
||||||
empty-timeline-info:
|
empty-timeline-info:
|
||||||
follow-users-to-make-your-timeline: "ユーザーをフォローすると投稿がタイムラインに表示されます。"
|
follow-users-to-make-your-timeline: "ユーザーをフォローすると投稿がタイムラインに表示されます。"
|
||||||
|
@ -1804,14 +1820,17 @@ dev/views/apps.vue:
|
||||||
app-missing: "アプリなし"
|
app-missing: "アプリなし"
|
||||||
|
|
||||||
dev/views/new-app.vue:
|
dev/views/new-app.vue:
|
||||||
|
new-app: "新しいアプリケーション"
|
||||||
|
new-app-info: "アプリケーションはAPIからでも作成できます。 (app/create)"
|
||||||
create-app: "アプリケーションの作成"
|
create-app: "アプリケーションの作成"
|
||||||
app-name: "アプリケーション名"
|
app-name: "アプリケーション名"
|
||||||
|
app-name-placeholder: "ex) Misskey for iOS"
|
||||||
app-name-desc: "あなたのアプリの名称。"
|
app-name-desc: "あなたのアプリの名称。"
|
||||||
app-name-ex: "ex) Misskey for iOS"
|
|
||||||
app-overview: "アプリの概要"
|
app-overview: "アプリの概要"
|
||||||
app-desc: "あなたのアプリの簡単な説明や紹介。"
|
app-overview-placeholder: " ex) Misskey iOSクライアント。"
|
||||||
app-desc-ex: "ex) Misskey iOSクライアント。"
|
app-overview-desc: "あなたのアプリの簡単な説明や紹介。"
|
||||||
callback-url: "コールバックURL (オプション)"
|
callback-url: "コールバックURL (オプション)"
|
||||||
|
callback-url-placeholder: "ex) https://your.app.example.com/callback.php"
|
||||||
callback-url-desc: "ユーザーが認証フォームで認証した際にリダイレクトするURLを設定できます。"
|
callback-url-desc: "ユーザーが認証フォームで認証した際にリダイレクトするURLを設定できます。"
|
||||||
authority: "権限"
|
authority: "権限"
|
||||||
authority-desc: "ここで要求した機能だけがAPIからアクセスできます。"
|
authority-desc: "ここで要求した機能だけがAPIからアクセスできます。"
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<template>
|
<template>
|
||||||
<mk-ui>
|
<mk-ui>
|
||||||
<b-card :header="$t('header')">
|
<b-card :header="$t('manage-apps')">
|
||||||
<b-button to="/app/new" variant="primary">{{ $t('create-app') }}</b-button>
|
<b-button to="/app/new" variant="primary">{{ $t('create-app') }}</b-button>
|
||||||
<hr>
|
<hr>
|
||||||
<div class="apps">
|
<div class="apps">
|
||||||
|
|
|
@ -1,35 +1,22 @@
|
||||||
<template>
|
<template>
|
||||||
<mk-ui>
|
<mk-ui>
|
||||||
<b-card :header="$t('header')">
|
<b-card :header="$t('new-app')">
|
||||||
|
<b-alert show variant="info"><fa icon="info-circle"/> {{ $t('new-app-info') }}</b-alert>
|
||||||
<b-form @submit.prevent="onSubmit" autocomplete="off">
|
<b-form @submit.prevent="onSubmit" autocomplete="off">
|
||||||
<b-form-group :label="$t('app-name')" :description="$t('description')">
|
<b-form-group :label="$t('app-name')" :description="$t('app-name-desc')">
|
||||||
<b-form-input v-model="name" type="text" :placeholder="$t('placeholder')" autocomplete="off" required/>
|
<b-form-input v-model="name" type="text" :placeholder="$t('app-name-placeholder')" autocomplete="off" required/>
|
||||||
</b-form-group>
|
</b-form-group>
|
||||||
<b-form-group :label="$t('app-overview')" :description="$t('description')">
|
<b-form-group :label="$t('app-overview')" :description="$t('app-overview-desc')">
|
||||||
<b-textarea v-model="description" :placeholder="$t('placeholder')" autocomplete="off" required></b-textarea>
|
<b-textarea v-model="description" :placeholder="$t('app-overview-placeholder')" autocomplete="off" required></b-textarea>
|
||||||
</b-form-group>
|
</b-form-group>
|
||||||
<b-form-group :label="$t('callback-url')" :description="$t('description')">
|
<b-form-group :label="$t('callback-url')" :description="$t('callback-url-desc')">
|
||||||
<b-input v-model="cb" type="url" placeholder="ex) https://your.app.example.com/callback.php" autocomplete="off"/>
|
<b-input v-model="cb" type="url" :placeholder="$t('callback-url-placeholder')" autocomplete="off"/>
|
||||||
</b-form-group>
|
</b-form-group>
|
||||||
<b-card :header="$t('header')">
|
<b-card :header="$t('authority')">
|
||||||
<b-form-group :description="$t('description')">
|
<b-form-group :description="$t('authority-desc')">
|
||||||
<b-alert show variant="warning"><fa icon="exclamation-triangle"/> {{ $t('authority-warning') }}</b-alert>
|
<b-alert show variant="warning"><fa icon="exclamation-triangle"/> {{ $t('authority-warning') }}</b-alert>
|
||||||
<b-form-checkbox-group v-model="permission" stacked>
|
<b-form-checkbox-group v-model="permission" stacked>
|
||||||
<b-form-checkbox value="read:account">{{ $t('read:account') }}</b-form-checkbox>
|
<b-form-checkbox v-for="v in permissionsList" :value="v" :key="v">{{ $t(`@.permissions.${v}`) }} ({{ v }})</b-form-checkbox>
|
||||||
<b-form-checkbox value="write:account">{{ $t('write:account') }}</b-form-checkbox>
|
|
||||||
<b-form-checkbox value="write:notes">{{ $t('write:notes') }}</b-form-checkbox>
|
|
||||||
<b-form-checkbox value="read:reactions">{{ $t('read:reactions') }}</b-form-checkbox>
|
|
||||||
<b-form-checkbox value="write:reactions">{{ $t('write:reactions') }}</b-form-checkbox>
|
|
||||||
<b-form-checkbox value="read:following">{{ $t('read:following') }}</b-form-checkbox>
|
|
||||||
<b-form-checkbox value="write:following">{{ $t('write:following') }}</b-form-checkbox>
|
|
||||||
<b-form-checkbox value="read:mutes">{{ $t('read:mutes') }}</b-form-checkbox>
|
|
||||||
<b-form-checkbox value="write:mutes">{{ $t('write:mutes') }}</b-form-checkbox>
|
|
||||||
<b-form-checkbox value="read:blocks">{{ $t('read:blocks') }}</b-form-checkbox>
|
|
||||||
<b-form-checkbox value="write:blocks">{{ $t('write:blocks') }}</b-form-checkbox>
|
|
||||||
<b-form-checkbox value="read:drive">{{ $t('read:drive') }}</b-form-checkbox>
|
|
||||||
<b-form-checkbox value="write:drive">{{ $t('write:drive') }}</b-form-checkbox>
|
|
||||||
<b-form-checkbox value="read:notifications">{{ $t('read:notifications') }}</b-form-checkbox>
|
|
||||||
<b-form-checkbox value="write:notifications">{{ $t('write:notifications') }}</b-form-checkbox>
|
|
||||||
</b-form-checkbox-group>
|
</b-form-checkbox-group>
|
||||||
</b-form-group>
|
</b-form-group>
|
||||||
</b-card>
|
</b-card>
|
||||||
|
@ -43,6 +30,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Vue from 'vue';
|
import Vue from 'vue';
|
||||||
import i18n from '../../i18n';
|
import i18n from '../../i18n';
|
||||||
|
|
||||||
export default Vue.extend({
|
export default Vue.extend({
|
||||||
i18n: i18n('dev/views/new-app.vue'),
|
i18n: i18n('dev/views/new-app.vue'),
|
||||||
data() {
|
data() {
|
||||||
|
@ -51,9 +39,15 @@ export default Vue.extend({
|
||||||
description: '',
|
description: '',
|
||||||
cb: '',
|
cb: '',
|
||||||
nidState: null,
|
nidState: null,
|
||||||
permission: []
|
permission: [],
|
||||||
|
permissionsList: []
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
created() {
|
||||||
|
this.$root.api('permissions').then(permissions => {
|
||||||
|
this.permissionsList = permissions
|
||||||
|
});
|
||||||
|
},
|
||||||
methods: {
|
methods: {
|
||||||
onSubmit() {
|
onSubmit() {
|
||||||
this.$root.api('app/create', {
|
this.$root.api('app/create', {
|
||||||
|
|
|
@ -115,3 +115,8 @@ export function cumulativeSum(xs: number[]): number[] {
|
||||||
for (let i = 1; i < ys.length; i++) ys[i] += ys[i - 1];
|
for (let i = 1; i < ys.length; i++) ys[i] += ys[i - 1];
|
||||||
return ys;
|
return ys;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Object.fromEntries()
|
||||||
|
export function fromEntries(xs: [string, any][]): { [x: string]: any; } {
|
||||||
|
return xs.reduce((obj, [k, v]) => Object.assign(obj, { [k]: v }), {} as { [x: string]: any; });
|
||||||
|
}
|
||||||
|
|
|
@ -14,7 +14,7 @@ export const meta = {
|
||||||
|
|
||||||
requireCredential: true,
|
requireCredential: true,
|
||||||
|
|
||||||
kind: 'favorites-read',
|
kind: 'read:favorites',
|
||||||
|
|
||||||
params: {
|
params: {
|
||||||
limit: {
|
limit: {
|
||||||
|
|
|
@ -14,7 +14,7 @@ export const meta = {
|
||||||
|
|
||||||
requireCredential: true,
|
requireCredential: true,
|
||||||
|
|
||||||
kind: 'messaging-read',
|
kind: 'read:messaging',
|
||||||
|
|
||||||
params: {
|
params: {
|
||||||
limit: {
|
limit: {
|
||||||
|
|
|
@ -17,7 +17,7 @@ export const meta = {
|
||||||
|
|
||||||
requireCredential: true,
|
requireCredential: true,
|
||||||
|
|
||||||
kind: 'messaging-read',
|
kind: 'read:messaging',
|
||||||
|
|
||||||
params: {
|
params: {
|
||||||
userId: {
|
userId: {
|
||||||
|
|
|
@ -20,7 +20,7 @@ export const meta = {
|
||||||
|
|
||||||
requireCredential: true,
|
requireCredential: true,
|
||||||
|
|
||||||
kind: 'messaging-write',
|
kind: 'write:messaging',
|
||||||
|
|
||||||
params: {
|
params: {
|
||||||
userId: {
|
userId: {
|
||||||
|
|
|
@ -18,7 +18,7 @@ export const meta = {
|
||||||
|
|
||||||
requireCredential: true,
|
requireCredential: true,
|
||||||
|
|
||||||
kind: 'messaging-write',
|
kind: 'write:messaging',
|
||||||
|
|
||||||
limit: {
|
limit: {
|
||||||
duration: ms('1hour'),
|
duration: ms('1hour'),
|
||||||
|
|
|
@ -15,7 +15,7 @@ export const meta = {
|
||||||
|
|
||||||
requireCredential: true,
|
requireCredential: true,
|
||||||
|
|
||||||
kind: 'messaging-write',
|
kind: 'write:messaging',
|
||||||
|
|
||||||
params: {
|
params: {
|
||||||
messageId: {
|
messageId: {
|
||||||
|
|
|
@ -18,7 +18,7 @@ export const meta = {
|
||||||
|
|
||||||
requireCredential: true,
|
requireCredential: true,
|
||||||
|
|
||||||
kind: 'favorite-write',
|
kind: 'write:favorites',
|
||||||
|
|
||||||
params: {
|
params: {
|
||||||
noteId: {
|
noteId: {
|
||||||
|
|
|
@ -17,7 +17,7 @@ export const meta = {
|
||||||
|
|
||||||
requireCredential: true,
|
requireCredential: true,
|
||||||
|
|
||||||
kind: 'favorite-write',
|
kind: 'write:favorites',
|
||||||
|
|
||||||
params: {
|
params: {
|
||||||
noteId: {
|
noteId: {
|
||||||
|
|
|
@ -26,7 +26,7 @@ export const meta = {
|
||||||
|
|
||||||
requireCredential: true,
|
requireCredential: true,
|
||||||
|
|
||||||
kind: 'vote-write',
|
kind: 'write:votes',
|
||||||
|
|
||||||
params: {
|
params: {
|
||||||
noteId: {
|
noteId: {
|
||||||
|
|
|
@ -0,0 +1,29 @@
|
||||||
|
import define from '../define';
|
||||||
|
import { kindsList } from '../kinds';
|
||||||
|
|
||||||
|
export const meta = {
|
||||||
|
stability: 'stable',
|
||||||
|
|
||||||
|
desc: {
|
||||||
|
'ja-JP': 'パーミッションの一覧を返します。',
|
||||||
|
'en-US': 'Get the list of permissons.'
|
||||||
|
},
|
||||||
|
|
||||||
|
tags: ['meta'],
|
||||||
|
|
||||||
|
requireCredential: false,
|
||||||
|
|
||||||
|
params: {
|
||||||
|
},
|
||||||
|
|
||||||
|
res: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'string',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default define(meta, async () => {
|
||||||
|
return kindsList;
|
||||||
|
});
|
|
@ -0,0 +1,58 @@
|
||||||
|
import endpoints from './endpoints';
|
||||||
|
import * as locale from '../../../locales/';
|
||||||
|
import { fromEntries } from '../../prelude/array';
|
||||||
|
|
||||||
|
export const kindsList = [
|
||||||
|
'read:account',
|
||||||
|
'write:account',
|
||||||
|
'read:blocks',
|
||||||
|
'write:blocks',
|
||||||
|
'read:drive',
|
||||||
|
'write:drive',
|
||||||
|
'read:favorites',
|
||||||
|
'write:favorites',
|
||||||
|
'read:following',
|
||||||
|
'write:following',
|
||||||
|
'read:messaging',
|
||||||
|
'write:messaging',
|
||||||
|
'read:mutes',
|
||||||
|
'write:mutes',
|
||||||
|
'write:notes',
|
||||||
|
'read:notifications',
|
||||||
|
'write:notifications',
|
||||||
|
'read:reactions',
|
||||||
|
'write:reactions',
|
||||||
|
'write:votes'
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface IKindInfo {
|
||||||
|
endpoints: string[];
|
||||||
|
descs: { [x: string]: string; };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function kinds() {
|
||||||
|
const kinds = fromEntries(
|
||||||
|
kindsList
|
||||||
|
.map(k => [k, {
|
||||||
|
endpoints: [],
|
||||||
|
descs: fromEntries(
|
||||||
|
Object.keys(locale)
|
||||||
|
.map(l => [l, locale[l].common.permissions[k] as string] as [string, string])
|
||||||
|
) as { [x: string]: string; }
|
||||||
|
}] as [ string, IKindInfo ])
|
||||||
|
) as { [x: string]: IKindInfo; };
|
||||||
|
|
||||||
|
const errors = [] as string[][];
|
||||||
|
|
||||||
|
for (const endpoint of endpoints.filter(ep => !ep.meta.secure)) {
|
||||||
|
if (endpoint.meta.kind) {
|
||||||
|
const kind = endpoint.meta.kind;
|
||||||
|
if (kind in kinds) kinds[kind].endpoints.push(endpoint.name);
|
||||||
|
else errors.push([kind, endpoint.name]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.length > 0) throw Error('\n ' + errors.map((e) => `Unknown kind (permission) "${e[0]}" found at ${e[1]}.`).join('\n '));
|
||||||
|
|
||||||
|
return kinds;
|
||||||
|
}
|
|
@ -1,6 +1,14 @@
|
||||||
import config from '../../../config';
|
import config from '../../../config';
|
||||||
|
import { IKindInfo, kinds } from '../kinds';
|
||||||
|
|
||||||
|
export function getDescription(lang = 'ja-JP'): string {
|
||||||
|
const permissionTable = (Object.entries(kinds()) as [string, IKindInfo][])
|
||||||
|
.map(e => `|${e[0]}|${e[1].descs[lang]}|${e[1].endpoints.map(f => `[${f}](#operation/${f})`).join(', ')}|`)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
const descriptions = {
|
||||||
|
'ja-JP': `**Misskey is a decentralized microblogging platform.**
|
||||||
|
|
||||||
export const description = `
|
|
||||||
## Usage
|
## Usage
|
||||||
**APIはすべてPOSTでリクエスト/レスポンスともにJSON形式です。**
|
**APIはすべてPOSTでリクエスト/レスポンスともにJSON形式です。**
|
||||||
一部のAPIはリクエストに認証情報(APIキー)が必要です。リクエストの際に\`i\`というパラメータでAPIキーを添付してください。
|
一部のAPIはリクエストに認証情報(APIキー)が必要です。リクエストの際に\`i\`というパラメータでAPIキーを添付してください。
|
||||||
|
@ -44,4 +52,12 @@ APIキーの生成方法を擬似コードで表すと次のようになりま
|
||||||
\`\`\` js
|
\`\`\` js
|
||||||
const i = sha256(userToken + secretKey);
|
const i = sha256(userToken + secretKey);
|
||||||
\`\`\`
|
\`\`\`
|
||||||
`;
|
|
||||||
|
## Permissions
|
||||||
|
|Permisson (kind)|Description|Endpoints|
|
||||||
|
|:--|:--|:--|
|
||||||
|
${permissionTable}
|
||||||
|
`
|
||||||
|
} as { [x: string]: string };
|
||||||
|
return lang in descriptions ? descriptions[lang] : descriptions['ja-JP'];
|
||||||
|
}
|
||||||
|
|
|
@ -3,7 +3,7 @@ import { Context } from 'cafy';
|
||||||
import config from '../../../config';
|
import config from '../../../config';
|
||||||
import { errors as basicErrors } from './errors';
|
import { errors as basicErrors } from './errors';
|
||||||
import { schemas } from './schemas';
|
import { schemas } from './schemas';
|
||||||
import { description } from './description';
|
import { getDescription } from './description';
|
||||||
import { convertOpenApiSchema } from '../../../misc/schema';
|
import { convertOpenApiSchema } from '../../../misc/schema';
|
||||||
|
|
||||||
export function genOpenapiSpec(lang = 'ja-JP') {
|
export function genOpenapiSpec(lang = 'ja-JP') {
|
||||||
|
@ -13,7 +13,7 @@ export function genOpenapiSpec(lang = 'ja-JP') {
|
||||||
info: {
|
info: {
|
||||||
version: 'v1',
|
version: 'v1',
|
||||||
title: 'Misskey API',
|
title: 'Misskey API',
|
||||||
description: '**Misskey is a decentralized microblogging platform.**\n\n' + description,
|
description: getDescription(lang),
|
||||||
'x-logo': { url: '/assets/api-doc.png' }
|
'x-logo': { url: '/assets/api-doc.png' }
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -110,7 +110,10 @@ export function genOpenapiSpec(lang = 'ja-JP') {
|
||||||
|
|
||||||
let desc = (endpoint.meta.desc ? endpoint.meta.desc[lang] : 'No description provided.') + '\n\n';
|
let desc = (endpoint.meta.desc ? endpoint.meta.desc[lang] : 'No description provided.') + '\n\n';
|
||||||
desc += `**Credential required**: *${endpoint.meta.requireCredential ? 'Yes' : 'No'}*`;
|
desc += `**Credential required**: *${endpoint.meta.requireCredential ? 'Yes' : 'No'}*`;
|
||||||
if (endpoint.meta.kind) desc += ` / **Permission**: *${endpoint.meta.kind}*`;
|
if (endpoint.meta.kind) {
|
||||||
|
const kind = endpoint.meta.kind;
|
||||||
|
desc += ` / **Permission**: *${kind}*`;
|
||||||
|
}
|
||||||
|
|
||||||
const info = {
|
const info = {
|
||||||
operationId: endpoint.name,
|
operationId: endpoint.name,
|
||||||
|
|
12
test/api.ts
12
test/api.ts
|
@ -18,6 +18,8 @@ import * as assert from 'assert';
|
||||||
import * as childProcess from 'child_process';
|
import * as childProcess from 'child_process';
|
||||||
import { async, signup, request, post, react, uploadFile } from './utils';
|
import { async, signup, request, post, react, uploadFile } from './utils';
|
||||||
|
|
||||||
|
import { kinds } from '../src/server/api/kinds';
|
||||||
|
|
||||||
describe('API', () => {
|
describe('API', () => {
|
||||||
let p: childProcess.ChildProcess;
|
let p: childProcess.ChildProcess;
|
||||||
|
|
||||||
|
@ -792,7 +794,7 @@ describe('API', () => {
|
||||||
parentId: folderA.id
|
parentId: folderA.id
|
||||||
}, arisugawa);
|
}, arisugawa);
|
||||||
|
|
||||||
expect(res).have.status(400);
|
assert.strictEqual(res.status, 400);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
it('存在しない親フォルダを設定できない', async(async () => {
|
it('存在しない親フォルダを設定できない', async(async () => {
|
||||||
|
@ -965,5 +967,13 @@ describe('API', () => {
|
||||||
assert.strictEqual(res.body[0].id, alicePost.id);
|
assert.strictEqual(res.body[0].id, alicePost.id);
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('kinds', () => {
|
||||||
|
it('登録されていないパーミッションを利用しているAPIがない', () => {
|
||||||
|
const res = kinds();
|
||||||
|
|
||||||
|
assert.strictEqual(typeof res === 'object', true);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -1141,7 +1141,7 @@ describe('MFM', () => {
|
||||||
it('exlude emotes', () => {
|
it('exlude emotes', () => {
|
||||||
const tokens = parse('*.*');
|
const tokens = parse('*.*');
|
||||||
assert.deepStrictEqual(tokens, [
|
assert.deepStrictEqual(tokens, [
|
||||||
text("*.*"),
|
text('*.*'),
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue