summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorかっこかり <67428053+kakkokari-gtyih@users.noreply.github.com>2023-10-21 18:41:12 +0900
committerGitHub <noreply@github.com>2023-10-21 18:41:12 +0900
commitf51bca41c5f59f9ffce346a3ec32badaf1ccda31 (patch)
treeb5799527c2d3602da3592f7d6c1b65bb6ac8922c
parentすべてのフォロー中の人のwithRepliesを変える機能 (#12049) (diff)
downloadsharkey-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.md13
-rw-r--r--locales/index.d.ts55
-rw-r--r--locales/ja-JP.yml42
-rw-r--r--packages/backend/src/server/api/EndpointsModule.ts4
-rw-r--r--packages/backend/src/server/api/endpoints.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/fetch-external-resources.ts72
-rw-r--r--packages/frontend/src/components/global/MkUrl.vue23
-rw-r--r--packages/frontend/src/pages/install-extentions.vue354
-rw-r--r--packages/frontend/src/pages/settings/plugin.install.vue117
-rw-r--r--packages/frontend/src/pages/settings/theme.install.vue67
-rw-r--r--packages/frontend/src/router.ts4
-rw-r--r--packages/frontend/src/scripts/install-plugin.ts129
-rw-r--r--packages/frontend/src/scripts/install-theme.ts37
-rw-r--r--packages/misskey-js/etc/misskey-js.api.md16
-rw-r--r--packages/misskey-js/src/api.types.ts7
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; };
+ };
};