From 123e5a5a15c83121aba3628efc813f9f83644227 Mon Sep 17 00:00:00 2001 From: syuilo Date: Mon, 11 Jan 2021 20:38:34 +0900 Subject: [PATCH] Registry (#7073) * wip * wip * wip * wip * wip * Update registry.value.vue * wip * wip * wip * wip * typo --- locales/ja-JP.yml | 14 ++ migration/1610277136869-registry.ts | 22 +++ migration/1610277585759-registry2.ts | 16 ++ migration/1610283021566-registry3.ts | 14 ++ package.json | 2 + src/client/account.ts | 1 - src/client/components/post-form.vue | 2 +- src/client/components/ui/info.vue | 2 +- src/client/init.ts | 8 - src/client/pages/settings/deck.vue | 21 ++- src/client/pages/settings/index.vue | 30 +++- src/client/pages/settings/other.vue | 8 +- src/client/pages/settings/registry.keys.vue | 115 ++++++++++++++ src/client/pages/settings/registry.value.vue | 149 ++++++++++++++++++ src/client/pages/settings/registry.vue | 91 +++++++++++ src/client/pizzax.ts | 56 ++++--- src/client/router.ts | 1 - src/client/ui/deck.vue | 3 +- src/client/ui/deck/deck-store.ts | 85 ++++++++-- src/db/postgre.ts | 2 + src/models/entities/registry-item.ts | 58 +++++++ src/models/entities/user-profile.ts | 1 + src/models/index.ts | 2 + src/models/repositories/user.ts | 1 - src/server/api/api-handler.ts | 5 +- src/server/api/endpoints/i.ts | 25 ++- src/server/api/endpoints/i/notifications.ts | 2 +- .../api/endpoints/i/registry/get-all.ts | 33 ++++ .../api/endpoints/i/registry/get-detail.ts | 48 ++++++ src/server/api/endpoints/i/registry/get.ts | 45 ++++++ .../endpoints/i/registry/keys-with-type.ts | 41 +++++ src/server/api/endpoints/i/registry/keys.ts | 28 ++++ src/server/api/endpoints/i/registry/remove.ts | 45 ++++++ src/server/api/endpoints/i/registry/scopes.ts | 30 ++++ src/server/api/endpoints/i/registry/set.ts | 61 +++++++ .../api/endpoints/i/update-client-setting.ts | 40 ----- yarn.lock | 10 ++ 37 files changed, 1017 insertions(+), 100 deletions(-) create mode 100644 migration/1610277136869-registry.ts create mode 100644 migration/1610277585759-registry2.ts create mode 100644 migration/1610283021566-registry3.ts create mode 100644 src/client/pages/settings/registry.keys.vue create mode 100644 src/client/pages/settings/registry.value.vue create mode 100644 src/client/pages/settings/registry.vue create mode 100644 src/models/entities/registry-item.ts create mode 100644 src/server/api/endpoints/i/registry/get-all.ts create mode 100644 src/server/api/endpoints/i/registry/get-detail.ts create mode 100644 src/server/api/endpoints/i/registry/get.ts create mode 100644 src/server/api/endpoints/i/registry/keys-with-type.ts create mode 100644 src/server/api/endpoints/i/registry/keys.ts create mode 100644 src/server/api/endpoints/i/registry/remove.ts create mode 100644 src/server/api/endpoints/i/registry/scopes.ts create mode 100644 src/server/api/endpoints/i/registry/set.ts delete mode 100644 src/server/api/endpoints/i/update-client-setting.ts diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 55847cca4..26e055f5c 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -685,6 +685,19 @@ accentColor: "アクセント" textColor: "文字" saveAs: "名前を付けて保存" advanced: "高度" +value: "値" +updatedAt: "更新日時" +saveConfirm: "保存しますか?" +deleteConfirm: "削除しますか?" +invalidValue: "有効な値ではありません。" +registry: "レジストリ" + +_registry: + scope: "スコープ" + key: "キー" + keys: "キー" + domain: "ドメイン" + createKey: "キーを作成" _aboutMisskey: about: "Misskeyはsyuiloによって2014年から開発されている、オープンソースのソフトウェアです。" @@ -1558,6 +1571,7 @@ _deck: swapDown: "下に移動" stackLeft: "左に重ねる" popRight: "右に出す" + profile: "プロファイル" _columns: main: "メイン" diff --git a/migration/1610277136869-registry.ts b/migration/1610277136869-registry.ts new file mode 100644 index 000000000..46c8113c1 --- /dev/null +++ b/migration/1610277136869-registry.ts @@ -0,0 +1,22 @@ +import {MigrationInterface, QueryRunner} from "typeorm"; + +export class registry1610277136869 implements MigrationInterface { + name = 'registry1610277136869' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "registry_item" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL, "userId" character varying(32) NOT NULL, "key" character varying(1024) NOT NULL, "scope" character varying(1024) array NOT NULL DEFAULT '{}'::varchar[], "domain" character varying(512), CONSTRAINT "PK_64b3f7e6008b4d89b826cd3af95" PRIMARY KEY ("id")); COMMENT ON COLUMN "registry_item"."createdAt" IS 'The created date of the RegistryItem.'; COMMENT ON COLUMN "registry_item"."updatedAt" IS 'The updated date of the RegistryItem.'; COMMENT ON COLUMN "registry_item"."userId" IS 'The owner ID.'; COMMENT ON COLUMN "registry_item"."key" IS 'The key of the RegistryItem.'`); + await queryRunner.query(`CREATE INDEX "IDX_fb9d21ba0abb83223263df6bcb" ON "registry_item" ("userId") `); + await queryRunner.query(`CREATE INDEX "IDX_22baca135bb8a3ea1a83d13df3" ON "registry_item" ("scope") `); + await queryRunner.query(`CREATE INDEX "IDX_0a72bdfcdb97c0eca11fe7ecad" ON "registry_item" ("domain") `); + await queryRunner.query(`ALTER TABLE "registry_item" ADD CONSTRAINT "FK_fb9d21ba0abb83223263df6bcb3" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "registry_item" DROP CONSTRAINT "FK_fb9d21ba0abb83223263df6bcb3"`); + await queryRunner.query(`DROP INDEX "IDX_0a72bdfcdb97c0eca11fe7ecad"`); + await queryRunner.query(`DROP INDEX "IDX_22baca135bb8a3ea1a83d13df3"`); + await queryRunner.query(`DROP INDEX "IDX_fb9d21ba0abb83223263df6bcb"`); + await queryRunner.query(`DROP TABLE "registry_item"`); + } + +} diff --git a/migration/1610277585759-registry2.ts b/migration/1610277585759-registry2.ts new file mode 100644 index 000000000..2f2d80c48 --- /dev/null +++ b/migration/1610277585759-registry2.ts @@ -0,0 +1,16 @@ +import {MigrationInterface, QueryRunner} from "typeorm"; + +export class registry21610277585759 implements MigrationInterface { + name = 'registry21610277585759' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "registry_item" ADD "value" jsonb NOT NULL DEFAULT '{}'`); + await queryRunner.query(`COMMENT ON COLUMN "registry_item"."value" IS 'The value of the RegistryItem.'`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`COMMENT ON COLUMN "registry_item"."value" IS 'The value of the RegistryItem.'`); + await queryRunner.query(`ALTER TABLE "registry_item" DROP COLUMN "value"`); + } + +} diff --git a/migration/1610283021566-registry3.ts b/migration/1610283021566-registry3.ts new file mode 100644 index 000000000..61f235fb2 --- /dev/null +++ b/migration/1610283021566-registry3.ts @@ -0,0 +1,14 @@ +import {MigrationInterface, QueryRunner} from "typeorm"; + +export class registry31610283021566 implements MigrationInterface { + name = 'registry31610283021566' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "registry_item" ALTER COLUMN "value" DROP NOT NULL`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "registry_item" ALTER COLUMN "value" SET NOT NULL`); + } + +} diff --git a/package.json b/package.json index cd7cc0ff0..685a5dead 100644 --- a/package.json +++ b/package.json @@ -98,6 +98,7 @@ "@types/sharp": "0.26.1", "@types/sinonjs__fake-timers": "6.0.1", "@types/speakeasy": "2.0.5", + "@types/throttle-debounce": "2.1.0", "@types/tinycolor2": "1.4.2", "@types/tmp": "0.2.0", "@types/uuid": "8.3.0", @@ -232,6 +233,7 @@ "syuilo-password-strength": "0.0.1", "textarea-caret": "3.1.0", "three": "0.117.1", + "throttle-debounce": "3.0.1", "tinycolor2": "1.4.2", "tmp": "0.2.1", "ts-loader": "8.0.11", diff --git a/src/client/account.ts b/src/client/account.ts index fdf49ee21..e6ee8613d 100644 --- a/src/client/account.ts +++ b/src/client/account.ts @@ -7,7 +7,6 @@ import { waiting } from '@/os'; type Account = { id: string; token: string; - clientData: Record; }; const data = localStorage.getItem('account'); diff --git a/src/client/components/post-form.vue b/src/client/components/post-form.vue index 19773b3b6..bf300eebd 100644 --- a/src/client/components/post-form.vue +++ b/src/client/components/post-form.vue @@ -262,7 +262,7 @@ export default defineComponent({ } // keep cw when reply - if (this.$store.keepCw && this.reply && this.reply.cw) { + if (this.$store.state.keepCw && this.reply && this.reply.cw) { this.useCw = true; this.cw = this.reply.cw; } diff --git a/src/client/components/ui/info.vue b/src/client/components/ui/info.vue index 3bdb69b3d..5c71b14a0 100644 --- a/src/client/components/ui/info.vue +++ b/src/client/components/ui/info.vue @@ -34,7 +34,7 @@ export default defineComponent({ font-size: 90%; background: var(--infoBg); color: var(--infoFg); - border-radius: 5px; + border-radius: var(--radius); &.warn { background: var(--infoWarnBg); diff --git a/src/client/init.ts b/src/client/init.ts index f39f50eea..f09097fe3 100644 --- a/src/client/init.ts +++ b/src/client/init.ts @@ -347,14 +347,6 @@ if ($i) { updateAccount({ hasUnreadAnnouncement: false }); }); - main.on('clientSettingUpdated', x => { - updateAccount({ - clientData: { - [x.key]: x.value - } - }); - }); - // トークンが再生成されたとき // このままではMisskeyが利用できないので強制的にサインアウトさせる main.on('myTokenRegenerated', () => { diff --git a/src/client/pages/settings/deck.vue b/src/client/pages/settings/deck.vue index 0d9f1ab0a..30d36d4a0 100644 --- a/src/client/pages/settings/deck.vue +++ b/src/client/pages/settings/deck.vue @@ -24,6 +24,8 @@ {{ $ts._deck.columnMargin }} + + {{ $ts._deck.profile }} @@ -31,7 +33,7 @@ import { defineComponent } from 'vue'; import { faImage, faCog, faColumns } from '@fortawesome/free-solid-svg-icons'; import FormSwitch from '@/components/form/switch.vue'; -import FormSelect from '@/components/form/select.vue'; +import FormLink from '@/components/form/link.vue'; import FormRadios from '@/components/form/radios.vue'; import FormInput from '@/components/form/input.vue'; import FormBase from '@/components/form/base.vue'; @@ -42,7 +44,7 @@ import * as os from '@/os'; export default defineComponent({ components: { FormSwitch, - FormSelect, + FormLink, FormInput, FormRadios, FormBase, @@ -67,6 +69,7 @@ export default defineComponent({ columnAlign: deckStore.makeGetterSetter('columnAlign'), columnMargin: deckStore.makeGetterSetter('columnMargin'), columnHeaderHeight: deckStore.makeGetterSetter('columnHeaderHeight'), + profile: deckStore.makeGetterSetter('profile'), }, watch: { @@ -85,5 +88,19 @@ export default defineComponent({ mounted() { this.$emit('info', this.INFO); }, + + methods: { + async setProfile() { + const { canceled, result: name } = await os.dialog({ + title: this.$ts._deck.profile, + input: { + allowEmpty: false + } + }); + if (canceled) return; + this.profile = name; + location.reload(); + } + } }); diff --git a/src/client/pages/settings/index.vue b/src/client/pages/settings/index.vue index aa9fe2716..0f95a76f1 100644 --- a/src/client/pages/settings/index.vue +++ b/src/client/pages/settings/index.vue @@ -35,13 +35,13 @@
- +
diff --git a/src/client/pages/settings/registry.value.vue b/src/client/pages/settings/registry.value.vue new file mode 100644 index 000000000..943ededd2 --- /dev/null +++ b/src/client/pages/settings/registry.value.vue @@ -0,0 +1,149 @@ + + + diff --git a/src/client/pages/settings/registry.vue b/src/client/pages/settings/registry.vue new file mode 100644 index 000000000..a43c98e73 --- /dev/null +++ b/src/client/pages/settings/registry.vue @@ -0,0 +1,91 @@ + + + diff --git a/src/client/pizzax.ts b/src/client/pizzax.ts index fdaf2bebb..794738edd 100644 --- a/src/client/pizzax.ts +++ b/src/client/pizzax.ts @@ -11,6 +11,7 @@ type ArrayElement = A extends readonly (infer T)[] ? T : never; export class Storage { public readonly key: string; + public readonly keyForLocalStorage: string; public readonly def: T; @@ -19,20 +20,22 @@ export class Storage { public readonly reactiveState: { [K in keyof T]: Ref }; constructor(key: string, def: T) { - this.key = 'pizzax::' + key; + this.key = key; + this.keyForLocalStorage = 'pizzax::' + key; this.def = def; // TODO: indexedDBにする - const deviceState = JSON.parse(localStorage.getItem(this.key) || '{}'); - const deviceAccountState = $i ? JSON.parse(localStorage.getItem(this.key + '::' + $i.id) || '{}') : {}; + const deviceState = JSON.parse(localStorage.getItem(this.keyForLocalStorage) || '{}'); + const deviceAccountState = $i ? JSON.parse(localStorage.getItem(this.keyForLocalStorage + '::' + $i.id) || '{}') : {}; + const registryCache = $i ? JSON.parse(localStorage.getItem(this.keyForLocalStorage + '::cache::' + $i.id) || '{}') : {}; const state = {}; const reactiveState = {}; for (const [k, v] of Object.entries(def)) { if (v.where === 'device' && Object.prototype.hasOwnProperty.call(deviceState, k)) { state[k] = deviceState[k]; - } else if (v.where === 'account' && $i && Object.prototype.hasOwnProperty.call($i.clientData, k)) { - state[k] = $i.clientData[k]; + } else if (v.where === 'account' && $i && Object.prototype.hasOwnProperty.call(registryCache, k)) { + state[k] = registryCache[k]; } else if (v.where === 'deviceAccount' && Object.prototype.hasOwnProperty.call(deviceAccountState, k)) { state[k] = deviceAccountState[k]; } else { @@ -47,16 +50,24 @@ export class Storage { this.reactiveState = reactiveState as any; if ($i) { - watch($i, () => { - if (_DEV_) console.log('$i updated'); - - for (const [k, v] of Object.entries(def)) { - if (v.where === 'account' && Object.prototype.hasOwnProperty.call($i!.clientData, k)) { - state[k] = $i!.clientData[k]; - reactiveState[k].value = $i!.clientData[k]; + // なぜかsetTimeoutしないとapi関数内でエラーになる(おそらく循環参照してることに原因がありそう) + setTimeout(() => { + api('i/registry/get-all', { scope: ['client', this.key] }).then(kvs => { + for (const [k, v] of Object.entries(def)) { + if (v.where === 'account') { + if (Object.prototype.hasOwnProperty.call(kvs, k)) { + state[k] = kvs[k]; + reactiveState[k].value = kvs[k]; + } else { + state[k] = v.default; + reactiveState[k].value = v.default; + } + } } - } - }); + }); + }, 1); + + // TODO: streamingのuser storage updateイベントを監視して更新 } } @@ -68,21 +79,26 @@ export class Storage { switch (this.def[key].where) { case 'device': { - const deviceState = JSON.parse(localStorage.getItem(this.key) || '{}'); + const deviceState = JSON.parse(localStorage.getItem(this.keyForLocalStorage) || '{}'); deviceState[key] = value; - localStorage.setItem(this.key, JSON.stringify(deviceState)); + localStorage.setItem(this.keyForLocalStorage, JSON.stringify(deviceState)); break; } case 'deviceAccount': { if ($i == null) break; - const deviceAccountState = JSON.parse(localStorage.getItem(this.key + '::' + $i.id) || '{}'); + const deviceAccountState = JSON.parse(localStorage.getItem(this.keyForLocalStorage + '::' + $i.id) || '{}'); deviceAccountState[key] = value; - localStorage.setItem(this.key + '::' + $i.id, JSON.stringify(deviceAccountState)); + localStorage.setItem(this.keyForLocalStorage + '::' + $i.id, JSON.stringify(deviceAccountState)); break; } case 'account': { - api('i/update-client-setting', { - name: key, + if ($i == null) break; + const cache = JSON.parse(localStorage.getItem(this.keyForLocalStorage + '::cache::' + $i.id) || '{}'); + cache[key] = value; + localStorage.setItem(this.keyForLocalStorage + '::cache::' + $i.id, JSON.stringify(cache)); + api('i/registry/set', { + scope: ['client', this.key], + key: key, value: value }); break; diff --git a/src/client/router.ts b/src/client/router.ts index 5753a4702..6f79426b2 100644 --- a/src/client/router.ts +++ b/src/client/router.ts @@ -81,7 +81,6 @@ export const router = createRouter({ { path: '/miauth/:session', component: page('miauth') }, { path: '/authorize-follow', component: page('follow') }, { path: '/share', component: page('share') }, - { path: '/test', component: page('test') }, { path: '/:catchAll(.*)', component: page('not-found') } ], // なんかHacky diff --git a/src/client/ui/deck.vue b/src/client/ui/deck.vue index 099a6f60c..a074629dd 100644 --- a/src/client/ui/deck.vue +++ b/src/client/ui/deck.vue @@ -41,7 +41,7 @@ import { getScrollContainer } from '@/scripts/scroll'; import * as os from '@/os'; import { sidebarDef } from '@/sidebar'; import XCommon from './_common_/common.vue'; -import { deckStore, addColumn } from './deck/deck-store'; +import { deckStore, addColumn, loadDeck } from './deck/deck-store'; export default defineComponent({ components: { @@ -88,6 +88,7 @@ export default defineComponent({ document.documentElement.style.overflowY = 'hidden'; document.documentElement.style.scrollBehavior = 'auto'; window.addEventListener('wheel', this.onWheel); + loadDeck(); }, mounted() { diff --git a/src/client/ui/deck/deck-store.ts b/src/client/ui/deck/deck-store.ts index 3d2e1873d..93ea0a322 100644 --- a/src/client/ui/deck/deck-store.ts +++ b/src/client/ui/deck/deck-store.ts @@ -1,5 +1,7 @@ +import { throttle } from 'throttle-debounce'; import { i18n } from '@/i18n'; -import { markRaw } from 'vue'; +import { api } from '@/os'; +import { markRaw, watch } from 'vue'; import { Storage } from '../../pizzax'; type ColumnWidget = { @@ -21,23 +23,17 @@ function copy(x: T): T { } export const deckStore = markRaw(new Storage('deck', { + profile: { + where: 'deviceAccount', + default: 'default' + }, columns: { where: 'deviceAccount', - default: [{ - id: 'a', - type: 'main', - name: i18n.locale._deck._columns.main, - width: 350, - }, { - id: 'b', - type: 'notifications', - name: i18n.locale._deck._columns.notifications, - width: 330, - }] as Column[] + default: [] as Column[] }, layout: { where: 'deviceAccount', - default: [['a'], ['b']] as Column['id'][][] + default: [] as Column['id'][][] }, columnAlign: { where: 'deviceAccount', @@ -61,10 +57,60 @@ export const deckStore = markRaw(new Storage('deck', { }, })); +export const loadDeck = async () => { + let deck; + + try { + deck = await api('i/registry/get', { + scope: ['client', 'deck', 'profiles'], + key: deckStore.state.profile, + }); + } catch (e) { + if (e.code === 'NO_SUCH_KEY') { + // 後方互換性のため + if (deckStore.state.profile === 'default') { + saveDeck(); + return; + } + + deckStore.set('columns', [{ + id: 'a', + type: 'main', + name: i18n.locale._deck._columns.main, + width: 350, + }, { + id: 'b', + type: 'notifications', + name: i18n.locale._deck._columns.notifications, + width: 330, + }]); + deckStore.set('layout', [['a'], ['b']]); + return; + } + throw e; + } + + deckStore.set('columns', deck.columns); + deckStore.set('layout', deck.layout); +}; + +// TODO: deckがloadされていない状態でsaveすると意図せず上書きが発生するので対策する +export const saveDeck = throttle(1000, () => { + api('i/registry/set', { + scope: ['client', 'deck', 'profiles'], + key: deckStore.state.profile, + value: { + columns: deckStore.reactiveState.columns.value, + layout: deckStore.reactiveState.layout.value, + } + }); +}); + export function addColumn(column: Column) { if (column.name == undefined) column.name = null; deckStore.push('columns', column); deckStore.push('layout', [column.id]); + saveDeck(); } export function removeColumn(id: Column['id']) { @@ -72,6 +118,7 @@ export function removeColumn(id: Column['id']) { deckStore.set('layout', deckStore.state.layout .map(ids => ids.filter(_id => _id !== id)) .filter(ids => ids.length > 0)); + saveDeck(); } export function swapColumn(a: Column['id'], b: Column['id']) { @@ -83,6 +130,7 @@ export function swapColumn(a: Column['id'], b: Column['id']) { layout[aX][aY] = b; layout[bX][bY] = a; deckStore.set('layout', layout); + saveDeck(); } export function swapLeftColumn(id: Column['id']) { @@ -98,6 +146,7 @@ export function swapLeftColumn(id: Column['id']) { return true; } }); + saveDeck(); } export function swapRightColumn(id: Column['id']) { @@ -113,6 +162,7 @@ export function swapRightColumn(id: Column['id']) { return true; } }); + saveDeck(); } export function swapUpColumn(id: Column['id']) { @@ -132,6 +182,7 @@ export function swapUpColumn(id: Column['id']) { return true; } }); + saveDeck(); } export function swapDownColumn(id: Column['id']) { @@ -151,6 +202,7 @@ export function swapDownColumn(id: Column['id']) { return true; } }); + saveDeck(); } export function stackLeftColumn(id: Column['id']) { @@ -160,6 +212,7 @@ export function stackLeftColumn(id: Column['id']) { layout[i - 1].push(id); layout = layout.filter(ids => ids.length > 0); deckStore.set('layout', layout); + saveDeck(); } export function popRightColumn(id: Column['id']) { @@ -169,6 +222,7 @@ export function popRightColumn(id: Column['id']) { layout.splice(i + 1, 0, [id]); layout = layout.filter(ids => ids.length > 0); deckStore.set('layout', layout); + saveDeck(); } export function addColumnWidget(id: Column['id'], widget: ColumnWidget) { @@ -180,6 +234,7 @@ export function addColumnWidget(id: Column['id'], widget: ColumnWidget) { column.widgets.unshift(widget); columns[columnIndex] = column; deckStore.set('columns', columns); + saveDeck(); } export function removeColumnWidget(id: Column['id'], widget: ColumnWidget) { @@ -190,6 +245,7 @@ export function removeColumnWidget(id: Column['id'], widget: ColumnWidget) { column.widgets = column.widgets.filter(w => w.id != widget.id); columns[columnIndex] = column; deckStore.set('columns', columns); + saveDeck(); } export function setColumnWidgets(id: Column['id'], widgets: ColumnWidget[]) { @@ -200,6 +256,7 @@ export function setColumnWidgets(id: Column['id'], widgets: ColumnWidget[]) { column.widgets = widgets; columns[columnIndex] = column; deckStore.set('columns', columns); + saveDeck(); } export function updateColumnWidget(id: Column['id'], widgetId: string, data: any) { @@ -213,6 +270,7 @@ export function updateColumnWidget(id: Column['id'], widgetId: string, data: any } : w); columns[columnIndex] = column; deckStore.set('columns', columns); + saveDeck(); } export function updateColumn(id: Column['id'], column: Partial) { @@ -225,4 +283,5 @@ export function updateColumn(id: Column['id'], column: Partial) { } columns[columnIndex] = currentColumn; deckStore.set('columns', columns); + saveDeck(); } diff --git a/src/db/postgre.ts b/src/db/postgre.ts index e2acdeafd..2f3c91016 100644 --- a/src/db/postgre.ts +++ b/src/db/postgre.ts @@ -63,6 +63,7 @@ import { MutedNote } from '../models/entities/muted-note'; import { Channel } from '../models/entities/channel'; import { ChannelFollowing } from '../models/entities/channel-following'; import { ChannelNotePining } from '../models/entities/channel-note-pining'; +import { RegistryItem } from '../models/entities/registry-item'; const sqlLogger = dbLogger.createSubLogger('sql', 'white', false); @@ -159,6 +160,7 @@ export const entities = [ Channel, ChannelFollowing, ChannelNotePining, + RegistryItem, ...charts as any ]; diff --git a/src/models/entities/registry-item.ts b/src/models/entities/registry-item.ts new file mode 100644 index 000000000..54d2ef208 --- /dev/null +++ b/src/models/entities/registry-item.ts @@ -0,0 +1,58 @@ +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { User } from './user'; +import { id } from '../id'; + +// TODO: 同じdomain、同じscope、同じkeyのレコードは二つ以上存在しないように制約付けたい +@Entity() +export class RegistryItem { + @PrimaryColumn(id()) + public id: string; + + @Column('timestamp with time zone', { + comment: 'The created date of the RegistryItem.' + }) + public createdAt: Date; + + @Column('timestamp with time zone', { + comment: 'The updated date of the RegistryItem.' + }) + public updatedAt: Date; + + @Index() + @Column({ + ...id(), + comment: 'The owner ID.' + }) + public userId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE' + }) + @JoinColumn() + public user: User | null; + + @Column('varchar', { + length: 1024, + comment: 'The key of the RegistryItem.' + }) + public key: string; + + @Column('jsonb', { + default: {}, nullable: true, + comment: 'The value of the RegistryItem.' + }) + public value: any | null; + + @Index() + @Column('varchar', { + length: 1024, array: true, default: '{}' + }) + public scope: string[]; + + // サードパーティアプリに開放するときのためのカラム + @Index() + @Column('varchar', { + length: 512, nullable: true + }) + public domain: string | null; +} diff --git a/src/models/entities/user-profile.ts b/src/models/entities/user-profile.ts index 97a4150be..0e2c66032 100644 --- a/src/models/entities/user-profile.ts +++ b/src/models/entities/user-profile.ts @@ -94,6 +94,7 @@ export class UserProfile { }) public password: string | null; + // TODO: そのうち消す @Column('jsonb', { default: {}, comment: 'The client-specific data of the User.' diff --git a/src/models/index.ts b/src/models/index.ts index dd05dcbcc..213570a9c 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -57,6 +57,7 @@ import { ChannelRepository } from './repositories/channel'; import { MutedNote } from './entities/muted-note'; import { ChannelFollowing } from './entities/channel-following'; import { ChannelNotePining } from './entities/channel-note-pining'; +import { RegistryItem } from './entities/registry-item'; export const Announcements = getRepository(Announcement); export const AnnouncementReads = getRepository(AnnouncementRead); @@ -116,3 +117,4 @@ export const MutedNotes = getRepository(MutedNote); export const Channels = getCustomRepository(ChannelRepository); export const ChannelFollowings = getRepository(ChannelFollowing); export const ChannelNotePinings = getRepository(ChannelNotePining); +export const RegistryItems = getRepository(RegistryItem); diff --git a/src/models/repositories/user.ts b/src/models/repositories/user.ts index 29facf523..7bf11b316 100644 --- a/src/models/repositories/user.ts +++ b/src/models/repositories/user.ts @@ -261,7 +261,6 @@ export class UserRepository extends Repository { } : {}), ...(opts.includeSecrets ? { - clientData: profile!.clientData, email: profile!.email, emailVerified: profile!.emailVerified, securityKeysList: profile!.twoFactorEnabled diff --git a/src/server/api/api-handler.ts b/src/server/api/api-handler.ts index 7fbc200fc..80a4fd97c 100644 --- a/src/server/api/api-handler.ts +++ b/src/server/api/api-handler.ts @@ -11,7 +11,7 @@ export default (endpoint: IEndpoint, ctx: Koa.Context) => new Promise((res) => { const reply = (x?: any, y?: ApiError) => { if (x == null) { ctx.status = 204; - } else if (typeof x === 'number') { + } else if (typeof x === 'number' && y) { ctx.status = x; ctx.body = { error: { @@ -23,7 +23,8 @@ export default (endpoint: IEndpoint, ctx: Koa.Context) => new Promise((res) => { } }; } else { - ctx.body = x; + // 文字列を返す場合は、JSON.stringify通さないとJSONと認識されない + ctx.body = typeof x === 'string' ? JSON.stringify(x) : x; } res(); }; diff --git a/src/server/api/endpoints/i.ts b/src/server/api/endpoints/i.ts index bceb9548e..3d0c092ad 100644 --- a/src/server/api/endpoints/i.ts +++ b/src/server/api/endpoints/i.ts @@ -1,5 +1,7 @@ import define from '../define'; -import { Users } from '../../../models'; +import { RegistryItems, UserProfiles, Users } from '../../../models'; +import { ensure } from '../../../prelude/ensure'; +import { genId } from '../../../misc/gen-id'; export const meta = { desc: { @@ -22,6 +24,27 @@ export const meta = { export default define(meta, async (ps, user, token) => { const isSecure = token == null; + // TODO: そのうち消す + const profile = await UserProfiles.findOne(user.id).then(ensure); + for (const [k, v] of Object.entries(profile.clientData)) { + await RegistryItems.insert({ + id: genId(), + createdAt: new Date(), + updatedAt: new Date(), + userId: user.id, + domain: null, + scope: ['client', 'base'], + key: k, + value: v + }); + } + await UserProfiles.createQueryBuilder().update() + .set({ + clientData: {}, + }) + .where('userId = :id', { id: user.id }) + .execute(); + return await Users.pack(user, user, { detail: true, includeSecrets: isSecure diff --git a/src/server/api/endpoints/i/notifications.ts b/src/server/api/endpoints/i/notifications.ts index fd355dab8..0e09bc73f 100644 --- a/src/server/api/endpoints/i/notifications.ts +++ b/src/server/api/endpoints/i/notifications.ts @@ -80,7 +80,7 @@ export default define(meta, async (ps, user) => { .where('muting.muterId = :muterId', { muterId: user.id }); const suspendedQuery = Users.createQueryBuilder('users') - .select('id') + .select('users.id') .where('users.isSuspended = TRUE'); const query = makePaginationQuery(Notifications.createQueryBuilder('notification'), ps.sinceId, ps.untilId) diff --git a/src/server/api/endpoints/i/registry/get-all.ts b/src/server/api/endpoints/i/registry/get-all.ts new file mode 100644 index 000000000..ce8653f22 --- /dev/null +++ b/src/server/api/endpoints/i/registry/get-all.ts @@ -0,0 +1,33 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { RegistryItems } from '../../../../../models'; + +export const meta = { + requireCredential: true as const, + + secure: true, + + params: { + scope: { + validator: $.optional.arr($.str.match(/^[a-zA-Z0-9_]+$/)), + default: [], + }, + } +}; + +export default define(meta, async (ps, user) => { + const query = RegistryItems.createQueryBuilder('item') + .where('item.domain IS NULL') + .andWhere('item.userId = :userId', { userId: user.id }) + .andWhere('item.scope = :scope', { scope: ps.scope }); + + const items = await query.getMany(); + + const res = {} as Record; + + for (const item of items) { + res[item.key] = item.value; + } + + return res; +}); diff --git a/src/server/api/endpoints/i/registry/get-detail.ts b/src/server/api/endpoints/i/registry/get-detail.ts new file mode 100644 index 000000000..441833d3d --- /dev/null +++ b/src/server/api/endpoints/i/registry/get-detail.ts @@ -0,0 +1,48 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { RegistryItems } from '../../../../../models'; +import { ApiError } from '../../../error'; + +export const meta = { + requireCredential: true as const, + + secure: true, + + params: { + key: { + validator: $.str + }, + + scope: { + validator: $.optional.arr($.str.match(/^[a-zA-Z0-9_]+$/)), + default: [], + }, + }, + + errors: { + noSuchKey: { + message: 'No such key.', + code: 'NO_SUCH_KEY', + id: '97a1e8e7-c0f7-47d2-957a-92e61256e01a' + }, + }, +}; + +export default define(meta, async (ps, user) => { + const query = RegistryItems.createQueryBuilder('item') + .where('item.domain IS NULL') + .andWhere('item.userId = :userId', { userId: user.id }) + .andWhere('item.key = :key', { key: ps.key }) + .andWhere('item.scope = :scope', { scope: ps.scope }); + + const item = await query.getOne(); + + if (item == null) { + throw new ApiError(meta.errors.noSuchKey); + } + + return { + updatedAt: item.updatedAt, + value: item.value, + }; +}); diff --git a/src/server/api/endpoints/i/registry/get.ts b/src/server/api/endpoints/i/registry/get.ts new file mode 100644 index 000000000..275e660cb --- /dev/null +++ b/src/server/api/endpoints/i/registry/get.ts @@ -0,0 +1,45 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { RegistryItems } from '../../../../../models'; +import { ApiError } from '../../../error'; + +export const meta = { + requireCredential: true as const, + + secure: true, + + params: { + key: { + validator: $.str + }, + + scope: { + validator: $.optional.arr($.str.match(/^[a-zA-Z0-9_]+$/)), + default: [], + }, + }, + + errors: { + noSuchKey: { + message: 'No such key.', + code: 'NO_SUCH_KEY', + id: 'ac3ed68a-62f0-422b-a7bc-d5e09e8f6a6a' + }, + }, +}; + +export default define(meta, async (ps, user) => { + const query = RegistryItems.createQueryBuilder('item') + .where('item.domain IS NULL') + .andWhere('item.userId = :userId', { userId: user.id }) + .andWhere('item.key = :key', { key: ps.key }) + .andWhere('item.scope = :scope', { scope: ps.scope }); + + const item = await query.getOne(); + + if (item == null) { + throw new ApiError(meta.errors.noSuchKey); + } + + return item.value; +}); diff --git a/src/server/api/endpoints/i/registry/keys-with-type.ts b/src/server/api/endpoints/i/registry/keys-with-type.ts new file mode 100644 index 000000000..06d77acbe --- /dev/null +++ b/src/server/api/endpoints/i/registry/keys-with-type.ts @@ -0,0 +1,41 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { RegistryItems } from '../../../../../models'; + +export const meta = { + requireCredential: true as const, + + secure: true, + + params: { + scope: { + validator: $.optional.arr($.str.match(/^[a-zA-Z0-9_]+$/)), + default: [], + }, + } +}; + +export default define(meta, async (ps, user) => { + const query = RegistryItems.createQueryBuilder('item') + .where('item.domain IS NULL') + .andWhere('item.userId = :userId', { userId: user.id }) + .andWhere('item.scope = :scope', { scope: ps.scope }); + + const items = await query.getMany(); + + const res = {} as Record; + + for (const item of items) { + const type = typeof item.value; + res[item.key] = + item.value === null ? 'null' : + Array.isArray(item.value) ? 'array' : + type === 'number' ? 'number' : + type === 'string' ? 'string' : + type === 'boolean' ? 'boolean' : + type === 'object' ? 'object' : + null as never; + } + + return res; +}); diff --git a/src/server/api/endpoints/i/registry/keys.ts b/src/server/api/endpoints/i/registry/keys.ts new file mode 100644 index 000000000..e4dd5044b --- /dev/null +++ b/src/server/api/endpoints/i/registry/keys.ts @@ -0,0 +1,28 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { RegistryItems } from '../../../../../models'; + +export const meta = { + requireCredential: true as const, + + secure: true, + + params: { + scope: { + validator: $.optional.arr($.str.match(/^[a-zA-Z0-9_]+$/)), + default: [], + }, + } +}; + +export default define(meta, async (ps, user) => { + const query = RegistryItems.createQueryBuilder('item') + .select('item.key') + .where('item.domain IS NULL') + .andWhere('item.userId = :userId', { userId: user.id }) + .andWhere('item.scope = :scope', { scope: ps.scope }); + + const items = await query.getMany(); + + return items.map(x => x.key); +}); diff --git a/src/server/api/endpoints/i/registry/remove.ts b/src/server/api/endpoints/i/registry/remove.ts new file mode 100644 index 000000000..e73444efd --- /dev/null +++ b/src/server/api/endpoints/i/registry/remove.ts @@ -0,0 +1,45 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { RegistryItems } from '../../../../../models'; +import { ApiError } from '../../../error'; + +export const meta = { + requireCredential: true as const, + + secure: true, + + params: { + key: { + validator: $.str + }, + + scope: { + validator: $.optional.arr($.str.match(/^[a-zA-Z0-9_]+$/)), + default: [], + }, + }, + + errors: { + noSuchKey: { + message: 'No such key.', + code: 'NO_SUCH_KEY', + id: '1fac4e8a-a6cd-4e39-a4a5-3a7e11f1b019' + }, + }, +}; + +export default define(meta, async (ps, user) => { + const query = RegistryItems.createQueryBuilder('item') + .where('item.domain IS NULL') + .andWhere('item.userId = :userId', { userId: user.id }) + .andWhere('item.key = :key', { key: ps.key }) + .andWhere('item.scope = :scope', { scope: ps.scope }); + + const item = await query.getOne(); + + if (item == null) { + throw new ApiError(meta.errors.noSuchKey); + } + + RegistryItems.remove(item); +}); diff --git a/src/server/api/endpoints/i/registry/scopes.ts b/src/server/api/endpoints/i/registry/scopes.ts new file mode 100644 index 000000000..8b0e1a7fd --- /dev/null +++ b/src/server/api/endpoints/i/registry/scopes.ts @@ -0,0 +1,30 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { RegistryItems } from '../../../../../models'; + +export const meta = { + requireCredential: true as const, + + secure: true, + + params: { + } +}; + +export default define(meta, async (ps, user) => { + const query = RegistryItems.createQueryBuilder('item') + .select('item.scope') + .where('item.domain IS NULL') + .andWhere('item.userId = :userId', { userId: user.id }); + + const items = await query.getMany(); + + const res = [] as string[][]; + + for (const item of items) { + if (res.some(scope => scope.join('.') === item.scope.join('.'))) continue; + res.push(item.scope); + } + + return res; +}); diff --git a/src/server/api/endpoints/i/registry/set.ts b/src/server/api/endpoints/i/registry/set.ts new file mode 100644 index 000000000..c732cfc8f --- /dev/null +++ b/src/server/api/endpoints/i/registry/set.ts @@ -0,0 +1,61 @@ +import $ from 'cafy'; +import { publishMainStream } from '../../../../../services/stream'; +import define from '../../../define'; +import { RegistryItems } from '../../../../../models'; +import { genId } from '../../../../../misc/gen-id'; + +export const meta = { + requireCredential: true as const, + + secure: true, + + params: { + key: { + validator: $.str.min(1) + }, + + value: { + validator: $.nullable.any + }, + + scope: { + validator: $.optional.arr($.str.match(/^[a-zA-Z0-9_]+$/)), + default: [], + }, + } +}; + +export default define(meta, async (ps, user) => { + const query = RegistryItems.createQueryBuilder('item') + .where('item.domain IS NULL') + .andWhere('item.userId = :userId', { userId: user.id }) + .andWhere('item.key = :key', { key: ps.key }) + .andWhere('item.scope = :scope', { scope: ps.scope }); + + const existingItem = await query.getOne(); + + if (existingItem) { + await RegistryItems.update(existingItem.id, { + updatedAt: new Date(), + value: ps.value + }); + } else { + await RegistryItems.insert({ + id: genId(), + createdAt: new Date(), + updatedAt: new Date(), + userId: user.id, + domain: null, + scope: ps.scope, + key: ps.key, + value: ps.value + }); + } + + // TODO: サードパーティアプリが傍受出来てしまうのでどうにかする + publishMainStream(user.id, 'registryUpdated', { + scope: ps.scope, + key: ps.key, + value: ps.value + }); +}); diff --git a/src/server/api/endpoints/i/update-client-setting.ts b/src/server/api/endpoints/i/update-client-setting.ts deleted file mode 100644 index 5143d3d9b..000000000 --- a/src/server/api/endpoints/i/update-client-setting.ts +++ /dev/null @@ -1,40 +0,0 @@ -import $ from 'cafy'; -import { publishMainStream } from '../../../../services/stream'; -import define from '../../define'; -import { UserProfiles } from '../../../../models'; -import { ensure } from '../../../../prelude/ensure'; - -export const meta = { - requireCredential: true as const, - - secure: true, - - params: { - name: { - validator: $.str.match(/^[a-zA-Z]+$/) - }, - - value: { - validator: $.nullable.any - } - } -}; - -export default define(meta, async (ps, user) => { - const profile = await UserProfiles.findOne(user.id).then(ensure); - - await UserProfiles.createQueryBuilder().update() - .set({ - clientData: Object.assign(profile.clientData, { - [ps.name]: ps.value - }), - }) - .where('userId = :id', { id: user.id }) - .execute(); - - // Publish event - publishMainStream(user.id, 'clientSettingUpdated', { - key: ps.name, - value: ps.value - }); -}); diff --git a/yarn.lock b/yarn.lock index 44cd0f074..3c53fc5a1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -911,6 +911,11 @@ resolved "https://registry.yarnpkg.com/@types/tapable/-/tapable-1.0.5.tgz#9adbc12950582aa65ead76bffdf39fe0c27a3c02" integrity sha512-/gG2M/Imw7cQFp8PGvz/SwocNrmKFjFsm5Pb8HdbHkZ1K8pmuPzOX4VeVoiEecFCVf4CsN1r3/BRvx+6sNqwtQ== +"@types/throttle-debounce@2.1.0": + version "2.1.0" + resolved "https://registry.yarnpkg.com/@types/throttle-debounce/-/throttle-debounce-2.1.0.tgz#1c3df624bfc4b62f992d3012b84c56d41eab3776" + integrity sha512-5eQEtSCoESnh2FsiLTxE121IiE60hnMqcb435fShf4bpLRjEu1Eoekht23y6zXS9Ts3l+Szu3TARnTsA0GkOkQ== + "@types/tinycolor2@1.4.2": version "1.4.2" resolved "https://registry.yarnpkg.com/@types/tinycolor2/-/tinycolor2-1.4.2.tgz#721ca5c5d1a2988b4a886e35c2ffc5735b6afbdf" @@ -10025,6 +10030,11 @@ three@0.117.1: resolved "https://registry.yarnpkg.com/three/-/three-0.117.1.tgz#a49bcb1a6ddea2f250003e42585dc3e78e92b9d3" integrity sha512-t4zeJhlNzUIj9+ub0l6nICVimSuRTZJOqvk3Rmlu+YGdTOJ49Wna8p7aumpkXJakJfITiybfpYE1XN1o1Z34UQ== +throttle-debounce@3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/throttle-debounce/-/throttle-debounce-3.0.1.tgz#32f94d84dfa894f786c9a1f290e7a645b6a19abb" + integrity sha512-dTEWWNu6JmeVXY0ZYoPuH5cRIwc0MeGbJwah9KUNYSJwommQpCzTySTpEe8Gs1J23aeWEuAobe4Ag7EHVt/LOg== + through2-filter@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/through2-filter/-/through2-filter-3.0.0.tgz#700e786df2367c2c88cd8aa5be4cf9c1e7831254"