diff options
| author | かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com> | 2023-10-21 18:41:12 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2023-10-21 18:41:12 +0900 |
| commit | f51bca41c5f59f9ffce346a3ec32badaf1ccda31 (patch) | |
| tree | b5799527c2d3602da3592f7d6c1b65bb6ac8922c | |
| parent | すべてのフォロー中の人のwithRepliesを変える機能 (#12049) (diff) | |
| download | sharkey-f51bca41c5f59f9ffce346a3ec32badaf1ccda31.tar.gz sharkey-f51bca41c5f59f9ffce346a3ec32badaf1ccda31.tar.bz2 sharkey-f51bca41c5f59f9ffce346a3ec32badaf1ccda31.zip | |
Feat: 外部サイトからテーマ・プラグインのインストールができるように (#12034)
* Feat: 外部サイトからテーマ・プラグインのインストールができるように
* Update Changelog
* Change Changelog
* Remove unnecessary imports
* Update fetch-external-resources.ts
* Update CHANGELOG.md
* Update CHANGELOG.md
| -rw-r--r-- | CHANGELOG.md | 13 | ||||
| -rw-r--r-- | locales/index.d.ts | 55 | ||||
| -rw-r--r-- | locales/ja-JP.yml | 42 | ||||
| -rw-r--r-- | packages/backend/src/server/api/EndpointsModule.ts | 4 | ||||
| -rw-r--r-- | packages/backend/src/server/api/endpoints.ts | 2 | ||||
| -rw-r--r-- | packages/backend/src/server/api/endpoints/fetch-external-resources.ts | 72 | ||||
| -rw-r--r-- | packages/frontend/src/components/global/MkUrl.vue | 23 | ||||
| -rw-r--r-- | packages/frontend/src/pages/install-extentions.vue | 354 | ||||
| -rw-r--r-- | packages/frontend/src/pages/settings/plugin.install.vue | 117 | ||||
| -rw-r--r-- | packages/frontend/src/pages/settings/theme.install.vue | 67 | ||||
| -rw-r--r-- | packages/frontend/src/router.ts | 4 | ||||
| -rw-r--r-- | packages/frontend/src/scripts/install-plugin.ts | 129 | ||||
| -rw-r--r-- | packages/frontend/src/scripts/install-theme.ts | 37 | ||||
| -rw-r--r-- | packages/misskey-js/etc/misskey-js.api.md | 16 | ||||
| -rw-r--r-- | packages/misskey-js/src/api.types.ts | 7 |
15 files changed, 784 insertions, 158 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index d06efad1ef..2613e6682c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,19 @@ --> +## 2023.x.x (unreleased) + +### General +- + +## Client +- Feat: プラグイン・テーマを外部サイトから直接インストールできるようになりました + - 外部サイトでの実装が必要です。詳細は Misskey Hub をご覧ください + https://misskey-hub.net/docs/advanced/publish-on-your-website.html + +### Server +- + ## 2023.10.2 ### General diff --git a/locales/index.d.ts b/locales/index.d.ts index d31ac0a9b2..bb9b4b3dc4 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -2313,6 +2313,61 @@ export interface Locale { "attachedNotes": string; "thisPageCanBeSeenFromTheAuthor": string; }; + "_externalResourceInstaller": { + "title": string; + "checkVendorBeforeInstall": string; + "_plugin": { + "title": string; + "metaTitle": string; + }; + "_theme": { + "title": string; + "metaTitle": string; + }; + "_meta": { + "base": string; + }; + "_vendorInfo": { + "title": string; + "endpoint": string; + "hashVerify": string; + }; + "_errors": { + "_invalidParams": { + "title": string; + "description": string; + }; + "_resourceTypeNotSupported": { + "title": string; + "description": string; + }; + "_failedToFetch": { + "title": string; + "fetchErrorDescription": string; + "parseErrorDescription": string; + }; + "_hashUnmatched": { + "title": string; + "description": string; + }; + "_pluginParseFailed": { + "title": string; + "description": string; + }; + "_pluginInstallFailed": { + "title": string; + "description": string; + }; + "_themeParseFailed": { + "title": string; + "description": string; + }; + "_themeInstallFailed": { + "title": string; + "description": string; + }; + }; + }; } declare const locales: { [lang: string]: Locale; diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index a63c698bb9..d3d6a80b1f 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -2225,3 +2225,45 @@ _fileViewer: uploadedAt: "追加日" attachedNotes: "添付されているノート" thisPageCanBeSeenFromTheAuthor: "このページは、このファイルをアップロードしたユーザーしか閲覧できません。" + +_externalResourceInstaller: + title: "外部サイトからインストール" + checkVendorBeforeInstall: "配布元が信頼できるかを確認した上でインストールしてください。" + _plugin: + title: "このプラグインをインストールしますか?" + metaTitle: "プラグイン情報" + _theme: + title: "このテーマをインストールしますか?" + metaTitle: "テーマ情報" + _meta: + base: "基本のカラースキーム" + _vendorInfo: + title: "配布元情報" + endpoint: "参照したエンドポイント" + hashVerify: "ファイル整合性の確認" + _errors: + _invalidParams: + title: "パラメータが不足しています" + description: "外部サイトからデータを取得するために必要な情報が不足しています。URLをお確かめください。" + _resourceTypeNotSupported: + title: "この外部リソースには対応していません" + description: "この外部サイトから取得したリソースの種別には対応していません。サイト管理者にお問い合わせください。" + _failedToFetch: + title: "データの取得に失敗しました" + fetchErrorDescription: "外部サイトとの通信に失敗しました。もう一度試しても改善しない場合、サイト管理者にお問い合わせください。" + parseErrorDescription: "外部サイトから取得したデータが読み取れませんでした。サイト管理者にお問い合わせください。" + _hashUnmatched: + title: "正しいデータが取得できませんでした" + description: "提供されたデータの整合性の確認に失敗しました。セキュリティ上、インストールは続行できません。サイト管理者にお問い合わせください。" + _pluginParseFailed: + title: "AiScript エラー" + description: "データは取得できたものの、AiScriptの解析時にエラーがあったため読み込めませんでした。プラグインの作者にお問い合わせください。エラーの詳細はJavascriptコンソールをご確認ください。" + _pluginInstallFailed: + title: "プラグインのインストールに失敗しました" + description: "プラグインのインストール中に問題が発生しました。もう一度お試しください。エラーの詳細はJavascriptコンソールをご覧ください。" + _themeParseFailed: + title: "テーマ解析エラー" + description: "データは取得できたものの、テーマファイルの解析時にエラーがあったため読み込めませんでした。テーマの作者にお問い合わせください。エラーの詳細はJavascriptコンソールをご確認ください。" + _themeInstallFailed: + title: "テーマのインストールに失敗しました" + description: "テーマのインストール中に問題が発生しました。もう一度お試しください。エラーの詳細はJavascriptコンソールをご覧ください。" diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index ab0e4c6273..376226be69 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -357,6 +357,7 @@ import * as ep___users_show from './endpoints/users/show.js'; import * as ep___users_achievements from './endpoints/users/achievements.js'; import * as ep___users_updateMemo from './endpoints/users/update-memo.js'; import * as ep___fetchRss from './endpoints/fetch-rss.js'; +import * as ep___fetchExternalResources from './endpoints/fetch-external-resources.js'; import * as ep___retention from './endpoints/retention.js'; import { GetterService } from './GetterService.js'; import { ApiLoggerService } from './ApiLoggerService.js'; @@ -713,6 +714,7 @@ const $users_show: Provider = { provide: 'ep:users/show', useClass: ep___users_s const $users_achievements: Provider = { provide: 'ep:users/achievements', useClass: ep___users_achievements.default }; const $users_updateMemo: Provider = { provide: 'ep:users/update-memo', useClass: ep___users_updateMemo.default }; const $fetchRss: Provider = { provide: 'ep:fetch-rss', useClass: ep___fetchRss.default }; +const $fetchExternalResources: Provider = { provide: 'ep:fetch-external-resources', useClass: ep___fetchExternalResources.default }; const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention.default }; @Module({ @@ -1073,6 +1075,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $users_achievements, $users_updateMemo, $fetchRss, + $fetchExternalResources, $retention, ], exports: [ @@ -1424,6 +1427,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $users_achievements, $users_updateMemo, $fetchRss, + $fetchExternalResources, $retention, ], }) diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index 79e62672fa..8be91469be 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -357,6 +357,7 @@ import * as ep___users_show from './endpoints/users/show.js'; import * as ep___users_achievements from './endpoints/users/achievements.js'; import * as ep___users_updateMemo from './endpoints/users/update-memo.js'; import * as ep___fetchRss from './endpoints/fetch-rss.js'; +import * as ep___fetchExternalResources from './endpoints/fetch-external-resources.js'; import * as ep___retention from './endpoints/retention.js'; const eps = [ @@ -711,6 +712,7 @@ const eps = [ ['users/achievements', ep___users_achievements], ['users/update-memo', ep___users_updateMemo], ['fetch-rss', ep___fetchRss], + ['fetch-external-resources', ep___fetchExternalResources], ['retention', ep___retention], ]; diff --git a/packages/backend/src/server/api/endpoints/fetch-external-resources.ts b/packages/backend/src/server/api/endpoints/fetch-external-resources.ts new file mode 100644 index 0000000000..d7b46cc666 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/fetch-external-resources.ts @@ -0,0 +1,72 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { createHash } from 'crypto'; +import ms from 'ms'; +import { Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { HttpRequestService } from '@/core/HttpRequestService.js'; +import { ApiError } from '../error.js'; + +export const meta = { + tags: ['meta'], + + requireCredential: true, + + limit: { + duration: ms('1hour'), + max: 50, + }, + + errors: { + invalidSchema: { + message: 'External resource returned invalid schema.', + code: 'EXT_RESOURCE_RETURNED_INVALID_SCHEMA', + id: 'bb774091-7a15-4a70-9dc5-6ac8cf125856', + }, + hashUnmached: { + message: 'Hash did not match.', + code: 'EXT_RESOURCE_HASH_DIDNT_MATCH', + id: '693ba8ba-b486-40df-a174-72f8279b56a4', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + url: { type: 'string' }, + hash: { type: 'string' }, + }, + required: ['url', 'hash'], +} as const; + +@Injectable() +export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export + constructor( + private httpRequestService: HttpRequestService, + ) { + super(meta, paramDef, async (ps) => { + const res = await this.httpRequestService.getJson<{ + type: string; + data: string; + }>(ps.url); + + if (!res.data || !res.type) { + throw new ApiError(meta.errors.invalidSchema); + } + + const resHash = createHash('sha512').update(res.data.replace(/\r\n/g, '\n')).digest('hex'); + if (resHash !== ps.hash) { + throw new ApiError(meta.errors.hashUnmached); + } + + return { + type: res.type, + data: res.data, + }; + }); + } +} diff --git a/packages/frontend/src/components/global/MkUrl.vue b/packages/frontend/src/components/global/MkUrl.vue index a8832cde01..db8a8399b5 100644 --- a/packages/frontend/src/components/global/MkUrl.vue +++ b/packages/frontend/src/components/global/MkUrl.vue @@ -31,23 +31,28 @@ import * as os from '@/os.js'; import { useTooltip } from '@/scripts/use-tooltip.js'; import { safeURIDecode } from '@/scripts/safe-uri-decode.js'; -const props = defineProps<{ +const props = withDefaults(defineProps<{ url: string; rel?: string; -}>(); + showUrlPreview?: boolean; +}>(), { + showUrlPreview: true, +}); const self = props.url.startsWith(local); const url = new URL(props.url); if (!['http:', 'https:'].includes(url.protocol)) throw new Error('invalid url'); const el = ref(); -useTooltip(el, (showing) => { - os.popup(defineAsyncComponent(() => import('@/components/MkUrlPreviewPopup.vue')), { - showing, - url: props.url, - source: el.value, - }, {}, 'closed'); -}); +if (props.showUrlPreview) { + useTooltip(el, (showing) => { + os.popup(defineAsyncComponent(() => import('@/components/MkUrlPreviewPopup.vue')), { + showing, + url: props.url, + source: el.value, + }, {}, 'closed'); + }); +} const schema = url.protocol; const hostname = decodePunycode(url.hostname); diff --git a/packages/frontend/src/pages/install-extentions.vue b/packages/frontend/src/pages/install-extentions.vue new file mode 100644 index 0000000000..9674b522aa --- /dev/null +++ b/packages/frontend/src/pages/install-extentions.vue @@ -0,0 +1,354 @@ +<!-- +SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :contentMax="500"> + <MkLoading v-if="uiPhase === 'fetching'"/> + <div v-else-if="uiPhase === 'confirm' && data" class="_gaps_m" :class="$style.extInstallerRoot"> + <div :class="$style.extInstallerIconWrapper"> + <i v-if="data.type === 'plugin'" class="ti ti-plug"></i> + <i v-else-if="data.type === 'theme'" class="ti ti-palette"></i> + <i v-else class="ti ti-download"></i> + </div> + <h2 :class="$style.extInstallerTitle">{{ i18n.ts._externalResourceInstaller[`_${data.type}`].title }}</h2> + <div :class="$style.extInstallerNormDesc">{{ i18n.ts._externalResourceInstaller.checkVendorBeforeInstall }}</div> + <MkInfo v-if="data.type === 'plugin'" :warn="true">{{ i18n.ts._plugin.installWarn }}</MkInfo> + <FormSection> + <template #label>{{ i18n.ts._externalResourceInstaller[`_${data.type}`].metaTitle }}</template> + <div class="_gaps_s"> + <FormSplit> + <MkKeyValue> + <template #key>{{ i18n.ts.name }}</template> + <template #value>{{ data.meta?.name }}</template> + </MkKeyValue> + <MkKeyValue> + <template #key>{{ i18n.ts.author }}</template> + <template #value>{{ data.meta?.author }}</template> + </MkKeyValue> + </FormSplit> + <MkKeyValue v-if="data.type === 'plugin'"> + <template #key>{{ i18n.ts.description }}</template> + <template #value>{{ data.meta?.description }}</template> + </MkKeyValue> + <MkKeyValue v-if="data.type === 'plugin'"> + <template #key>{{ i18n.ts.version }}</template> + <template #value>{{ data.meta?.version }}</template> + </MkKeyValue> + <MkKeyValue v-if="data.type === 'plugin'"> + <template #key>{{ i18n.ts.permission }}</template> + <template #value> + <ul :class="$style.extInstallerKVList"> + <li v-for="permission in data.meta?.permissions" :key="permission">{{ i18n.ts._permissions[permission] }}</li> + </ul> + </template> + </MkKeyValue> + <MkKeyValue v-if="data.type === 'theme' && data.meta?.base"> + <template #key>{{ i18n.ts._externalResourceInstaller._meta.base }}</template> + <template #value>{{ i18n.ts[data.meta.base] }}</template> + </MkKeyValue> + <MkFolder> + <template #icon><i class="ti ti-code"></i></template> + <template #label>{{ i18n.ts._plugin.viewSource }}</template> + + <MkCode :code="data.raw ?? ''"/> + </MkFolder> + </div> + </FormSection> + <FormSection> + <template #label>{{ i18n.ts._externalResourceInstaller._vendorInfo.title }}</template> + <div class="_gaps_s"> + <MkKeyValue> + <template #key>{{ i18n.ts._externalResourceInstaller._vendorInfo.endpoint }}</template> + <template #value><MkUrl :url="url ?? ''" :showUrlPreview="false"></MkUrl></template> + </MkKeyValue> + <MkKeyValue> + <template #key>{{ i18n.ts._externalResourceInstaller._vendorInfo.hashVerify }}</template> + <template #value> + <!--この画面が出ている時点でハッシュの検証には成功している--> + <i class="ti ti-check" style="color: var(--accent)"></i> + </template> + </MkKeyValue> + </div> + </FormSection> + <div class="_buttonsCenter"> + <MkButton primary @click="install()"><i class="ti ti-check"></i> {{ i18n.ts.install }}</MkButton> + </div> + </div> + <div v-else-if="uiPhase === 'error'" class="_gaps_m" :class="[$style.extInstallerRoot, $style.error]"> + <div :class="$style.extInstallerIconWrapper"> + <i class="ti ti-circle-x"></i> + </div> + <h2 :class="$style.extInstallerTitle">{{ errorKV?.title }}</h2> + <div :class="$style.extInstallerNormDesc">{{ errorKV?.description }}</div> + <div class="_buttonsCenter"> + <MkButton @click="goBack()">{{ i18n.ts.goBack }}</MkButton> + <MkButton @click="goToMisskey()">{{ i18n.ts.goToMisskey }}</MkButton> + </div> + </div> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { ref, computed, onMounted, nextTick } from 'vue'; +import MkLoading from '@/components/global/MkLoading.vue'; +import MkButton from '@/components/MkButton.vue'; +import FormSection from '@/components/form/section.vue'; +import FormSplit from '@/components/form/split.vue'; +import MkCode from '@/components/MkCode.vue'; +import MkUrl from '@/components/global/MkUrl.vue'; +import MkInfo from '@/components/MkInfo.vue'; +import MkFolder from '@/components/MkFolder.vue'; +import MkKeyValue from '@/components/MkKeyValue.vue'; +import * as os from '@/os.js'; +import { AiScriptPluginMeta, parsePluginMeta, installPlugin } from '@/scripts/install-plugin.js'; +import { parseThemeCode, installTheme } from '@/scripts/install-theme.js'; +import { unisonReload } from '@/scripts/unison-reload.js'; +import { i18n } from '@/i18n.js'; +import { definePageMetadata } from '@/scripts/page-metadata.js'; + +const uiPhase = ref<'fetching' | 'confirm' | 'error'>('fetching'); +const errorKV = ref<{ + title?: string; + description?: string; +}>({ + title: '', + description: '', +}); + +const urlParams = new URLSearchParams(window.location.search); +const url = urlParams.get('url'); +const hash = urlParams.get('hash'); + +const data = ref<{ + type: 'plugin' | 'theme'; + raw: string; + meta?: { + // Plugin & Theme Common + name: string; + author: string; + + // Plugin + description?: string; + version?: string; + permissions?: string[]; + config?: Record<string, any>; + + // Theme + base?: 'light' | 'dark'; + }; +} | null>(null); + +function goBack(): void { + history.back(); +} + +function goToMisskey(): void { + location.href = '/'; +} + +async function fetch() { + if (!url || !hash) { + errorKV.value = { + title: i18n.ts._externalResourceInstaller._errors._invalidParams.title, + description: i18n.ts._externalResourceInstaller._errors._invalidParams.description, + }; + uiPhase.value = 'error'; + return; + } + const res = await os.api('fetch-external-resources', { + url, + hash, + }).catch((err) => { + switch (err.id) { + case 'bb774091-7a15-4a70-9dc5-6ac8cf125856': + errorKV.value = { + title: i18n.ts._externalResourceInstaller._errors._failedToFetch.title, + description: i18n.ts._externalResourceInstaller._errors._failedToFetch.parseErrorDescription, + }; + uiPhase.value = 'error'; + break; + case '693ba8ba-b486-40df-a174-72f8279b56a4': + errorKV.value = { + title: i18n.ts._externalResourceInstaller._errors._hashUnmatched.title, + description: i18n.ts._externalResourceInstaller._errors._hashUnmatched.description, + }; + uiPhase.value = 'error'; + break; + default: + errorKV.value = { + title: i18n.ts._externalResourceInstaller._errors._failedToFetch.title, + description: i18n.ts._externalResourceInstaller._errors._failedToFetch.fetchErrorDescription, + }; + uiPhase.value = 'error'; + break; + } + throw new Error(err.code); + }); + + if (!res) { + errorKV.value = { + title: i18n.ts._externalResourceInstaller._errors._failedToFetch.title, + description: i18n.ts._externalResourceInstaller._errors._failedToFetch.fetchErrorDescription, + }; + uiPhase.value = 'error'; + return; + } + + switch (res.type) { + case 'plugin': + try { + const meta = await parsePluginMeta(res.data); + data.value = { + type: 'plugin', + meta, + raw: res.data, + }; + } catch (err) { + errorKV.value = { + title: i18n.ts._externalResourceInstaller._errors._pluginParseFailed.title, + description: i18n.ts._externalResourceInstaller._errors._pluginParseFailed.description, + }; + console.error(err); + uiPhase.value = 'error'; + return; + } + break; + + case 'theme': + try { + const metaRaw = parseThemeCode(res.data); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { id, props, desc: description, ...meta } = metaRaw; + data.value = { + type: 'theme', + meta: { + description, + ...meta, + }, + raw: res.data, + }; + } catch (err) { + switch (err.message.toLowerCase()) { + case 'this theme is already installed': + errorKV.value = { + title: i18n.ts._externalResourceInstaller._errors._themeParseFailed.title, + description: i18n.ts._theme.alreadyInstalled, + }; + break; + + default: + errorKV.value = { + title: i18n.ts._externalResourceInstaller._errors._themeParseFailed.title, + description: i18n.ts._externalResourceInstaller._errors._themeParseFailed.description, + }; + break; + } + console.error(err); + uiPhase.value = 'error'; + return; + } + break; + + default: + errorKV.value = { + title: i18n.ts._externalResourceInstaller._errors._resourceTypeNotSupported.title, + description: i18n.ts._externalResourceInstaller._errors._resourceTypeNotSupported.description, + }; + uiPhase.value = 'error'; + return; + } + + uiPhase.value = 'confirm'; +} + +async function install() { + if (!data.value) return; + + switch (data.value.type) { + case 'plugin': + if (!data.value.meta) return; + try { + await installPlugin(data.value.raw, data.value.meta as AiScriptPluginMeta); + os.success(); + nextTick(() => { + unisonReload('/'); + }); + } catch (err) { + errorKV.value = { + title: i18n.ts._externalResourceInstaller._errors._pluginInstallFailed.title, + description: i18n.ts._externalResourceInstaller._errors._pluginInstallFailed.description, + }; + console.error(err); + uiPhase.value = 'error'; + } + break; + case 'theme': + if (!data.value.meta) return; + await installTheme(data.value.raw); + os.success(); + nextTick(() => { + location.href = '/settings/theme'; + }); + } +} + +onMounted(() => { + fetch(); +}); + +const headerActions = computed(() => []); + +const headerTabs = computed(() => []); + +definePageMetadata({ + title: i18n.ts._externalResourceInstaller.title, + icon: 'ti ti-download', +}); +</script> + +<style lang="scss" module> +.extInstallerRoot { + border-radius: var(--radius); + background: var(--panel); + padding: 1.5rem; +} + +.extInstallerIconWrapper { + width: 48px; + height: 48px; + font-size: 24px; + line-height: 48px; + text-align: center; + border-radius: 50%; + margin-left: auto; + margin-right: auto; + + background-color: var(--accentedBg); + color: var(--accent); +} + +.error .extInstallerIconWrapper { + background-color: rgba(255, 42, 42, .15); + color: #ff2a2a; +} + +.extInstallerTitle { + font-size: 1.2rem; + text-align: center; + margin: 0; +} + +.extInstallerNormDesc { + text-align: center; +} + +.extInstallerKVList { + margin-top: 0; + margin-bottom: 0; +} +</style> diff --git a/packages/frontend/src/pages/settings/plugin.install.vue b/packages/frontend/src/pages/settings/plugin.install.vue index 47ebe9cfd6..693e02d0ed 100644 --- a/packages/frontend/src/pages/settings/plugin.install.vue +++ b/packages/frontend/src/pages/settings/plugin.install.vue @@ -18,130 +18,35 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { defineAsyncComponent, nextTick, ref } from 'vue'; -import { compareVersions } from 'compare-versions'; -import { Interpreter, Parser, utils } from '@syuilo/aiscript'; -import { v4 as uuid } from 'uuid'; +import { nextTick, ref } from 'vue'; import MkTextarea from '@/components/MkTextarea.vue'; import MkButton from '@/components/MkButton.vue'; import FormInfo from '@/components/MkInfo.vue'; import * as os from '@/os.js'; -import { ColdDeviceStorage } from '@/store.js'; +import { installPlugin } from '@/scripts/install-plugin.js'; import { unisonReload } from '@/scripts/unison-reload.js'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; -const parser = new Parser(); -const code = ref(null); - -function installPlugin({ id, meta, src, token }) { - ColdDeviceStorage.set('plugins', ColdDeviceStorage.get('plugins').concat({ - ...meta, - id, - active: true, - configData: {}, - token: token, - src: src, - })); -} - -function isSupportedAiScriptVersion(version: string): boolean { - try { - return (compareVersions(version, '0.12.0') >= 0); - } catch (err) { - return false; - } -} +const code = ref<string | null>(null); async function install() { - if (code.value == null) return; + if (!code.value) return; - const lv = utils.getLangVersion(code.value); - if (lv == null) { - os.alert({ - type: 'error', - text: 'No language version annotation found :(', - }); - return; - } else if (!isSupportedAiScriptVersion(lv)) { - os.alert({ - type: 'error', - text: `aiscript version '${lv}' is not supported :(`, - }); - return; - } - - let ast; try { - ast = parser.parse(code.value); - } catch (err) { - os.alert({ - type: 'error', - text: 'Syntax error :(', - }); - return; - } + await installPlugin(code.value); + os.success(); - const meta = Interpreter.collectMetadata(ast); - if (meta == null) { - os.alert({ - type: 'error', - text: 'No metadata found :(', + nextTick(() => { + unisonReload(); }); - return; - } - - const metadata = meta.get(null); - if (metadata == null) { - os.alert({ - type: 'error', - text: 'No metadata found :(', - }); - return; - } - - const { name, version, author, description, permissions, config } = metadata; - if (name == null || version == null || author == null) { + } catch (err) { os.alert({ type: 'error', - text: 'Required property not found :(', + title: 'Install failed', + text: err.toString() ?? null, }); - return; } - - const token = permissions == null || permissions.length === 0 ? null : await new Promise((res, rej) => { - os.popup(defineAsyncComponent(() => import('@/components/MkTokenGenerateWindow.vue')), { - title: i18n.ts.tokenRequested, - information: i18n.ts.pluginTokenRequestedDescription, - initialName: name, - initialPermissions: permissions, - }, { - done: async result => { - const { name, permissions } = result; - const { token } = await os.api('miauth/gen-token', { - session: null, - name: name, - permission: permissions, - }); - res(token); - }, - }, 'closed'); - }); - - installPlugin({ - id: uuid(), - meta: { - name, version, author, description, permissions, config, - }, - token, - src: code.value, - }); - - os.success(); - - nextTick(() => { - unisonReload(); - }); } const headerActions = $computed(() => []); diff --git a/packages/frontend/src/pages/settings/theme.install.vue b/packages/frontend/src/pages/settings/theme.install.vue index 155ce9d9da..7fa7b23e44 100644 --- a/packages/frontend/src/pages/settings/theme.install.vue +++ b/packages/frontend/src/pages/settings/theme.install.vue @@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only </MkTextarea> <div class="_buttons"> - <MkButton :disabled="installThemeCode == null" inline @click="() => preview(installThemeCode)"><i class="ti ti-eye"></i> {{ i18n.ts.preview }}</MkButton> + <MkButton :disabled="installThemeCode == null" inline @click="() => previewTheme(installThemeCode)"><i class="ti ti-eye"></i> {{ i18n.ts.preview }}</MkButton> <MkButton :disabled="installThemeCode == null" primary inline @click="() => install(installThemeCode)"><i class="ti ti-check"></i> {{ i18n.ts.install }}</MkButton> </div> </div> @@ -18,60 +18,41 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { } from 'vue'; -import JSON5 from 'json5'; import MkTextarea from '@/components/MkTextarea.vue'; import MkButton from '@/components/MkButton.vue'; -import { applyTheme, validateTheme } from '@/scripts/theme.js'; +import { parseThemeCode, previewTheme, installTheme } from '@/scripts/install-theme.js'; import * as os from '@/os.js'; -import { addTheme, getThemes } from '@/theme-store'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; let installThemeCode = $ref(null); -function parseThemeCode(code: string) { - let theme; - +async function install(code: string): Promise<void> { try { - theme = JSON5.parse(code); - } catch (err) { - os.alert({ - type: 'error', - text: i18n.ts._theme.invalid, - }); - return false; - } - if (!validateTheme(theme)) { - os.alert({ - type: 'error', - text: i18n.ts._theme.invalid, - }); - return false; - } - if (getThemes().some(t => t.id === theme.id)) { + const theme = parseThemeCode(code); + await installTheme(code); os.alert({ - type: 'info', - text: i18n.ts._theme.alreadyInstalled, + type: 'success', + text: i18n.t('_theme.installed', { name: theme.name }), }); - return false; - } - - return theme; -} - -function preview(code: string): void { - const theme = parseThemeCode(code); - if (theme) applyTheme(theme, false); -} + } catch (err) { + switch (err.message.toLowerCase()) { + case 'this theme is already installed': + os.alert({ + type: 'info', + text: i18n.ts._theme.alreadyInstalled, + }); + break; -async function install(code: string): Promise<void> { - const theme = parseThemeCode(code); - if (!theme) return; - await addTheme(theme); - os.alert({ - type: 'success', - text: i18n.t('_theme.installed', { name: theme.name }), - }); + default: + os.alert({ + type: 'error', + text: i18n.ts._theme.invalid, + }); + break; + } + console.error(err); + } } const headerActions = $computed(() => []); diff --git a/packages/frontend/src/router.ts b/packages/frontend/src/router.ts index 2258edebbb..e73532f6b2 100644 --- a/packages/frontend/src/router.ts +++ b/packages/frontend/src/router.ts @@ -323,6 +323,10 @@ export const routes = [{ path: '/registry', component: page(() => import('./pages/registry.vue')), }, { + path: '/install-extentions', + component: page(() => import('./pages/install-extentions.vue')), + loginRequired: true, +}, { path: '/admin/user/:userId', component: iAmModerator ? page(() => import('./pages/admin-user.vue')) : page(() => import('./pages/not-found.vue')), }, { diff --git a/packages/frontend/src/scripts/install-plugin.ts b/packages/frontend/src/scripts/install-plugin.ts new file mode 100644 index 0000000000..1310a0dc73 --- /dev/null +++ b/packages/frontend/src/scripts/install-plugin.ts @@ -0,0 +1,129 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { defineAsyncComponent } from 'vue'; +import { compareVersions } from 'compare-versions'; +import { v4 as uuid } from 'uuid'; +import { Interpreter, Parser, utils } from '@syuilo/aiscript'; +import type { Plugin } from '@/store.js'; +import { ColdDeviceStorage } from '@/store.js'; +import * as os from '@/os.js'; +import { i18n } from '@/i18n.js'; + +export type AiScriptPluginMeta = { + name: string; + version: string; + author: string; + description?: string; + permissions?: string[]; + config?: Record<string, any>; +}; + +const parser = new Parser(); + +export function savePlugin({ id, meta, src, token }: { + id: string; + meta: AiScriptPluginMeta; + src: string; + token: string; +}) { + ColdDeviceStorage.set('plugins', ColdDeviceStorage.get('plugins').concat({ + ...meta, + id, + active: true, + configData: {}, + token: token, + src: src, + } as Plugin)); +} + +export function isSupportedAiScriptVersion(version: string): boolean { + try { + return (compareVersions(version, '0.12.0') >= 0); + } catch (err) { + return false; + } +} + +export async function parsePluginMeta(code: string): Promise<AiScriptPluginMeta> { + if (!code) { + throw new Error('code is required'); + } + + const lv = utils.getLangVersion(code); + if (lv == null) { + throw new Error('No language version annotation found'); + } else if (!isSupportedAiScriptVersion(lv)) { + throw new Error(`Aiscript version '${lv}' is not supported`); + } + + let ast; + try { + ast = parser.parse(code); + } catch (err) { + throw new Error('Aiscript syntax error'); + } + + const meta = Interpreter.collectMetadata(ast); + if (meta == null) { + throw new Error('Meta block not found'); + } + + const metadata = meta.get(null); + if (metadata == null) { + throw new Error('Metadata not found'); + } + + const { name, version, author, description, permissions, config } = metadata; + if (name == null || version == null || author == null) { + throw new Error('Required property not found'); + } + + return { + name, + version, + author, + description, + permissions, + config, + }; +} + +export async function installPlugin(code: string, meta?: AiScriptPluginMeta) { + if (!code) return; + + let realMeta: AiScriptPluginMeta; + if (!meta) { + realMeta = await parsePluginMeta(code); + } else { + realMeta = meta; + } + + const token = realMeta.permissions == null || realMeta.permissions.length === 0 ? null : await new Promise((res, rej) => { + os.popup(defineAsyncComponent(() => import('@/components/MkTokenGenerateWindow.vue')), { + title: i18n.ts.tokenRequested, + information: i18n.ts.pluginTokenRequestedDescription, + initialName: realMeta.name, + initialPermissions: realMeta.permissions, + }, { + done: async result => { + const { name, permissions } = result; + const { token } = await os.api('miauth/gen-token', { + session: null, + name: name, + permission: permissions, + }); + res(token); + }, + }, 'closed'); + }); + + savePlugin({ + id: uuid(), + meta: realMeta, + token, + src: code, + }); +} diff --git a/packages/frontend/src/scripts/install-theme.ts b/packages/frontend/src/scripts/install-theme.ts new file mode 100644 index 0000000000..394b642bf4 --- /dev/null +++ b/packages/frontend/src/scripts/install-theme.ts @@ -0,0 +1,37 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import JSON5 from 'json5'; +import { addTheme, getThemes } from '@/theme-store.js'; +import { Theme, applyTheme, validateTheme } from '@/scripts/theme.js'; + +export function parseThemeCode(code: string): Theme { + let theme; + + try { + theme = JSON5.parse(code); + } catch (err) { + throw new Error('Failed to parse theme json'); + } + if (!validateTheme(theme)) { + throw new Error('This theme is invaild'); + } + if (getThemes().some(t => t.id === theme.id)) { + throw new Error('This theme is already installed'); + } + + return theme; +} + +export function previewTheme(code: string): void { + const theme = parseThemeCode(code); + if (theme) applyTheme(theme, false); +} + +export async function installTheme(code: string): Promise<void> { + const theme = parseThemeCode(code); + if (!theme) return; + await addTheme(theme); +} diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md index 4fabc195de..208fe5b16d 100644 --- a/packages/misskey-js/etc/misskey-js.api.md +++ b/packages/misskey-js/etc/misskey-js.api.md @@ -2229,6 +2229,22 @@ export type Endpoints = { }; }; }; + 'fetch-rss': { + req: { + url: string; + }; + res: TODO; + }; + 'fetch-external-resources': { + req: { + url: string; + hash: string; + }; + res: { + type: string; + data: string; + }; + }; }; declare namespace entities { diff --git a/packages/misskey-js/src/api.types.ts b/packages/misskey-js/src/api.types.ts index 8c6205bf51..e1c2aaf51d 100644 --- a/packages/misskey-js/src/api.types.ts +++ b/packages/misskey-js/src/api.types.ts @@ -639,4 +639,11 @@ export type Endpoints = { $default: UserDetailed; }; }; }; + + // fetching external data + 'fetch-rss': { req: { url: string; }; res: TODO; }; + 'fetch-external-resources': { + req: { url: string; hash: string; }; + res: { type: string; data: string; }; + }; }; |