Feat: 外部サイトからテーマ・プラグインのインストールができるように (#12034)

* Feat: 外部サイトからテーマ・プラグインのインストールができるように

* Update Changelog

* Change Changelog

* Remove unnecessary imports

* Update fetch-external-resources.ts

* Update CHANGELOG.md

* Update CHANGELOG.md
This commit is contained in:
かっこかり 2023-10-21 18:41:12 +09:00 committed by GitHub
parent 722584bf72
commit f51bca41c5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 788 additions and 162 deletions

View file

@ -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 ## 2023.10.2
### General ### General

55
locales/index.d.ts vendored
View file

@ -2313,6 +2313,61 @@ export interface Locale {
"attachedNotes": string; "attachedNotes": string;
"thisPageCanBeSeenFromTheAuthor": 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: { declare const locales: {
[lang: string]: Locale; [lang: string]: Locale;

View file

@ -2225,3 +2225,45 @@ _fileViewer:
uploadedAt: "追加日" uploadedAt: "追加日"
attachedNotes: "添付されているノート" attachedNotes: "添付されているノート"
thisPageCanBeSeenFromTheAuthor: "このページは、このファイルをアップロードしたユーザーしか閲覧できません。" 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コンソールをご覧ください。"

View file

@ -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_achievements from './endpoints/users/achievements.js';
import * as ep___users_updateMemo from './endpoints/users/update-memo.js'; import * as ep___users_updateMemo from './endpoints/users/update-memo.js';
import * as ep___fetchRss from './endpoints/fetch-rss.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 * as ep___retention from './endpoints/retention.js';
import { GetterService } from './GetterService.js'; import { GetterService } from './GetterService.js';
import { ApiLoggerService } from './ApiLoggerService.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_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 $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 $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 }; const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention.default };
@Module({ @Module({
@ -1073,6 +1075,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$users_achievements, $users_achievements,
$users_updateMemo, $users_updateMemo,
$fetchRss, $fetchRss,
$fetchExternalResources,
$retention, $retention,
], ],
exports: [ exports: [
@ -1424,6 +1427,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$users_achievements, $users_achievements,
$users_updateMemo, $users_updateMemo,
$fetchRss, $fetchRss,
$fetchExternalResources,
$retention, $retention,
], ],
}) })

View file

@ -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_achievements from './endpoints/users/achievements.js';
import * as ep___users_updateMemo from './endpoints/users/update-memo.js'; import * as ep___users_updateMemo from './endpoints/users/update-memo.js';
import * as ep___fetchRss from './endpoints/fetch-rss.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 * as ep___retention from './endpoints/retention.js';
const eps = [ const eps = [
@ -711,6 +712,7 @@ const eps = [
['users/achievements', ep___users_achievements], ['users/achievements', ep___users_achievements],
['users/update-memo', ep___users_updateMemo], ['users/update-memo', ep___users_updateMemo],
['fetch-rss', ep___fetchRss], ['fetch-rss', ep___fetchRss],
['fetch-external-resources', ep___fetchExternalResources],
['retention', ep___retention], ['retention', ep___retention],
]; ];

View file

@ -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,
};
});
}
}

View file

