diff --git a/.github/workflows/get-api-diff.yml b/.github/workflows/get-api-diff.yml index 8e3437ad86..e6de226e6b 100644 --- a/.github/workflows/get-api-diff.yml +++ b/.github/workflows/get-api-diff.yml @@ -6,9 +6,12 @@ on: branches: - master - develop + paths: + - packages/backend/** + - .github/workflows/get-api-diff.yml jobs: - get-base: + get-from-misskey: runs-on: ubuntu-latest permissions: contents: read @@ -16,27 +19,20 @@ jobs: strategy: matrix: node-version: [20.5.1] - - services: - db: - image: postgres:13 - ports: - - 5432:5432 - env: - POSTGRES_DB: misskey - POSTGRES_HOST_AUTH_METHOD: trust - POSTGRES_USER: example-misskey-user - POSTGRESS_PASS: example-misskey-pass - redis: - image: redis:7 - ports: - - 6379:6379 + api-json-name: [api-base.json, api-head.json] + include: + - api-json-name: api-base.json + repo-name: ${{ github.event.pull_request.base.repo.full_name }} + ref: ${{ github.base_ref }} + - api-json-name: api-head.json + repo-name: ${{ github.event.pull_request.head.repo.full_name }} + ref: ${{ github.head_ref }} steps: - uses: actions/checkout@v4.1.1 with: - repository: ${{ github.event.pull_request.base.repo.full_name }} - ref: ${{ github.base_ref }} + repository: ${{ matrix.repo-name }} + ref: ${{ matrix.ref }} submodules: true - name: Install pnpm uses: pnpm/action-setup@v2 @@ -56,121 +52,15 @@ jobs: run: cp .config/example.yml .config/default.yml - name: Build run: pnpm build - - name : Migrate - run: pnpm migrate - - name: Launch misskey - run: | - screen -S misskey -dm pnpm run dev - sleep 30s - - name: Wait for Misskey to be ready - run: | - MAX_RETRIES=12 - RETRY_DELAY=5 - count=0 - until $(curl --output /dev/null --silent --head --fail http://localhost:3000) || [[ $count -eq $MAX_RETRIES ]]; do - printf '.' - sleep $RETRY_DELAY - count=$((count + 1)) - done - - if [[ $count -eq $MAX_RETRIES ]]; then - echo "Failed to connect to Misskey after $MAX_RETRIES attempts." - exit 1 - fi - - id: fetch - name: Get api.json from Misskey - run: | - RESULT=$(curl --retry 5 --retry-delay 5 --retry-max-time 60 http://localhost:3000/api.json) - echo $RESULT > api-base.json + - name: Generate API JSON + run: pnpm --filter backend generate-api-json + - name: Copy API.json + run: cp packages/backend/built/api.json ${{ matrix.api-json-name }} - name: Upload Artifact uses: actions/upload-artifact@v3 with: name: api-artifact - path: api-base.json - - name: Kill Misskey Job - run: screen -S misskey -X quit - - get-head: - runs-on: ubuntu-latest - permissions: - contents: read - - strategy: - matrix: - node-version: [20.5.1] - - services: - db: - image: postgres:13 - ports: - - 5432:5432 - env: - POSTGRES_DB: misskey - POSTGRES_HOST_AUTH_METHOD: trust - POSTGRES_USER: example-misskey-user - POSTGRESS_PASS: example-misskey-pass - redis: - image: redis:7 - ports: - - 6379:6379 - - steps: - - uses: actions/checkout@v4.1.1 - with: - repository: ${{ github.event.pull_request.head.repo.full_name }} - ref: ${{ github.head_ref }} - submodules: true - - name: Install pnpm - uses: pnpm/action-setup@v2 - with: - version: 8 - run_install: false - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4.0.0 - with: - node-version: ${{ matrix.node-version }} - cache: 'pnpm' - - run: corepack enable - - run: pnpm i --frozen-lockfile - - name: Check pnpm-lock.yaml - run: git diff --exit-code pnpm-lock.yaml - - name: Copy Configure - run: cp .config/example.yml .config/default.yml - - name: Build - run: pnpm build - - name : Migrate - run: pnpm migrate - - name: Launch misskey - run: | - screen -S misskey -dm pnpm run dev - sleep 30s - - name: Wait for Misskey to be ready - run: | - MAX_RETRIES=12 - RETRY_DELAY=5 - count=0 - until $(curl --output /dev/null --silent --head --fail http://localhost:3000) || [[ $count -eq $MAX_RETRIES ]]; do - printf '.' - sleep $RETRY_DELAY - count=$((count + 1)) - done - - if [[ $count -eq $MAX_RETRIES ]]; then - echo "Failed to connect to Misskey after $MAX_RETRIES attempts." - exit 1 - fi - - id: fetch - name: Get api.json from Misskey - run: | - RESULT=$(curl --retry 5 --retry-delay 5 --retry-max-time 60 http://localhost:3000/api.json) - echo $RESULT > api-head.json - - name: Upload Artifact - uses: actions/upload-artifact@v3 - with: - name: api-artifact - path: api-head.json - - name: Kill Misskey Job - run: screen -S misskey -X quit + path: ${{ matrix.api-json-name }} save-pr-number: runs-on: ubuntu-latest diff --git a/CHANGELOG.md b/CHANGELOG.md index f85c52e312..4e1a79dd66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,13 +17,22 @@ ### General - Feat: メールアドレスの認証にverifymail.ioを使えるように (cherry-pick from https://github.com/TeamNijimiss/misskey/commit/971ba07a44550f68d2ba31c62066db2d43a0caed) - Feat: モデレーターがユーザーのアイコンもしくはバナー画像を未設定状態にできる機能を追加 (cherry-pick from https://github.com/TeamNijimiss/misskey/commit/e0eb5a752f6e5616d6312bb7c9790302f9dbff83) +- Fix: MFM `$[unixtime ]` に不正な値を入力した際に発生する各種エラーを修正 ### Client - Feat: コントロールパネルのアイコンデコレーションページから直接ファイルをアップロードおよび設定できるように +- Enhance: 絵文字のオートコンプリート機能強化 #12364 - Fix: 「設定のバックアップ」で一部の項目がバックアップに含まれていなかった問題を修正 +- Fix: ウィジェットのジョブキューにて音声の発音方法変更に追従できていなかったのを修正 #12367 +- Fix: コードエディタが正しく表示されない問題を修正 +- Fix: プロフィールの「ファイル」にセンシティブな画像がある際のデザインを修正 ### Server +- Enhance: MFM `$[ruby ]` が他ソフトウェアと連合されるように +- Fix: 時間経過により無効化されたアンテナを再有効化したとき、サーバ再起動までその状況が反映されないのを修正 #12303 - Fix: ロールタイムラインが保存されない問題を修正 +- Fix: api.jsonの生成ロジックを改善 #12402 +- Fix: 招待コードが使い回せる問題を修正 ## 2023.11.1 diff --git a/locales/index.d.ts b/locales/index.d.ts index 39fbb57799..3ed2706298 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -642,6 +642,7 @@ export interface Locale { "smtpSecureInfo": string; "testEmail": string; "wordMute": string; + "hardWordMute": string; "regexpError": string; "regexpErrorDescription": string; "instanceMute": string; diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 3757715c0f..9b5dfce63d 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -639,6 +639,7 @@ smtpSecure: "SMTP 接続に暗黙的なSSL/TLSを使用する" smtpSecureInfo: "STARTTLS使用時はオフにします。" testEmail: "配信テスト" wordMute: "ワードミュート" +hardWordMute: "ハードワードミュート" regexpError: "正規表現エラー" regexpErrorDescription: "{tab}ワードミュートの{line}行目の正規表現にエラーが発生しました:" instanceMute: "サーバーミュート" @@ -1855,7 +1856,7 @@ _ago: weeksAgo: "{n}週間前" monthsAgo: "{n}ヶ月前" yearsAgo: "{n}年前" - invalid: "ありません" + invalid: "日時の解析に失敗" _timeIn: seconds: "{n}秒後" diff --git a/packages/backend/generate_api_json.js b/packages/backend/generate_api_json.js new file mode 100644 index 0000000000..5819c60a5f --- /dev/null +++ b/packages/backend/generate_api_json.js @@ -0,0 +1,8 @@ +import { loadConfig } from './built/config.js' +import { genOpenapiSpec } from './built/server/api/openapi/gen-spec.js' +import { writeFileSync } from "node:fs"; + +const config = loadConfig(); +const spec = genOpenapiSpec(config); + +writeFileSync('./built/api.json', JSON.stringify(spec), 'utf-8'); \ No newline at end of file diff --git a/packages/backend/migration/1700383825690-hard-mute.js b/packages/backend/migration/1700383825690-hard-mute.js new file mode 100644 index 0000000000..afd3247f5c --- /dev/null +++ b/packages/backend/migration/1700383825690-hard-mute.js @@ -0,0 +1,11 @@ +export class HardMute1700383825690 { + name = 'HardMute1700383825690' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "user_profile" ADD "hardMutedWords" jsonb NOT NULL DEFAULT '[]'`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "user_profile" DROP COLUMN "hardMutedWords"`); + } +} diff --git a/packages/backend/package.json b/packages/backend/package.json index 3b029a49d2..18c7818dcc 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -23,7 +23,8 @@ "jest-and-coverage": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --coverage --forceExit", "jest-clear": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --clearCache", "test": "pnpm jest", - "test-and-coverage": "pnpm jest-and-coverage" + "test-and-coverage": "pnpm jest-and-coverage", + "generate-api-json": "node ./generate_api_json.js" }, "optionalDependencies": { "@swc/core-android-arm64": "1.3.11", diff --git a/packages/backend/src/core/AntennaService.ts b/packages/backend/src/core/AntennaService.ts index 65be275548..2815d24734 100644 --- a/packages/backend/src/core/AntennaService.ts +++ b/packages/backend/src/core/AntennaService.ts @@ -60,11 +60,21 @@ export class AntennaService implements OnApplicationShutdown { lastUsedAt: new Date(body.lastUsedAt), }); break; - case 'antennaUpdated': - this.antennas[this.antennas.findIndex(a => a.id === body.id)] = { - ...body, - lastUsedAt: new Date(body.lastUsedAt), - }; + case 'antennaUpdated': { + const idx = this.antennas.findIndex(a => a.id === body.id); + if (idx >= 0) { + this.antennas[idx] = { + ...body, + lastUsedAt: new Date(body.lastUsedAt), + }; + } else { + // サーバ起動時にactiveじゃなかった場合、リストに持っていないので追加する必要あり + this.antennas.push({ + ...body, + lastUsedAt: new Date(body.lastUsedAt), + }); + } + } break; case 'antennaDeleted': this.antennas = this.antennas.filter(a => a.id !== body.id); diff --git a/packages/backend/src/core/MfmService.ts b/packages/backend/src/core/MfmService.ts index af602168d4..e74c62e1a8 100644 --- a/packages/backend/src/core/MfmService.ts +++ b/packages/backend/src/core/MfmService.ts @@ -250,6 +250,12 @@ export class MfmService { } } + function fnDefault(node: mfm.MfmFn) { + const el = doc.createElement('i'); + appendChildren(node.children, el); + return el; + } + const handlers: { [K in mfm.MfmNode['type']]: (node: mfm.NodeType) => any } = { bold: (node) => { const el = doc.createElement('b'); @@ -276,17 +282,68 @@ export class MfmService { }, fn: (node) => { - if (node.props.name === 'unixtime') { - const text = node.children[0]!.type === 'text' ? node.children[0].props.text : ''; - const date = new Date(parseInt(text, 10) * 1000); - const el = doc.createElement('time'); - el.setAttribute('datetime', date.toISOString()); - el.textContent = date.toISOString(); - return el; - } else { - const el = doc.createElement('i'); - appendChildren(node.children, el); - return el; + switch (node.props.name) { + case 'unixtime': { + const text = node.children[0].type === 'text' ? node.children[0].props.text : ''; + try { + const date = new Date(parseInt(text, 10) * 1000); + const el = doc.createElement('time'); + el.setAttribute('datetime', date.toISOString()); + el.textContent = date.toISOString(); + return el; + } catch (err) { + return fnDefault(node); + } + } + + case 'ruby': { + if (node.children.length === 1) { + const child = node.children[0]; + const text = child.type === 'text' ? child.props.text : ''; + const rubyEl = doc.createElement('ruby'); + const rtEl = doc.createElement('rt'); + + // ruby未対応のHTMLサニタイザーを通したときにルビが「劉備(りゅうび)」となるようにする + const rpStartEl = doc.createElement('rp'); + rpStartEl.appendChild(doc.createTextNode('(')); + const rpEndEl = doc.createElement('rp'); + rpEndEl.appendChild(doc.createTextNode(')')); + + rubyEl.appendChild(doc.createTextNode(text.split(' ')[0])); + rtEl.appendChild(doc.createTextNode(text.split(' ')[1])); + rubyEl.appendChild(rpStartEl); + rubyEl.appendChild(rtEl); + rubyEl.appendChild(rpEndEl); + return rubyEl; + } else { + const rt = node.children.at(-1); + + if (!rt) { + return fnDefault(node); + } + + const text = rt.type === 'text' ? rt.props.text : ''; + const rubyEl = doc.createElement('ruby'); + const rtEl = doc.createElement('rt'); + + // ruby未対応のHTMLサニタイザーを通したときにルビが「劉備(りゅうび)」となるようにする + const rpStartEl = doc.createElement('rp'); + rpStartEl.appendChild(doc.createTextNode('(')); + const rpEndEl = doc.createElement('rp'); + rpEndEl.appendChild(doc.createTextNode(')')); + + appendChildren(node.children.slice(0, node.children.length - 1), rubyEl); + rtEl.appendChild(doc.createTextNode(text.trim())); + rubyEl.appendChild(rpStartEl); + rubyEl.appendChild(rtEl); + rubyEl.appendChild(rpEndEl); + return rubyEl; + } + } + + default: { + return fnDefault(node); + } } }, diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index 17e7988176..917f4e06d0 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -473,6 +473,7 @@ export class UserEntityService implements OnModuleInit { hasPendingReceivedFollowRequest: this.getHasPendingReceivedFollowRequest(user.id), unreadNotificationsCount: notificationsInfo?.unreadCount, mutedWords: profile!.mutedWords, + hardMutedWords: profile!.hardMutedWords, mutedInstances: profile!.mutedInstances, mutingNotificationTypes: [], // 後方互換性のため notificationRecieveConfig: profile!.notificationRecieveConfig, diff --git a/packages/backend/src/models/UserProfile.ts b/packages/backend/src/models/UserProfile.ts index d6d85c5609..8a43b60039 100644 --- a/packages/backend/src/models/UserProfile.ts +++ b/packages/backend/src/models/UserProfile.ts @@ -215,7 +215,12 @@ export class MiUserProfile { @Column('jsonb', { default: [], }) - public mutedWords: string[][]; + public mutedWords: (string[] | string)[]; + + @Column('jsonb', { + default: [], + }) + public hardMutedWords: (string[] | string)[]; @Column('jsonb', { default: [], diff --git a/packages/backend/src/models/json-schema/note.ts b/packages/backend/src/models/json-schema/note.ts index 9d5d558f51..392fa7e1cb 100644 --- a/packages/backend/src/models/json-schema/note.ts +++ b/packages/backend/src/models/json-schema/note.ts @@ -134,11 +134,19 @@ export const packedNoteSchema = { }, name: { type: 'string', - optional: false, nullable: true, + optional: false, nullable: false, + }, + color: { + type: 'string', + optional: false, nullable: false, }, isSensitive: { type: 'boolean', - optional: true, nullable: false, + optional: false, nullable: false, + }, + allowRenoteToExternal: { + type: 'boolean', + optional: false, nullable: false, }, }, }, diff --git a/packages/backend/src/models/json-schema/user.ts b/packages/backend/src/models/json-schema/user.ts index b0e18db01a..a2ec203e96 100644 --- a/packages/backend/src/models/json-schema/user.ts +++ b/packages/backend/src/models/json-schema/user.ts @@ -530,6 +530,18 @@ export const packedMeDetailedOnlySchema = { }, }, }, + hardMutedWords: { + type: 'array', + nullable: false, optional: false, + items: { + type: 'array', + nullable: false, optional: false, + items: { + type: 'string', + nullable: false, optional: false, + }, + }, + }, mutedInstances: { type: 'array', nullable: true, optional: false, diff --git a/packages/backend/src/server/api/SignupApiService.ts b/packages/backend/src/server/api/SignupApiService.ts index d6f4df7f13..753984ef52 100644 --- a/packages/backend/src/server/api/SignupApiService.ts +++ b/packages/backend/src/server/api/SignupApiService.ts @@ -126,7 +126,7 @@ export class SignupApiService { code: invitationCode, }); - if (ticket == null) { + if (ticket == null || ticket.usedById != null) { reply.code(400); return; } diff --git a/packages/backend/src/server/api/endpoints/admin/ad/list.ts b/packages/backend/src/server/api/endpoints/admin/ad/list.ts index 29eff89523..8cdeaae179 100644 --- a/packages/backend/src/server/api/endpoints/admin/ad/list.ts +++ b/packages/backend/src/server/api/endpoints/admin/ad/list.ts @@ -22,7 +22,7 @@ export const paramDef = { limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, - publishing: { type: 'boolean', default: false }, + publishing: { type: 'boolean', default: null, nullable: true }, }, required: [], } as const; @@ -37,8 +37,10 @@ export default class extends Endpoint { // eslint- ) { super(meta, paramDef, async (ps, me) => { const query = this.queryService.makePaginationQuery(this.adsRepository.createQueryBuilder('ad'), ps.sinceId, ps.untilId); - if (ps.publishing) { + if (ps.publishing === true) { query.andWhere('ad.expiresAt > :now', { now: new Date() }).andWhere('ad.startsAt <= :now', { now: new Date() }); + } else if (ps.publishing === false) { + query.andWhere('ad.expiresAt <= :now', { now: new Date() }).orWhere('ad.startsAt > :now', { now: new Date() }); } const ads = await query.limit(ps.limit).getMany(); diff --git a/packages/backend/src/server/api/endpoints/admin/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts index cc9afaf7fd..1dddb166ae 100644 --- a/packages/backend/src/server/api/endpoints/admin/meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/meta.ts @@ -267,6 +267,14 @@ export const meta = { type: 'boolean', optional: false, nullable: false, }, + enableVerifymailApi: { + type: 'boolean', + optional: false, nullable: false, + }, + verifymailAuthKey: { + type: 'string', + optional: false, nullable: true, + }, enableChartsForRemoteUser: { type: 'boolean', optional: false, nullable: false, @@ -421,6 +429,8 @@ export default class extends Endpoint { // eslint- deeplIsPro: instance.deeplIsPro, enableIpLogging: instance.enableIpLogging, enableActiveEmailValidation: instance.enableActiveEmailValidation, + enableVerifymailApi: instance.enableVerifymailApi, + verifymailAuthKey: instance.verifymailAuthKey, enableChartsForRemoteUser: instance.enableChartsForRemoteUser, enableChartsForFederatedInstances: instance.enableChartsForFederatedInstances, enableServerMachineStats: instance.enableServerMachineStats, diff --git a/packages/backend/src/server/api/endpoints/antennas/notes.ts b/packages/backend/src/server/api/endpoints/antennas/notes.ts index 9b5911800c..29e56b1085 100644 --- a/packages/backend/src/server/api/endpoints/antennas/notes.ts +++ b/packages/backend/src/server/api/endpoints/antennas/notes.ts @@ -13,6 +13,7 @@ import { DI } from '@/di-symbols.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { IdService } from '@/core/IdService.js'; import { FunoutTimelineService } from '@/core/FunoutTimelineService.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -71,6 +72,7 @@ export default class extends Endpoint { // eslint- private queryService: QueryService, private noteReadService: NoteReadService, private funoutTimelineService: FunoutTimelineService, + private globalEventService: GlobalEventService, ) { super(meta, paramDef, async (ps, me) => { const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null); @@ -85,10 +87,16 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.noSuchAntenna); } - this.antennasRepository.update(antenna.id, { - isActive: true, - lastUsedAt: new Date(), - }); + // falseだった場合はアンテナの配信先が増えたことを通知したい + const needPublishEvent = !antenna.isActive; + + antenna.isActive = true; + antenna.lastUsedAt = new Date(); + this.antennasRepository.update(antenna.id, antenna); + + if (needPublishEvent) { + this.globalEventService.publishInternalEvent('antennaUpdated', antenna); + } let noteIds = await this.funoutTimelineService.get(`antennaTimeline:${antenna.id}`, untilId, sinceId); noteIds = noteIds.slice(0, ps.limit); diff --git a/packages/backend/src/server/api/endpoints/federation/show-instance.ts b/packages/backend/src/server/api/endpoints/federation/show-instance.ts index 71eec11235..781c15e742 100644 --- a/packages/backend/src/server/api/endpoints/federation/show-instance.ts +++ b/packages/backend/src/server/api/endpoints/federation/show-instance.ts @@ -16,12 +16,9 @@ export const meta = { requireCredential: false, res: { - oneOf: [{ - type: 'object', - ref: 'FederationInstance', - }, { - type: 'null', - }], + type: 'object', + optional: false, nullable: true, + ref: 'FederationInstance', }, } as const; diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts index b00aa87bee..8ba29c5658 100644 --- a/packages/backend/src/server/api/endpoints/i/update.ts +++ b/packages/backend/src/server/api/endpoints/i/update.ts @@ -123,6 +123,11 @@ export const meta = { }, } as const; +const muteWords = { type: 'array', items: { oneOf: [ + { type: 'array', items: { type: 'string' } }, + { type: 'string' } +] } } as const; + export const paramDef = { type: 'object', properties: { @@ -171,7 +176,8 @@ export const paramDef = { autoSensitive: { type: 'boolean' }, ffVisibility: { type: 'string', enum: ['public', 'followers', 'private'] }, pinnedPageId: { type: 'string', format: 'misskey:id', nullable: true }, - mutedWords: { type: 'array' }, + mutedWords: muteWords, + hardMutedWords: muteWords, mutedInstances: { type: 'array', items: { type: 'string', } }, @@ -234,16 +240,20 @@ export default class extends Endpoint { // eslint- if (ps.location !== undefined) profileUpdates.location = ps.location; if (ps.birthday !== undefined) profileUpdates.birthday = ps.birthday; if (ps.ffVisibility !== undefined) profileUpdates.ffVisibility = ps.ffVisibility; - if (ps.mutedWords !== undefined) { + + function checkMuteWordCount(mutedWords: (string[] | string)[], limit: number) { // TODO: ちゃんと数える const length = JSON.stringify(ps.mutedWords).length; - if (length > (await this.roleService.getUserPolicies(user.id)).wordMuteLimit) { + if (length > limit) { throw new ApiError(meta.errors.tooManyMutedWords); } + } - // validate regular expression syntax - ps.mutedWords.filter(x => !Array.isArray(x)).forEach(x => { - const regexp = x.match(/^\/(.+)\/(.*)$/); + function validateMuteWordRegex(mutedWords: (string[] | string)[]) { + for (const mutedWord of mutedWords) { + if (typeof mutedWord !== "string") continue; + + const regexp = mutedWord.match(/^\/(.+)\/(.*)$/); if (!regexp) throw new ApiError(meta.errors.invalidRegexp); try { @@ -251,11 +261,21 @@ export default class extends Endpoint { // eslint- } catch (err) { throw new ApiError(meta.errors.invalidRegexp); } - }); + } + } + + if (ps.mutedWords !== undefined) { + checkMuteWordCount(ps.mutedWords, (await this.roleService.getUserPolicies(user.id)).wordMuteLimit); + validateMuteWordRegex(ps.mutedWords); profileUpdates.mutedWords = ps.mutedWords; profileUpdates.enableWordMute = ps.mutedWords.length > 0; } + if (ps.hardMutedWords !== undefined) { + checkMuteWordCount(ps.hardMutedWords, (await this.roleService.getUserPolicies(user.id)).wordMuteLimit); + validateMuteWordRegex(ps.hardMutedWords); + profileUpdates.hardMutedWords = ps.hardMutedWords; + } if (ps.mutedInstances !== undefined) profileUpdates.mutedInstances = ps.mutedInstances; if (ps.notificationRecieveConfig !== undefined) profileUpdates.notificationRecieveConfig = ps.notificationRecieveConfig; if (typeof ps.isLocked === 'boolean') updates.isLocked = ps.isLocked; diff --git a/packages/backend/src/server/api/openapi/gen-spec.ts b/packages/backend/src/server/api/openapi/gen-spec.ts index 4f972d3f7e..30bf6b8b3e 100644 --- a/packages/backend/src/server/api/openapi/gen-spec.ts +++ b/packages/backend/src/server/api/openapi/gen-spec.ts @@ -4,7 +4,7 @@ */ import type { Config } from '@/config.js'; -import endpoints from '../endpoints.js'; +import endpoints, { IEndpoint } from '../endpoints.js'; import { errors as basicErrors } from './errors.js'; import { schemas, convertSchemaToOpenApiSchema } from './schemas.js'; @@ -33,16 +33,17 @@ export function genOpenapiSpec(config: Config) { schemas: schemas, securitySchemes: { - ApiKeyAuth: { - type: 'apiKey', - in: 'body', - name: 'i', + bearerAuth: { + type: 'http', + scheme: 'bearer', }, }, }, }; - for (const endpoint of endpoints.filter(ep => !ep.meta.secure)) { + // 書き換えたりするのでディープコピーしておく。そのまま編集するとメモリ上の値が汚れて次回以降の出力に影響する + const copiedEndpoints = JSON.parse(JSON.stringify(endpoints)) as IEndpoint[]; + for (const endpoint of copiedEndpoints.filter(ep => !ep.meta.secure)) { const errors = {} as any; if (endpoint.meta.errors) { @@ -79,6 +80,13 @@ export function genOpenapiSpec(config: Config) { schema.required = [...schema.required ?? [], 'file']; } + if (schema.required && schema.required.length <= 0) { + // 空配列は許可されない + schema.required = undefined; + } + + const hasBody = (schema.type === 'object' && schema.properties && Object.keys(schema.properties).length >= 1); + const info = { operationId: endpoint.name, summary: endpoint.name, @@ -92,17 +100,19 @@ export function genOpenapiSpec(config: Config) { } : {}), ...(endpoint.meta.requireCredential ? { security: [{ - ApiKeyAuth: [], + bearerAuth: [], }], } : {}), - requestBody: { - required: true, - content: { - [requestType]: { - schema, + ...(hasBody ? { + requestBody: { + required: true, + content: { + [requestType]: { + schema, + }, }, }, - }, + } : {}), responses: { ...(endpoint.meta.res ? { '200': { @@ -118,6 +128,11 @@ export function genOpenapiSpec(config: Config) { description: 'OK (without any results)', }, }), + ...(endpoint.meta.res?.optional === true || endpoint.meta.res?.nullable === true ? { + '204': { + description: 'OK (without any results)', + }, + } : {}), '400': { description: 'Client error', content: { @@ -190,6 +205,7 @@ export function genOpenapiSpec(config: Config) { }; spec.paths['/' + endpoint.name] = { + ...(endpoint.meta.allowGet ? { get: info } : {}), post: info, }; } diff --git a/packages/backend/src/server/api/openapi/schemas.ts b/packages/backend/src/server/api/openapi/schemas.ts index 1a1d973e56..2716f5f162 100644 --- a/packages/backend/src/server/api/openapi/schemas.ts +++ b/packages/backend/src/server/api/openapi/schemas.ts @@ -7,10 +7,16 @@ import type { Schema } from '@/misc/json-schema.js'; import { refs } from '@/misc/json-schema.js'; export function convertSchemaToOpenApiSchema(schema: Schema) { - const res: any = schema; + // optional, refはスキーマ定義に含まれないので分離しておく + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { optional, ref, ...res }: any = schema; if (schema.type === 'object' && schema.properties) { - res.required = Object.entries(schema.properties).filter(([k, v]) => !v.optional).map(([k]) => k); + const required = Object.entries(schema.properties).filter(([k, v]) => !v.optional).map(([k]) => k); + if (required.length > 0) { + // 空配列は許可されない + res.required = required; + } for (const k of Object.keys(schema.properties)) { res.properties[k] = convertSchemaToOpenApiSchema(schema.properties[k]); diff --git a/packages/backend/test/e2e/users.ts b/packages/backend/test/e2e/users.ts index 1867525cc8..2ce8fbc129 100644 --- a/packages/backend/test/e2e/users.ts +++ b/packages/backend/test/e2e/users.ts @@ -168,6 +168,7 @@ describe('ユーザー', () => { hasPendingReceivedFollowRequest: user.hasPendingReceivedFollowRequest, unreadAnnouncements: user.unreadAnnouncements, mutedWords: user.mutedWords, + hardMutedWords: user.hardMutedWords, mutedInstances: user.mutedInstances, mutingNotificationTypes: user.mutingNotificationTypes, notificationRecieveConfig: user.notificationRecieveConfig, diff --git a/packages/frontend/src/components/MkAutocomplete.vue b/packages/frontend/src/components/MkAutocomplete.vue index 7e0c219045..a0f4961116 100644 --- a/packages/frontend/src/components/MkAutocomplete.vue +++ b/packages/frontend/src/components/MkAutocomplete.vue @@ -242,29 +242,7 @@ function exec() { return; } - const matched: EmojiDef[] = []; - const max = 30; - - emojiDb.value.some(x => { - if (x.name.startsWith(props.q ?? '') && !x.aliasOf && !matched.some(y => y.emoji === x.emoji)) matched.push(x); - return matched.length === max; - }); - - if (matched.length < max) { - emojiDb.value.some(x => { - if (x.name.startsWith(props.q ?? '') && !matched.some(y => y.emoji === x.emoji)) matched.push(x); - return matched.length === max; - }); - } - - if (matched.length < max) { - emojiDb.value.some(x => { - if (x.name.includes(props.q ?? '') && !matched.some(y => y.emoji === x.emoji)) matched.push(x); - return matched.length === max; - }); - } - - emojis.value = matched; + emojis.value = emojiAutoComplete(props.q, emojiDb.value); } else if (props.type === 'mfmTag') { if (!props.q || props.q === '') { mfmTags.value = MFM_TAGS; @@ -275,6 +253,82 @@ function exec() { } } +type EmojiScore = { emoji: EmojiDef, score: number }; + +function emojiAutoComplete(query: string | null, emojiDb: EmojiDef[], max = 30): EmojiDef[] { + if (!query) { + return []; + } + + const matched = new Map(); + + // 前方一致(エイリアスなし) + emojiDb.some(x => { + if (x.name.startsWith(query) && !x.aliasOf) { + matched.set(x.name, { emoji: x, score: query.length }); + } + return matched.size === max; + }); + + // 前方一致(エイリアス込み) + if (matched.size < max) { + emojiDb.some(x => { + if (x.name.startsWith(query)) { + matched.set(x.name, { emoji: x, score: query.length }); + } + return matched.size === max; + }); + } + + // 部分一致(エイリアス込み) + if (matched.size < max) { + emojiDb.some(x => { + if (x.name.includes(query)) { + matched.set(x.name, { emoji: x, score: query.length }); + } + return matched.size === max; + }); + } + + // 簡易あいまい検索 + if (matched.size < max) { + const queryChars = [...query]; + const hitEmojis = new Map(); + + for (const x of emojiDb) { + // クエリ文字列の1文字単位で絵文字名にヒットするかを見る + // ただし、過剰に検出されるのを防ぐためクエリ文字列に登場する順番で絵文字名を走査する + + let queryCharHitPos = 0; + let queryCharHitCount = 0; + for (let idx = 0; idx < queryChars.length; idx++) { + queryCharHitPos = x.name.indexOf(queryChars[idx], queryCharHitPos); + if (queryCharHitPos <= -1) { + break; + } + + queryCharHitCount++; + } + + // ヒット数が少なすぎると検索結果が汚れるので調節する + if (queryCharHitCount > 2) { + hitEmojis.set(x.name, { emoji: x, score: queryCharHitCount }); + } + } + + // ヒットしたものを全部追加すると雑多になるので、先頭の6件程度だけにしておく(6件=オートコンプリートのポップアップのサイズ分) + [...hitEmojis.values()] + .sort((x, y) => y.score - x.score) + .slice(0, 6) + .forEach(it => matched.set(it.emoji.name, it)); + } + + return [...matched.values()] + .sort((x, y) => y.score - x.score) + .slice(0, max) + .map(it => it.emoji); +} + function onMousedown(event: Event) { if (!contains(rootEl.value, event.target) && (rootEl.value !== event.target)) props.close(); } diff --git a/packages/frontend/src/components/MkCodeEditor.vue b/packages/frontend/src/components/MkCodeEditor.vue index 2d56a61963..03788af21e 100644 --- a/packages/frontend/src/components/MkCodeEditor.vue +++ b/packages/frontend/src/components/MkCodeEditor.vue @@ -139,6 +139,10 @@ watch(v, () => { height: 100%; } +.textarea, .codeEditorHighlighter { + margin: 0; +} + .textarea { position: absolute; top: 0; @@ -154,6 +158,8 @@ watch(v, () => { background-color: transparent; border: 0; outline: 0; + min-width: calc(100% - 24px); + height: calc(100% - 24px); padding: 12px; line-height: 1.5em; font-size: 1em; diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue index e300ef88a5..6349df2e30 100644 --- a/packages/frontend/src/components/MkNote.vue +++ b/packages/frontend/src/components/MkNote.vue @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only