@ -31,16 +31,20 @@ import * as os from '@/os.js';
import { useTooltip } from '@/scripts/use-tooltip.js'; import { useTooltip } from '@/scripts/use-tooltip.js';
import { safeURIDecode } from '@/scripts/safe-uri-decode.js'; import { safeURIDecode } from '@/scripts/safe-uri-decode.js';
const props = defineProps<{ const props = withDefaults(defineProps<{
url: string; url: string;
rel?: string; rel?: string;
}>(); showUrlPreview?: boolean;
}>(), {
showUrlPreview: true,
});
const self = props.url.startsWith(local); const self = props.url.startsWith(local);
const url = new URL(props.url); const url = new URL(props.url);
if (!['http:', 'https:'].includes(url.protocol)) throw new Error('invalid url'); if (!['http:', 'https:'].includes(url.protocol)) throw new Error('invalid url');
const el = ref(); const el = ref();
if (props.showUrlPreview) {
useTooltip(el, (showing) => { useTooltip(el, (showing) => {
os.popup(defineAsyncComponent(() => import('@/components/MkUrlPreviewPopup.vue')), { os.popup(defineAsyncComponent(() => import('@/components/MkUrlPreviewPopup.vue')), {
showing, showing,
@ -48,6 +52,7 @@ useTooltip(el, (showing) => {
source: el.value, source: el.value,
}, {}, 'closed'); }, {}, 'closed');
}); });
}
const schema = url.protocol; const schema = url.protocol;
const hostname = decodePunycode(url.hostname); const hostname = decodePunycode(url.hostname);

View file

@ -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>

View file

@ -18,130 +18,35 @@ SPDX-License-Identifier: AGPL-3.0-only
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { defineAsyncComponent, nextTick, ref } from 'vue'; import { nextTick, ref } from 'vue';
import { compareVersions } from 'compare-versions';
import { Interpreter, Parser, utils } from '@syuilo/aiscript';
import { v4 as uuid } from 'uuid';
import MkTextarea from '@/components/MkTextarea.vue'; import MkTextarea from '@/components/MkTextarea.vue';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import FormInfo from '@/components/MkInfo.vue'; import FormInfo from '@/components/MkInfo.vue';
import * as os from '@/os.js'; 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 { unisonReload } from '@/scripts/unison-reload.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js'; import { definePageMetadata } from '@/scripts/page-metadata.js';
const parser = new Parser(); const code = ref<string | null>(null);
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;
}
}
async function install() { 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 { try {
ast = parser.parse(code.value); await installPlugin(code.value);
} catch (err) {
os.alert({
type: 'error',
text: 'Syntax error :(',
});
return;
}
const meta = Interpreter.collectMetadata(ast);
if (meta == null) {
os.alert({
type: 'error',
text: 'No metadata found :(',
});
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) {
os.alert({
type: 'error',
text: 'Required property not found :(',
});
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(); os.success();
nextTick(() => { nextTick(() => {
unisonReload(); unisonReload();
}); });
} catch (err) {
os.alert({
type: 'error',
title: 'Install failed',
text: err.toString() ?? null,
});
}
} }
const headerActions = $computed(() => []); const headerActions = $computed(() => []);

View file

@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkTextarea> </MkTextarea>
<div class="_buttons"> <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> <MkButton :disabled="installThemeCode == null" primary inline @click="() => install(installThemeCode)"><i class="ti ti-check"></i> {{ i18n.ts.install }}</MkButton>
</div> </div>
</div> </div>
@ -18,60 +18,41 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup> <script lang="ts" setup>
import { } from 'vue'; import { } from 'vue';
import JSON5 from 'json5';
import MkTextarea from '@/components/MkTextarea.vue'; import MkTextarea from '@/components/MkTextarea.vue';
import MkButton from '@/components/MkButton.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 * as os from '@/os.js';
import { addTheme, getThemes } from '@/theme-store';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js'; import { definePageMetadata } from '@/scripts/page-metadata.js';
let installThemeCode = $ref(null); let installThemeCode = $ref(null);
function parseThemeCode(code: string) {
let theme;
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)) {
os.alert({
type: 'info',
text: i18n.ts._theme.alreadyInstalled,
});
return false;
}
return theme;
}
function preview(code: string): void {
const theme = parseThemeCode(code);
if (theme) applyTheme(theme, false);
}
async function install(code: string): Promise<void> { async function install(code: string): Promise<void> {
try {
const theme = parseThemeCode(code); const theme = parseThemeCode(code);
if (!theme) return; await installTheme(code);
await addTheme(theme);
os.alert({ os.alert({
type: 'success', type: 'success',
text: i18n.t('_theme.installed', { name: theme.name }), text: i18n.t('_theme.installed', { name: theme.name }),
}); });
} catch (err) {
switch (err.message.toLowerCase()) {
case 'this theme is already installed':
os.alert({
type: 'info',
text: i18n.ts._theme.alreadyInstalled,
});
break;
default:
os.alert({
type: 'error',
text: i18n.ts._theme.invalid,
});
break;
}
console.error(err);
}
} }
const headerActions = $computed(() => []); const headerActions = $computed(() => []);

View file

@ -322,6 +322,10 @@ export const routes = [{
}, { }, {
path: '/registry', path: '/registry',
component: page(() => import('./pages/registry.vue')), component: page(() => import('./pages/registry.vue')),
}, {
path: '/install-extentions',
component: page(() => import('./pages/install-extentions.vue')),
loginRequired: true,
}, { }, {
path: '/admin/user/:userId', path: '/admin/user/:userId',
component: iAmModerator ? page(() => import('./pages/admin-user.vue')) : page(() => import('./pages/not-found.vue')), component: iAmModerator ? page(() => import('./pages/admin-user.vue')) : page(() => import('./pages/not-found.vue')),

View file

@ -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,
});
}

View file

@ -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);
}

View file

@ -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 { declare namespace entities {

View file

@ -639,4 +639,11 @@ export type Endpoints = {
$default: UserDetailed; $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; };
};
}; };