From c49a34811f94829f8f0d53036716a4be175951d7 Mon Sep 17 00:00:00 2001 From: HotoRas Date: Fri, 9 Aug 2024 19:39:01 +0900 Subject: [PATCH] Merge changes from old repository MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit nekoplanet/misskey-io#13 Add changelog by @HotoRas 2215de539d8bd49c9a1fd3b3cb0596c6773b1a8b by @HotoRas c645e8f0defd2670eaf60f51c10924caf7acb2f1 by @HotoRas nekoplanet/misskey-io#15 노트 수정 기능 부활 (3트) by @HotoRas nekoplanet/misskey-io#16 Feat: "다른 계정 추가" 버튼 아래에 "새 계정 추가" 버튼이 살아 있어서 지웠습니다 by @HotoRas nekoplanet/misskey-io#17 Typecheck Fix by @janghoseo nekoplanet/misskey-io#21 Fix note edit 2 by @HotoRas --- .github/workflows/storybook.yml | 111 ------- Changelog-neko.md | 19 ++ locales/en-US.yml | 1 + locales/index.d.ts | 4 + locales/ja-JP.yml | 1 + locales/ko-KR.yml | 1 + package.json | 2 +- .../1720853122058-RevRevertNoteEdit.js | 20 ++ packages/backend/src/config.ts | 8 + packages/backend/src/core/CoreModule.ts | 6 + .../backend/src/core/NoteUpdateService.ts | 295 ++++++++++++++++++ packages/backend/src/core/RoleService.ts | 24 ++ packages/backend/src/core/S3Service.ts | 3 + .../src/core/activitypub/ApInboxService.ts | 60 ++++ .../core/activitypub/models/ApNoteService.ts | 80 +++++ packages/backend/src/core/activitypub/type.ts | 1 + packages/backend/src/models/Note.ts | 26 ++ .../backend/src/models/json-schema/note.ts | 22 ++ .../backend/src/models/json-schema/role.ts | 32 ++ .../backend/src/server/api/EndpointsModule.ts | 4 + packages/backend/src/server/api/endpoints.ts | 2 + .../src/server/api/endpoints/endpoint.ts | 6 +- .../src/server/api/endpoints/notes/update.ts | 168 ++++++++++ .../backend/test/unit/NoteCreateService.ts | 3 + packages/backend/test/unit/activitypub.ts | 29 ++ packages/backend/test/unit/misc/is-renote.ts | 3 + packages/frontend/src/account.ts | 4 +- packages/frontend/src/const.ts | 8 + packages/frontend/src/os.ts | 3 +- .../frontend/src/scripts/get-note-menu.ts | 17 + packages/misskey-js/etc/misskey-js.api.md | 4 + .../misskey-js/generator/docs/README.ko.md | 17 + .../misskey-js/src/autogen/apiClientJSDoc.ts | 11 + packages/misskey-js/src/autogen/endpoint.ts | 2 + packages/misskey-js/src/autogen/entities.ts | 1 + packages/misskey-js/src/autogen/types.ts | 91 ++++++ 36 files changed, 971 insertions(+), 118 deletions(-) delete mode 100644 .github/workflows/storybook.yml create mode 100644 Changelog-neko.md create mode 100644 packages/backend/migration/1720853122058-RevRevertNoteEdit.js create mode 100644 packages/backend/src/core/NoteUpdateService.ts create mode 100644 packages/backend/src/server/api/endpoints/notes/update.ts create mode 100644 packages/misskey-js/generator/docs/README.ko.md diff --git a/.github/workflows/storybook.yml b/.github/workflows/storybook.yml deleted file mode 100644 index 68452aacaf88..000000000000 --- a/.github/workflows/storybook.yml +++ /dev/null @@ -1,111 +0,0 @@ -name: Storybook - -on: - push: - branches: - - master - - develop - - dev/storybook8 # for testing - pull_request_target: - -jobs: - build: - runs-on: ubuntu-latest - - env: - NODE_OPTIONS: "--max_old_space_size=7168" - - steps: - - uses: actions/checkout@v4.1.1 - if: github.event_name != 'pull_request_target' - with: - fetch-depth: 0 - submodules: true - - uses: actions/checkout@v4.1.1 - if: github.event_name == 'pull_request_target' - with: - fetch-depth: 0 - submodules: true - ref: "refs/pull/${{ github.event.number }}/merge" - - name: Checkout actual HEAD - if: github.event_name == 'pull_request_target' - id: rev - run: | - echo "base=$(git rev-list --parents -n1 HEAD | cut -d" " -f2)" >> $GITHUB_OUTPUT - git checkout $(git rev-list --parents -n1 HEAD | cut -d" " -f3) - - name: Install pnpm - uses: pnpm/action-setup@v4 - - name: Use Node.js 20.x - uses: actions/setup-node@v4.0.3 - with: - node-version-file: '.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: Build misskey-js - run: pnpm --filter misskey-js build - - name: Build storybook - run: pnpm --filter frontend build-storybook - - name: Publish to Chromatic - if: github.event_name != 'pull_request_target' && github.ref == 'refs/heads/master' - run: pnpm --filter frontend chromatic --exit-once-uploaded -d storybook-static - env: - CHROMATIC_PROJECT_TOKEN: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} - - name: Publish to Chromatic - if: github.event_name != 'pull_request_target' && github.ref != 'refs/heads/master' - id: chromatic_push - run: | - DIFF="${{ github.event.before }} HEAD" - if [ "$DIFF" = "0000000000000000000000000000000000000000 HEAD" ]; then - DIFF="HEAD" - fi - CHROMATIC_PARAMETER="$(node packages/frontend/.storybook/changes.js $(git diff-tree --no-commit-id --name-only -r $(echo "$DIFF") | xargs))" - if [ "$CHROMATIC_PARAMETER" = " --skip" ]; then - echo "skip=true" >> $GITHUB_OUTPUT - fi - if pnpm --filter frontend chromatic -d storybook-static $(echo "$CHROMATIC_PARAMETER"); then - echo "success=true" >> $GITHUB_OUTPUT - else - echo "success=false" >> $GITHUB_OUTPUT - fi - env: - CHROMATIC_PROJECT_TOKEN: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} - - name: Publish to Chromatic - if: github.event_name == 'pull_request_target' - id: chromatic_pull_request - run: | - DIFF="${{ steps.rev.outputs.base }} HEAD" - if [ "$DIFF" = "0000000000000000000000000000000000000000 HEAD" ]; then - DIFF="HEAD" - fi - CHROMATIC_PARAMETER="$(node packages/frontend/.storybook/changes.js $(git diff-tree --no-commit-id --name-only -r $(echo "$DIFF") | xargs))" - if [ "$CHROMATIC_PARAMETER" = " --skip" ]; then - echo "skip=true" >> $GITHUB_OUTPUT - fi - BRANCH="${{ github.event.pull_request.head.user.login }}:$HEAD_REF" - if [ "$BRANCH" = "misskey-dev:$HEAD_REF" ]; then - BRANCH="$HEAD_REF" - fi - pnpm --filter frontend chromatic --exit-once-uploaded -d storybook-static --branch-name "$BRANCH" $(echo "$CHROMATIC_PARAMETER") - env: - HEAD_REF: ${{ github.event.pull_request.head.ref }} - CHROMATIC_PROJECT_TOKEN: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} - - name: Notify that Chromatic detects changes - uses: actions/github-script@v7.0.1 - if: github.event_name != 'pull_request_target' && steps.chromatic_push.outputs.success == 'false' - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - github.rest.repos.createCommitComment({ - owner: context.repo.owner, - repo: context.repo.repo, - commit_sha: context.sha, - body: 'Chromatic detects changes. Please [review the changes on Chromatic](https://www.chromatic.com/builds?appId=6428f7d7b962f0b79f97d6e4).' - }) - - name: Upload Artifacts - uses: actions/upload-artifact@v4 - with: - name: storybook - path: packages/frontend/storybook-static diff --git a/Changelog-neko.md b/Changelog-neko.md new file mode 100644 index 000000000000..9efee1c7f39c --- /dev/null +++ b/Changelog-neko.md @@ -0,0 +1,19 @@ +## Unreleased + +### General +- Fix: 프론트엔드의 타입 이슈 +- Revert: s3 설정을 설정 파일로 옮김 (취소됨: MisskeyIO#104) +- Feat: 노트 수정 기능 부활 (Code cherry-picked from cherrypick) + +### Client +- Revert: s3 설정 페이지를 관리 페이지 하위에서 삭제 (취소됨: MisskeyIO#104) + +### Backend + +### Frontend +- Feat: "다른 계정 추가" 버튼 아래에 "새 계정 추가" 버튼이 살아 있어서 지웠습니다 + +### misskey-js + +### develop +- QoL: `misskey-js`의 갱신과 이를 적용한 전체 빌드의 자동화 스크립트 추가 \ No newline at end of file diff --git a/locales/en-US.yml b/locales/en-US.yml index 2cb76fa74652..a16fdae893cd 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -657,6 +657,7 @@ tokenRequested: "Grant access to account" pluginTokenRequestedDescription: "This plugin will be able to use the permissions set here." notificationType: "Notification type" edit: "Edit" +editConfirm: "Edit note? Some remote servers won't properly display the edited content." emailServer: "Email server" enableEmail: "Enable email distribution" emailConfigInfo: "Used to confirm your email during sign-up or if you forget your password" diff --git a/locales/index.d.ts b/locales/index.d.ts index 91d36a14a627..da217bcaa2ad 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -2644,6 +2644,10 @@ export interface Locale extends ILocale { * 編集 */ "edit": string; + /** + * ノートを修正しますか?連合するサーバーによっては、修正後のノートが正常に表示されない場合があります。 + */ + "editConfirm": string; /** * メールサーバー */ diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index b493183974cc..44cf4b16da09 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -657,6 +657,7 @@ tokenRequested: "アカウントへのアクセス許可" pluginTokenRequestedDescription: "このプラグインはここで設定した権限を行使できるようになります。" notificationType: "通知の種類" edit: "編集" +editConfirm: "ノートを修正しますか?連合するサーバーによっては、修正後のノートが正常に表示されない場合があります。" emailServer: "メールサーバー" enableEmail: "メール配信機能を有効化する" emailConfigInfo: "メールアドレスの確認やパスワードリセットの際に使います" diff --git a/locales/ko-KR.yml b/locales/ko-KR.yml index 34c1cc3ebfb5..a2d04a77a984 100644 --- a/locales/ko-KR.yml +++ b/locales/ko-KR.yml @@ -651,6 +651,7 @@ tokenRequested: "계정 접근 허용" pluginTokenRequestedDescription: "이 플러그인은 여기서 설정한 권한을 사용할 수 있게 됩니다." notificationType: "알림 유형" edit: "편집" +editConfirm: "노트를 수정하시겠습니까? 연합되는 서버에 따라 수정 후의 노트가 정상적으로 표시되지 않을 수 있습니다." emailServer: "메일 서버" enableEmail: "이메일 송신 기능 활성화" emailConfigInfo: "가입 시 메일 주소 확인이나 비밀번호 초기화 시에 사용합니다." diff --git a/package.json b/package.json index 581a2603a193..c339f0c0e9af 100644 --- a/package.json +++ b/package.json @@ -75,4 +75,4 @@ "optionalDependencies": { "@tensorflow/tfjs-core": "4.4.0" } -} +} \ No newline at end of file diff --git a/packages/backend/migration/1720853122058-RevRevertNoteEdit.js b/packages/backend/migration/1720853122058-RevRevertNoteEdit.js new file mode 100644 index 000000000000..4ee0a2b8a2b8 --- /dev/null +++ b/packages/backend/migration/1720853122058-RevRevertNoteEdit.js @@ -0,0 +1,20 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class RevRevertNoteEdit1720853122058 { + name = 'RevRevertNoteEdit1720853122058' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "note" ADD "updatedAt" TIMESTAMP WITH TIME ZONE`); + await queryRunner.query(`ALTER TABLE "note" ADD "updatedAtHistory" TIMESTAMP WITH TIME ZONE ARRAY`); + await queryRunner.query(`ALTER TABLE "note" ADD "noteEditHistory" character varying array`) + } + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "note" DROP COLUMN "updatedAt"`); + await queryRunner.query(`ALTER TABLE "note" DROP "updatedAtHistory" TIMESTAMP WITH TIME ZONE ARRAY`); + await queryRunner.query(`ALTER TABLE "note" DROP "noteEditHistory"`) + } + +} diff --git a/packages/backend/src/config.ts b/packages/backend/src/config.ts index 3e5a1e81cd70..475504bc2197 100644 --- a/packages/backend/src/config.ts +++ b/packages/backend/src/config.ts @@ -129,6 +129,14 @@ export type Config = { index: string; scope?: 'local' | 'global' | string[]; } | undefined; + skebStatus: { + method: string; + endpoint: string; + headers: { [x: string]: string }; + parameters: { [x: string]: string }; + userIdParameterName: string; + roleId: string; + } | undefined; proxy: string | undefined; proxySmtp: string | undefined; proxyBypassHosts: string[] | undefined; diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index c9427bbeb7bd..48e53e4858bb 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -41,6 +41,7 @@ import { MetaService } from './MetaService.js'; import { MfmService } from './MfmService.js'; import { ModerationLogService } from './ModerationLogService.js'; import { NoteCreateService } from './NoteCreateService.js'; +import { NoteUpdateService } from './NoteUpdateService.js'; import { NoteDeleteService } from './NoteDeleteService.js'; import { NotePiningService } from './NotePiningService.js'; import { NoteReadService } from './NoteReadService.js'; @@ -183,6 +184,7 @@ const $MetaService: Provider = { provide: 'MetaService', useExisting: MetaServic const $MfmService: Provider = { provide: 'MfmService', useExisting: MfmService }; const $ModerationLogService: Provider = { provide: 'ModerationLogService', useExisting: ModerationLogService }; const $NoteCreateService: Provider = { provide: 'NoteCreateService', useExisting: NoteCreateService }; +const $NoteUpdateService: Provider = { provide: 'NoteUpdateService', useExisting: NoteUpdateService }; const $NoteDeleteService: Provider = { provide: 'NoteDeleteService', useExisting: NoteDeleteService }; const $NotePiningService: Provider = { provide: 'NotePiningService', useExisting: NotePiningService }; const $NoteReadService: Provider = { provide: 'NoteReadService', useExisting: NoteReadService }; @@ -331,6 +333,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting MfmService, ModerationLogService, NoteCreateService, + NoteUpdateService, NoteDeleteService, NotePiningService, NoteReadService, @@ -475,6 +478,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $MfmService, $ModerationLogService, $NoteCreateService, + $NoteUpdateService, $NoteDeleteService, $NotePiningService, $NoteReadService, @@ -620,6 +624,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting MfmService, ModerationLogService, NoteCreateService, + NoteUpdateService, NoteDeleteService, NotePiningService, NoteReadService, @@ -763,6 +768,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $MfmService, $ModerationLogService, $NoteCreateService, + $NoteUpdateService, $NoteDeleteService, $NotePiningService, $NoteReadService, diff --git a/packages/backend/src/core/NoteUpdateService.ts b/packages/backend/src/core/NoteUpdateService.ts new file mode 100644 index 000000000000..e717b644e489 --- /dev/null +++ b/packages/backend/src/core/NoteUpdateService.ts @@ -0,0 +1,295 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { setImmediate } from "timers/promises"; +import util from 'util'; +import { In, DataSource, TransactionAlreadyStartedError } from 'typeorm'; +import { Inject, Injectable, OnApplicationShutdown } from "@nestjs/common"; +import * as mfm from 'mfm-js'; +import type { IMentionedRemoteUsers } from "@/models/Note.js"; +import { MiNote } from "@/models/Note.js"; +import type { NotesRepository, UsersRepository } from "@/models/_.js"; +import type { MiUser, MiLocalUser, MiRemoteUser } from "@/models/User.js"; +import { RelayService } from "@/core/RelayService.js"; +import { DI } from "@/di-symbols.js"; +import ActiveUsersChart from "@/core/chart/charts/active-users.js"; +import { GlobalEventService } from "@/core/GlobalEventService.js"; +import { UserEntityService } from "@/core/entities/UserEntityService.js"; +import { ApRendererService } from "@/core/activitypub/ApRendererService.js"; +import { ApDeliverManagerService } from "@/core/activitypub/ApDeliverManagerService.js"; +import { bindThis } from "@/decorators.js"; +import { DB_MAX_NOTE_TEXT_LENGTH } from "@/const.js"; +import { SearchService } from "@/core/SearchService.js"; +import { normalizeForSearch } from "@/misc/normalize-for-search.js"; +import { MiDriveFile } from "@/models/_.js"; +import { MiPoll, IPoll } from "@/models/Poll.js"; +import { concat } from "@/misc/prelude/array.js"; +import { extractHashtags } from "@/misc/extract-hashtags.js"; +import { extractCustomEmojisFromMfm } from "@/misc/extract-custom-emojis-from-mfm.js"; +import { ApiError } from "@/server/api/error.js"; + +type MinimumUser = { + id: MiUser['id']; + host: MiUser['host']; + username: MiUser['username']; + uri: MiUser['uri']; +}; + +type Option = { + updatedAt?: Date | null; + files?: MiDriveFile[] | null; + name?: string | null; + text?: string | null; + cw?: string | null; + apHashtags?: string[] | null; + apEmojis?: string[] | null; + poll?: IPoll | null; +}; + +@Injectable() +export class NoteUpdateService implements OnApplicationShutdown { + #shutdownController = new AbortController(); + + constructor ( + @Inject(DI.db) + private db: DataSource, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + private userEntityService: UserEntityService, + private globalEventService: GlobalEventService, + private relayService: RelayService, + private apDeliverManagerService: ApDeliverManagerService, + private apRendererService: ApRendererService, + private searchService: SearchService, + private activeUsersChart: ActiveUsersChart, + ) {} + + @bindThis + public async update(user: { + id: MiUser['id'], + username: MiUser['username'], + host: MiUser['host'], + isBot: MiUser['isBot'], + }, data: Option, note: MiNote, silent = false): Promise { + if (data.updatedAt == null) data.updatedAt = new Date(); + + if (data.text) { + if (data.text.length > DB_MAX_NOTE_TEXT_LENGTH) + data.text = data.text.slice(0, DB_MAX_NOTE_TEXT_LENGTH); + data.text = data.text.trim(); + } else { + data.text = null; + } + + let tags = data.apHashtags; + let emojis = data.apEmojis; + + // Parse MFM if needed + if (!tags || !emojis) { + const tokens = data.text ? mfm.parse(data.text) : []; + const cwTokens = data.cw ? mfm.parse(data.cw) : []; + const choiceTokens = data.poll && data.poll.choices + ? concat(data.poll.choices.map((choice: string) => mfm.parse(choice))) + : []; + + const combinedTokens = tokens.concat(cwTokens).concat(choiceTokens); + + tags = data.apHashtags ?? extractHashtags(combinedTokens); + emojis = data.apEmojis ?? extractCustomEmojisFromMfm(combinedTokens); + } + + const updatedNote = await this.updateNote(user, note, data, (tags ?? []), (emojis ?? [])); + + if (updatedNote) { + setImmediate('post updated', { signal: this.#shutdownController.signal }).then( + () => this.postNoteUpdated(updatedNote, user, silent), + () => { /* Aborated: ignore this */}, + ); + } + + return updatedNote; + } + + @bindThis + private async updateNote(user: { + id: MiUser['id'], + host: MiUser['host'], + }, note: MiNote, data: Option, tags: string[], emojis: string[]): Promise { + if (data.updatedAt === null || data.updatedAt === undefined) { + data.updatedAt = new Date(); + const updatedAtHistory = note.updatedAtHistory ?? []; + + const values = new MiNote({ + updatedAt: data.updatedAt, + fileIds: data.files ? data.files.map(file => file.id) : [], + text: data.text, + hasPoll: data.poll != null, + cw: data.cw ?? null, + tags: tags.map(tag => normalizeForSearch(tag)), + emojis: emojis, + attachedFileTypes: data.files ? data.files.map(file => file.type) : [], + updatedAtHistory: [...updatedAtHistory, data.updatedAt], + noteEditHistory: [...note.noteEditHistory, ((note.cw ? note.cw + '\n' : '') + note.text as string)], + }); + + try { + if (note.hasPoll && values.hasPoll) { + // start transaction + await this.db.transaction(async transactionalEntityManager => { + await transactionalEntityManager.update(MiNote, { id: note.id }, values); + + if (values.hasPoll) { + const old_poll = await transactionalEntityManager.findOneBy(MiPoll, { noteId: note.id }); + if ((old_poll && old_poll.choices.toString() !== data.poll?.choices.toString()) + || (old_poll && old_poll.multiple !== data.poll?.multiple)) { + await transactionalEntityManager.delete(MiPoll, { noteId: note.id }); + const poll = new MiPoll ({ + noteId: note.id, + choices: data.poll!.choices, + expiresAt: data.poll!.expiresAt, + multiple: data.poll!.multiple, + votes: new Array(data.poll!.choices.length).fill(0), + noteVisibility: note.visibility, + userId: user.id, + userHost: user.host, + }); + await transactionalEntityManager.insert(MiPoll, poll); + } + } + }); + } else if (!note.hasPoll && values.hasPoll) { + // start transaction + await this.db.transaction(async transactionalEntitymanager => { + await transactionalEntitymanager.update(MiNote, { id: note.id }, values); + + if (values.hasPoll) { + const poll = new MiPoll ({ + noteId: note.id, + choices: data.poll!.choices, + expiresAt: data.poll!.expiresAt, + multiple: data.poll!.multiple, + votes: new Array(data.poll!.choices.length).fill(0), + noteVisibility: note.visibility, + userId: user.id, + userHost: user.host, + }); + + await transactionalEntitymanager.insert(MiPoll, poll); + } + }); + } else if (note.hasPoll && !values.hasPoll) { + // start transaction + await this.db.transaction(async transactionalEntityManager => { + await transactionalEntityManager.update(MiNote, { id: note.id }, values); + + if(!values.hasPoll) + await transactionalEntityManager.delete(MiPoll, { noteId: note.id }); + }); + } else { + await this.notesRepository.update({ id: note.id }, values); + } + + const updatedNote = await this.notesRepository.findOneBy({ id: note.id }); + if (updatedNote) return updatedNote; + throw new ApiError({ message: 'Updated note has gone.', id: '6dffaa9b-f578-45ac-9755-9e64290b4528', code: 'NOTE_UPDATE_GONE' }, { noteId: note.id }); + } catch (e) { + console.error(e); throw e; + } + } + + @bindThis + private async postNoteUpdated (note: MiNote, user: { + id: MiUser['id'], + username: MiUser['username'], + host: MiUser['host'], + isBot: MiUser['isBot'], + }, silent: boolean) { + if (!silent) { + if (this.userEntityService.isLocalUser(user)) this.activeUsersChart.write(user); + + this.globalEventService.publishNoteStream(note.id, 'updated', { cw: note.cw, text: (note.text ?? '') }); + + //#region AP deliver + if (this.userEntityService.isLocalUser(user)) { + await (async () => { + const noteActivity = await this.renderNoteActivity(note, user as MiUser); + + await this.deliverToConcerned(user, note, noteActivity); + })(); + } + //#endregion + } + + // Register to search database + this.reIndex(note); + } + + @bindThis + private async renderNoteActivity(note: MiNote, user: MiUser) { + const content = this.apRendererService.renderUpdate(await this.apRendererService.renderNote(note, false), user); + + return this.apRendererService.addContext(content); + } + + @bindThis + private async getMentionedRemoteUsers(note: MiNote) { + const where = [] as any[]; + + // mention / reply / dm + const uris = (JSON.parse(note.mentionedRemoteUsers) as IMentionedRemoteUsers).map(x => x.uri); + if (uris.length > 0) { + where.push( + { uri: In(uris) }, + ); + } + + // renote / quote + if (note.renoteUserId) { + where.push({ + id: note.renoteUserId, + }); + } + + if (where.length === 0) return []; + + return await this.usersRepository.find({ + where, + }) as MiRemoteUser[]; + } + + @bindThis + private async deliverToConcerned(user: { id: MiLocalUser['id']; host: null; }, note: MiNote, content: any) { + console.log('deliverToConcerned', util.inspect(content, { depth: null })); + await this.apDeliverManagerService.deliverToFollowers(user, content); + await this.relayService.deliverToRelays(user, content); + const remoteUsers = await this.getMentionedRemoteUsers(note); + for (const remoteUser of remoteUsers) { + await this.apDeliverManagerService.deliverToUser(user, content, remoteUser); + } + } + + @bindThis + private reIndex(note: MiNote) { + if (note.text == null && note.cw == null) return; + + this.searchService.unindexNote(note); + this.searchService.indexNote(note); + } + + @bindThis + public dispose(): void { + this.#shutdownController.abort(); + } + + @bindThis + public onApplicationShutdown(signal?: string | undefined): void { + this.dispose(); + } +} \ No newline at end of file diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts index 796677467364..087bd4648d95 100644 --- a/packages/backend/src/core/RoleService.ts +++ b/packages/backend/src/core/RoleService.ts @@ -35,6 +35,14 @@ export type RolePolicies = { gtlAvailable: boolean; ltlAvailable: boolean; canPublicNote: boolean; + canEditNote: boolean; + canInitiateConversation: boolean; + canCreateContent: boolean; + canUpdateContent: boolean; + canDeleteContent: boolean; + canPurgeAccount: boolean; + canUpdateAvatar: boolean; + canUpdateBanner: boolean; mentionLimit: number; canInvite: boolean; inviteLimit: number; @@ -64,6 +72,14 @@ export const DEFAULT_POLICIES: RolePolicies = { gtlAvailable: true, ltlAvailable: true, canPublicNote: true, + canEditNote: true, + canInitiateConversation: true, + canCreateContent: true, + canUpdateContent: true, + canDeleteContent: true, + canPurgeAccount: true, + canUpdateAvatar: true, + canUpdateBanner: true, mentionLimit: 20, canInvite: false, inviteLimit: 0, @@ -366,6 +382,14 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { gtlAvailable: calc('gtlAvailable', vs => vs.some(v => v === true)), ltlAvailable: calc('ltlAvailable', vs => vs.some(v => v === true)), canPublicNote: calc('canPublicNote', vs => vs.some(v => v === true)), + canEditNote: calc('canEditNote', vs => vs.some(v => v === true)), + canInitiateConversation: calc('canInitiateConversation', vs => vs.some(v => v === true)), + canCreateContent: calc('canCreateContent', vs => vs.some(v => v === true)), + canUpdateContent: calc('canUpdateContent', vs => vs.some(v => v === true)), + canDeleteContent: calc('canDeleteContent', vs => vs.some(v => v === true)), + canPurgeAccount: calc('canPurgeAccount', vs => vs.some(v => v === true)), + canUpdateAvatar: calc('canUpdateAvatar', vs => vs.some(v => v === true)), + canUpdateBanner: calc('canUpdateBanner', vs => vs.some(v => v === true)), mentionLimit: calc('mentionLimit', vs => Math.max(...vs)), canInvite: calc('canInvite', vs => vs.some(v => v === true)), inviteLimit: calc('inviteLimit', vs => Math.max(...vs)), diff --git a/packages/backend/src/core/S3Service.ts b/packages/backend/src/core/S3Service.ts index bb2a463354a7..a4a8bbdd3446 100644 --- a/packages/backend/src/core/S3Service.ts +++ b/packages/backend/src/core/S3Service.ts @@ -10,6 +10,9 @@ import { Injectable } from '@nestjs/common'; import { DeleteObjectCommand, S3Client } from '@aws-sdk/client-s3'; import { Upload } from '@aws-sdk/lib-storage'; import { NodeHttpHandler, NodeHttpHandlerOptions } from '@smithy/node-http-handler'; +import { bindThis } from '@/decorators.js'; +import { DI } from '@/di-symbols.js'; +import type { Config } from '@/config.js'; import type { MiMeta } from '@/models/Meta.js'; import { HttpRequestService } from '@/core/HttpRequestService.js'; import { bindThis } from '@/decorators.js'; diff --git a/packages/backend/src/core/activitypub/ApInboxService.ts b/packages/backend/src/core/activitypub/ApInboxService.ts index e2164fec1d93..945d9d77cad1 100644 --- a/packages/backend/src/core/activitypub/ApInboxService.ts +++ b/packages/backend/src/core/activitypub/ApInboxService.ts @@ -14,6 +14,7 @@ import { NotePiningService } from '@/core/NotePiningService.js'; import { UserBlockingService } from '@/core/UserBlockingService.js'; import { NoteDeleteService } from '@/core/NoteDeleteService.js'; import { NoteCreateService } from '@/core/NoteCreateService.js'; +import { NoteUpdateService } from '@/core/NoteUpdateService.js'; import { concat, toArray, toSingle, unique } from '@/misc/prelude/array.js'; import { AppLockService } from '@/core/AppLockService.js'; import type Logger from '@/logger.js'; @@ -73,6 +74,7 @@ export class ApInboxService { private notePiningService: NotePiningService, private userBlockingService: UserBlockingService, private noteCreateService: NoteCreateService, + private noteUpdateService: NoteUpdateService, private noteDeleteService: NoteDeleteService, private appLockService: AppLockService, private apResolverService: ApResolverService, @@ -770,11 +772,69 @@ export class ApInboxService { } else if (getApType(object) === 'Question') { await this.apQuestionService.updateQuestion(object, resolver).catch(err => console.error(err)); return 'ok: Question updated'; + } else if (getApType(object) === 'Note') { + await this.updateNote(resolver, actor, object, false, activity); + return 'ok: Note updated'; + } else if (additionalCc && isPost(object)) { + const uri = getApId(object); + const unlock = await this.appLockService.getApLock(uri); + + try { + const exist = await this.apNoteService.fetchNote(object); + if (exist && !await this.noteEntityService.isVisibleForMe(exist, additionalCc)) { + await this.noteCreateService.appendNoteVisibleUser(actor, exist, additionalCc); + return 'ok: note visible user appended'; + } else { + return 'skip: nothing to do'; + } + } catch (err) { + if (err instanceof StatusError && !err.isRetryable) { + return `skip ${err.statusCode}`; + } else { + throw err; + } + } finally { + unlock(); + } } else { return `skip: Unknown type: ${getApType(object)}`; } } + @bindThis + private async updateNote(resolver: Resolver, actor: MiRemoteUser, note: IObject, silent = false, activity?: IUpdate): Promise { + const uri = getApId(note); + + if (typeof note === 'object') { + if (actor.uri !== note.attributedTo) { + return 'skip: actor.uri !== note.attributedTo'; + } + + if (typeof note.id === 'string') { + if (this.utilityService.extractDbHost(actor.uri) !== this.utilityService.extractDbHost(note.id)) { + return 'skip: host in actor.uri !== note.id'; + } + } + } + + const unlock = await this.appLockService.getApLock(uri); + + try { + const target = await this.notesRepository.findOneBy({ uri: uri }); + if (!target) return `skip: target note not located: ${uri}`; + await this.apNoteService.updateNote(note, target, resolver, silent); + return 'ok'; + } catch (err) { + if (err instanceof StatusError && err.isClientError) { + return `skip ${err.statusCode}`; + } else { + throw err; + } + } finally { + unlock(); + } + } + @bindThis private async move(actor: MiRemoteUser, activity: IMove): Promise { // fetch the new and old accounts diff --git a/packages/backend/src/core/activitypub/models/ApNoteService.ts b/packages/backend/src/core/activitypub/models/ApNoteService.ts index fc7aa1e0b972..ed562ccea53e 100644 --- a/packages/backend/src/core/activitypub/models/ApNoteService.ts +++ b/packages/backend/src/core/activitypub/models/ApNoteService.ts @@ -23,6 +23,7 @@ import { StatusError } from '@/misc/status-error.js'; import { UtilityService } from '@/core/UtilityService.js'; import { bindThis } from '@/decorators.js'; import { checkHttps } from '@/misc/check-https.js'; +import { NoteUpdateService } from '@/core/NoteUpdateService.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; import { getOneApId, getApId, getOneApHrefNullable, validPost, isEmoji, getApType } from '../type.js'; import { ApLoggerService } from '../ApLoggerService.js'; @@ -69,6 +70,7 @@ export class ApNoteService { private appLockService: AppLockService, private pollService: PollService, private noteCreateService: NoteCreateService, + private noteUpdateService: NoteUpdateService, private apDbResolverService: ApDbResolverService, private apLoggerService: ApLoggerService, ) { @@ -324,6 +326,84 @@ export class ApNoteService { } } + /** + * Updates Note. + * + * If there's target Note it updates- + * otherwise fetch from remote and returns. + */ + + @bindThis + public async updateNote(value: string | IObject, target: MiNote, resolver?: Resolver, silent = false): Promise { + if (resolver == null) resolver = this.apResolverService.createResolver(); + + const object = await resolver.resolve(value); + const entryUri = getApId(value); + + const err = this.validateNote(object, entryUri); + if (err) { + this.logger.error(err.message, { + resolver: { history: resolver.getHistory() }, + value, + object, + }); + throw new Error('invalid note'); + } + + const note = object as IPost; + + if (note.attributedTo == null) + throw new Error('invalid note.attributedTo' + note.attributedTo); + + const actor = await this.apPersonService + .resolvePerson(getOneApId(note.attributedTo), resolver) as MiRemoteUser; + if (actor.isSuspended) + throw new Error('actor has been suspended'); + + const files: MiDriveFile[] = []; + + for (const attach of toArray(note.attachment)) { + attach.sensitive ??= note.sensitive; + const file = await this.apImageService.resolveImage(actor, attach); + if (file) files.push(file); + } + + const cw = note.summary === '' ? null : note.summary; + + let text: string | null = null; + if (note.source?.mediaType === 'text/x.misskeymarkdown' && typeof note.source.content === 'string') { + text = note.source.content; + } else if (typeof note._misskey_content !== undefined) { + text = note._misskey_content ?? null; + } else if (typeof note.content === 'string') { + text = this.apMfmService.htmlToMfm(note.content, note.tag); + } + + const apHashtags = extractApHashtags(note.tag); + const emojis = await this.extractEmojis(note.tag ?? [], actor.host).catch(e => { + this.logger.info(`extractEmojis: ${e}`); return []; + }); + + const apEmojis = emojis.map((emoji: MiEmoji) => emoji.name); + + const poll = await this.apQuestionService.extractPollFromQuestion(note, resolver).catch(() => undefined); + + try { + return await this.noteUpdateService.update(actor, { + updatedAt: note.updated ? new Date(note.updated) : null, + files: files, + name: note.name, + cw: cw, + text: text, + apHashtags: apHashtags, + apEmojis: apEmojis, + poll: poll, + }, target, silent); + } catch (err: any) { + this.logger.warn(`note update failed: ${err}`); return err; + } + } + /** * Noteを解決します。 * diff --git a/packages/backend/src/core/activitypub/type.ts b/packages/backend/src/core/activitypub/type.ts index 5b6c6c8ca6cb..a491fbaf103c 100644 --- a/packages/backend/src/core/activitypub/type.ts +++ b/packages/backend/src/core/activitypub/type.ts @@ -14,6 +14,7 @@ export interface IObject { summary?: string; _misskey_summary?: string; published?: string; + updated?: string; cc?: ApObject; to?: ApObject; attributedTo?: ApObject; diff --git a/packages/backend/src/models/Note.ts b/packages/backend/src/models/Note.ts index 9a95c6faab9b..759ff78094ba 100644 --- a/packages/backend/src/models/Note.ts +++ b/packages/backend/src/models/Note.ts @@ -15,6 +15,26 @@ export class MiNote { @PrimaryColumn(id()) public id: string; + @Index() + @Column('timestamp with time zone', { + comment: 'The created date of the Note.', + default: () => 'CURRENT_TIMESTAMP', + }) + public createdAt: Date; + + @Column('timestamp with time zone', { + comment: 'The last updated date of the Note.', + default: null, + }) + public updatedAt: Date | null; + + @Column('timestamp with time zone', { + comment: 'The history of when the note is updated', + array: true, + default: null, + }) + public updatedAtHistory: Date[] | null; + @Index() @Column({ ...id(), @@ -55,6 +75,12 @@ export class MiNote { }) public text: string | null; + @Column('text', { + array: true, + default: '{}', + }) + public noteEditHistory: string[]; + @Column('varchar', { length: 256, nullable: true, }) diff --git a/packages/backend/src/models/json-schema/note.ts b/packages/backend/src/models/json-schema/note.ts index 432c096e484c..a19cfec62ac4 100644 --- a/packages/backend/src/models/json-schema/note.ts +++ b/packages/backend/src/models/json-schema/note.ts @@ -17,6 +17,20 @@ export const packedNoteSchema = { optional: false, nullable: false, format: 'date-time', }, + updatedAt: { + type: 'string', + optional: true, nullable: false, + format: 'date-time', + }, + updatedAtHistory: { + type: 'array', + optional: true, nullable: false, + items: { + type: 'string', + optional: false, nullable: false, + format: 'date-time', + } + }, deletedAt: { type: 'string', optional: true, nullable: true, @@ -26,6 +40,14 @@ export const packedNoteSchema = { type: 'string', optional: false, nullable: true, }, + noteEditHistory: { + type: 'array', + optional: true, nullable: false, + items: { + type: 'string', + optional: false, nullable: true, + } + }, cw: { type: 'string', optional: true, nullable: true, diff --git a/packages/backend/src/models/json-schema/role.ts b/packages/backend/src/models/json-schema/role.ts index 7366f053560d..78d19b3eaa1a 100644 --- a/packages/backend/src/models/json-schema/role.ts +++ b/packages/backend/src/models/json-schema/role.ts @@ -180,6 +180,38 @@ export const packedRolePoliciesSchema = { type: 'boolean', optional: false, nullable: false, }, + canEditNote: { + type: 'boolean', + optional: false, nullable: false, + }, + canInitiateConversation: { + type: 'boolean', + optional: false, nullable: false, + }, + canCreateContent: { + type: 'boolean', + optional: false, nullable: false, + }, + canUpdateContent: { + type: 'boolean', + optional: false, nullable: false, + }, + canDeleteContent: { + type: 'boolean', + optional: false, nullable: false, + }, + canPurgeAccount: { + type: 'boolean', + optional: false, nullable: false, + }, + canUpdateAvatar: { + type: 'boolean', + optional: false, nullable: false, + }, + canUpdateBanner: { + type: 'boolean', + optional: false, nullable: false, + }, mentionLimit: { type: 'integer', optional: false, nullable: false, diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index 41576bedaae7..ae05b6366d96 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -278,6 +278,7 @@ import * as ep___notes_children from './endpoints/notes/children.js'; import * as ep___notes_clips from './endpoints/notes/clips.js'; import * as ep___notes_conversation from './endpoints/notes/conversation.js'; import * as ep___notes_create from './endpoints/notes/create.js'; +import * as ep___notes_update from './endpoints/notes/update.js'; import * as ep___notes_delete from './endpoints/notes/delete.js'; import * as ep___notes_favorites_create from './endpoints/notes/favorites/create.js'; import * as ep___notes_favorites_delete from './endpoints/notes/favorites/delete.js'; @@ -661,6 +662,7 @@ const $notes_children: Provider = { provide: 'ep:notes/children', useClass: ep__ const $notes_clips: Provider = { provide: 'ep:notes/clips', useClass: ep___notes_clips.default }; const $notes_conversation: Provider = { provide: 'ep:notes/conversation', useClass: ep___notes_conversation.default }; const $notes_create: Provider = { provide: 'ep:notes/create', useClass: ep___notes_create.default }; +const $notes_update: Provider = { provide: 'ep:notes/update', useClass: ep___notes_update.default }; const $notes_delete: Provider = { provide: 'ep:notes/delete', useClass: ep___notes_delete.default }; const $notes_favorites_create: Provider = { provide: 'ep:notes/favorites/create', useClass: ep___notes_favorites_create.default }; const $notes_favorites_delete: Provider = { provide: 'ep:notes/favorites/delete', useClass: ep___notes_favorites_delete.default }; @@ -1048,6 +1050,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ $notes_clips, $notes_conversation, $notes_create, + $notes_update, $notes_delete, $notes_favorites_create, $notes_favorites_delete, @@ -1429,6 +1432,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ $notes_clips, $notes_conversation, $notes_create, + $notes_update, $notes_delete, $notes_favorites_create, $notes_favorites_delete, diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index 3dfb7fdad4c2..c30039dfd694 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -284,6 +284,7 @@ import * as ep___notes_children from './endpoints/notes/children.js'; import * as ep___notes_clips from './endpoints/notes/clips.js'; import * as ep___notes_conversation from './endpoints/notes/conversation.js'; import * as ep___notes_create from './endpoints/notes/create.js'; +import * as ep___notes_update from './endpoints/notes/update.js'; import * as ep___notes_delete from './endpoints/notes/delete.js'; import * as ep___notes_favorites_create from './endpoints/notes/favorites/create.js'; import * as ep___notes_favorites_delete from './endpoints/notes/favorites/delete.js'; @@ -665,6 +666,7 @@ const eps = [ ['notes/clips', ep___notes_clips], ['notes/conversation', ep___notes_conversation], ['notes/create', ep___notes_create], + ['notes/update', ep___notes_update], ['notes/delete', ep___notes_delete], ['notes/favorites/create', ep___notes_favorites_create], ['notes/favorites/delete', ep___notes_favorites_delete], diff --git a/packages/backend/src/server/api/endpoints/endpoint.ts b/packages/backend/src/server/api/endpoints/endpoint.ts index fe7e9c36f3ad..7fc7644ed9e1 100644 --- a/packages/backend/src/server/api/endpoints/endpoint.ts +++ b/packages/backend/src/server/api/endpoints/endpoint.ts @@ -5,7 +5,7 @@ import { Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import endpoints from '../endpoints.js'; +import endpoints, { IEndpoint } from '../endpoints.js'; export const meta = { requireCredential: false, @@ -42,8 +42,8 @@ export const paramDef = { export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( ) { - super(meta, paramDef, async (ps) => { - const ep = endpoints.find(x => x.name === ps.endpoint); + super(meta, paramDef, async (ps: {endpoint?: string}) => { + const ep = endpoints.find((x: IEndpoint) => x.name === ps.endpoint); if (ep == null) return null; return { params: Object.entries(ep.params.properties ?? {}).map(([k, v]) => ({ diff --git a/packages/backend/src/server/api/endpoints/notes/update.ts b/packages/backend/src/server/api/endpoints/notes/update.ts new file mode 100644 index 000000000000..beb34549cbcf --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/update.ts @@ -0,0 +1,168 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import ms from "ms"; +import { Inject, Injectable } from "@nestjs/common"; +import { Endpoint } from "@/server/api/endpoint-base.js"; +import { NoteEntityService } from "@/core/entities/NoteEntityService.js"; +import { NoteUpdateService } from "@/core/NoteUpdateService.js"; +import { DI } from "@/di-symbols.js"; +import { GetterService } from "@/server/api/GetterService.js"; +import { MAX_NOTE_TEXT_LENGTH } from "@/const.js"; +import type { DriveFilesRepository, MiDriveFile } from "@/models/_.js"; +import { ApiError } from "@/server/api/error.js"; + +export const meta = { + tags: ['notes'], + + requireCredential: true, + requireRolePolicy: 'canEditNote', + + kind: 'write:notes', + + limit: { + duration: ms('1hour'), + max: 10, + minInterval: ms('1sec'), + }, + + errors: { + noSuchNote: { + message: 'No such note.', + code: 'NO_SUCH_NOTE', + id: 'a6584e14-6e01-4ad3-b566-851e7bf0d474', + }, + noSuchFile: { + message: 'Some files are not found.', + code: 'NO_SUCH_FILE', + id: 'b6992544-63e7-67f0-fa7f-32444b1b5306', + }, + cannotCreateAlreadyExpiredPoll: { + message: 'Poll is already expired.', + code: 'CANNOT_CREATE_ALREADY_EXPIRED_POLL', + id: '04da457d-b083-4055-9082-955525eda5a5', + }, + updatedNoteGone: { + message: 'Updated note has gone.', + code: 'NOTE_UPDATE_GONE', + id: '6dffaa9b-f578-45ac-9755-9e64290b4528', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + noteId: { type: 'string', format: 'misskey:id' }, + text: { + type: 'string', + minLength: 1, + maxLength: MAX_NOTE_TEXT_LENGTH, + nullable: false, + }, + fileIds: { + type: 'array', + uniqueItems: true, + minItems: 1, + maxItems: 16, + items: { type: 'string', format: 'misskey:id' }, + }, + mediaIds: { + type: 'array', + uniqueItems: true, + minItems: 1, + maxItems: 16, + items: { type: 'string', format: 'misskey:id' }, + }, + poll: { + type: 'object', + nullable: true, + properties: { + choices: { + type: 'array', + uniqueItems: true, + minItems: 2, + maxItems: 10, + items: { type: 'string', minLength: 1, maxLength: 50 }, + }, + multiple: { type: 'boolean' }, + expiresAt: { type: 'integer', nullable: true }, + expiredAfter: { type: 'integer', nullable: true, minimum: 1 }, + }, + required: ['choices'], + }, + cw: { type: 'string', nullable: true, maxLength: 100 }, + disableRightClick: { type: 'boolean', default: false }, + }, + required: ['noteId', 'text', 'cw'], +} as const; + +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, + + private getterService: GetterService, + private noteEntityService: NoteEntityService, + private noteUpdateService: NoteUpdateService, + ) { + super({ + ...meta, + requireRolePolicy: 'canEditNote', + }, paramDef, async(ps, me) => { + const note = await this.getterService.getNote(ps.noteId).catch(err => { + if (err.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') + throw new ApiError(meta.errors.noSuchNote); + throw err; + }); + + if (note.userId !== me.id) + throw new ApiError(meta.errors.noSuchNote); + + let files: MiDriveFile[] = []; + const fileIds = ps.fileIds ?? ps.mediaIds ?? null; + if (fileIds != null) { + files = await this.driveFilesRepository.createQueryBuilder('file') + .where('file.userId = :userId AND file.id IN (:...fileIds)', { + userId: me.id, + fileIds, + }) + .orderBy('array_position(ARRAY[:...fileIds], "id"::text)') + .setParameters({ fileIds }) + .getMany(); + + if (files.length !== fileIds.length) + throw new ApiError(meta.errors.noSuchFile); + } + + if (ps.poll) { + if (typeof ps.poll.expiresAt === 'number') { + if (ps.poll.expiresAt < Date.now()) + throw new ApiError(meta.errors.cannotCreateAlreadyExpiredPoll); + } else if (typeof ps.poll.expiredAfter === 'number') { + ps.poll.expiresAt = Date.now() + ps.poll.expiredAfter; + } + } + + const data = { + text: ps.text, + files: files, + cw: ps.cw, + poll: ps.poll ? { + choices: ps.poll.choices, + multiple: ps.poll.multiple ?? false, + expiresAt: ps.poll.expiresAt ? new Date(ps.poll.expiresAt) : null, + } : undefined, + }; + + const updatedNote = await this.noteUpdateService.update(me, data, note, false); + + return { + updatedNote: await this.noteEntityService.pack(updatedNote, me), + }; + }); + } +} \ No newline at end of file diff --git a/packages/backend/test/unit/NoteCreateService.ts b/packages/backend/test/unit/NoteCreateService.ts index f2d4c8ffbb77..da3de0e8a9ed 100644 --- a/packages/backend/test/unit/NoteCreateService.ts +++ b/packages/backend/test/unit/NoteCreateService.ts @@ -60,6 +60,9 @@ describe('NoteCreateService', () => { replyUserHost: null, renoteUserId: null, renoteUserHost: null, + updatedAt: null, + updatedAtHistory: [], + noteEditHistory: [], }; const poll: IPoll = { diff --git a/packages/backend/test/unit/activitypub.ts b/packages/backend/test/unit/activitypub.ts index 696260810677..ce4ea6e9297a 100644 --- a/packages/backend/test/unit/activitypub.ts +++ b/packages/backend/test/unit/activitypub.ts @@ -422,5 +422,34 @@ describe('ActivityPub', () => { // undefined: 'test test baz', }); }); + + test('cacheRemoteSensitiveFiles=false only affects sensitive files', async () => { + meta = { ...metaInitial, cacheRemoteSensitiveFiles: false }; + + const imageObject: IApDocument = { + type: 'Document', + mediaType: 'image/png', + url: 'http://host1.test/foo.png', + name: '', + }; + const driveFile = await imageService.createImage( + await createRandomRemoteUser(resolver, personService), + imageObject, + ); + assert.ok(driveFile && !driveFile.isLink); + + const sensitiveImageObject: IApDocument = { + type: 'Document', + mediaType: 'image/png', + url: 'http://host1.test/bar.png', + name: '', + sensitive: true, + }; + const sensitiveDriveFile = await imageService.createImage( + await createRandomRemoteUser(resolver, personService), + sensitiveImageObject, + ); + assert.ok(sensitiveDriveFile && sensitiveDriveFile.isLink); + }); }); }); diff --git a/packages/backend/test/unit/misc/is-renote.ts b/packages/backend/test/unit/misc/is-renote.ts index 0b713e8bf6b4..d3c9a5171fa8 100644 --- a/packages/backend/test/unit/misc/is-renote.ts +++ b/packages/backend/test/unit/misc/is-renote.ts @@ -43,6 +43,9 @@ const base: MiNote = { replyUserHost: null, renoteUserId: null, renoteUserHost: null, + updatedAt: null, + updatedAtHistory: [], + noteEditHistory: [], }; describe('misc:is-renote', () => { diff --git a/packages/frontend/src/account.ts b/packages/frontend/src/account.ts index 4172016f8984..d0b59c2ec9d6 100644 --- a/packages/frontend/src/account.ts +++ b/packages/frontend/src/account.ts @@ -301,10 +301,10 @@ export async function openAccountMenu(opts: { children: [{ text: i18n.ts.existingAccount, action: () => { showSigninDialog(); }, - }, { + }, /*{ text: i18n.ts.createAccount, action: () => { createAccount(); }, - }], + }*/], }, { type: 'link' as const, icon: 'ti ti-users', diff --git a/packages/frontend/src/const.ts b/packages/frontend/src/const.ts index e135bc69a0f3..2a4ad0eb87f8 100644 --- a/packages/frontend/src/const.ts +++ b/packages/frontend/src/const.ts @@ -75,6 +75,14 @@ export const ROLE_POLICIES = [ 'gtlAvailable', 'ltlAvailable', 'canPublicNote', + 'canEditNote', + 'canInitiateConversation', + 'canCreateContent', + 'canUpdateContent', + 'canDeleteContent', + 'canPurgeAccount', + 'canUpdateAvatar', + 'canUpdateBanner', 'mentionLimit', 'canInvite', 'inviteLimit', diff --git a/packages/frontend/src/os.ts b/packages/frontend/src/os.ts index f42e2ed3c525..7b970f40ef88 100644 --- a/packages/frontend/src/os.ts +++ b/packages/frontend/src/os.ts @@ -700,7 +700,8 @@ export function post(props: Record = {}): Promise { // Vueが渡されたコンポーネントに内部的に__propsというプロパティを生やす影響で、 // 複数のpost formを開いたときに場合によってはエラーになる // もちろん複数のpost formを開けること自体Misskeyサイドのバグなのだが - const { dispose } = popup(MkPostFormDialog, props, { + let dispose: () => void; + popup(MkPostFormDialog, props, { closed: () => { resolve(); dispose(); diff --git a/packages/frontend/src/scripts/get-note-menu.ts b/packages/frontend/src/scripts/get-note-menu.ts index ebb96d1746bb..1b465db73da2 100644 --- a/packages/frontend/src/scripts/get-note-menu.ts +++ b/packages/frontend/src/scripts/get-note-menu.ts @@ -211,6 +211,18 @@ export function getNoteMenu(props: { }); } + function edit(): void { + os.confirm({ + type: 'warning', + text: i18n.ts.editConfirm, + }).then(({canceled}) => { + if (canceled) return; + os.post({ initialNote: appearNote, renote: appearNote.renote, reply: appearNote.reply, channel: appearNote.channel, editMode: true }) + .then(() => { location.reload(); }); + // 노트 수정 사항이 바로 반영되지 않는 문제 수정을 위해 일단 넣었습니다. 수정이 되면 강제 새로고침합니다. + }); + } + function toggleFavorite(favorite: boolean): void { claimAchievement('noteFavorited1'); os.apiWithDialog(favorite ? 'notes/favorites/create' : 'notes/favorites/delete', { @@ -432,6 +444,11 @@ export function getNoteMenu(props: { text: i18n.ts.deleteAndEdit, action: delEdit, } : undefined, + $i.policies.canEditNote || $i.isModerator || $i.isAdmin ? { + icon: 'ti ti-edit', + text: i18n.ts.edit, + action: edit, + } : undefined, { icon: 'ti ti-trash', text: i18n.ts.delete, diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md index 16cb560a52c0..078ee4e80c60 100644 --- a/packages/misskey-js/etc/misskey-js.api.md +++ b/packages/misskey-js/etc/misskey-js.api.md @@ -1598,6 +1598,7 @@ declare namespace entities { NotesConversationResponse, NotesCreateRequest, NotesCreateResponse, + NotesUpdateRequest, NotesDeleteRequest, NotesFavoritesCreateRequest, NotesFavoritesDeleteRequest, @@ -2663,6 +2664,9 @@ type NotesTranslateResponse = operations['notes___translate']['responses']['200' // @public (undocumented) type NotesUnrenoteRequest = operations['notes___unrenote']['requestBody']['content']['application/json']; +// @public (undocumented) +type NotesUpdateRequest = operations['notes___update']['requestBody']['content']['application/json']; + // @public (undocumented) type NotesUserListTimelineRequest = operations['notes___user-list-timeline']['requestBody']['content']['application/json']; diff --git a/packages/misskey-js/generator/docs/README.ko.md b/packages/misskey-js/generator/docs/README.ko.md new file mode 100644 index 000000000000..3828090818f8 --- /dev/null +++ b/packages/misskey-js/generator/docs/README.ko.md @@ -0,0 +1,17 @@ +# misskey-js용 타입 생성 모듈 +백엔드에서 생성하는 OpenAPI 호환 `api.json`에서 타입 별칭을 생성하는 모듈입니다. +이 모듈이 misskey-js 자체에 포함되어 배포되는 것은 상정하지 않으나, misskey-js의 소스 아래에서 사용하는 것을 의도했습니다. + +## 사용 방법 +먼저 Misskey의 백엔드에서 `api.json` 파일을 가져와야 합니다. +아무 Misskey 서버에서 `/api-doc` 페이지를 열어 다운받거나, +백엔드 모듈에서 `pnpm generate-api-json`을 실행해 얻을 수 있습니다. +> `pnpm generate-api-json`을 실행하기 전에 `default.yml`을 생성해야 합니다. + +`api.json`을 얻었다면, 이 파일이 있는 디렉토리로 가져와, 다음 명령어를 실행합니다. + +```sh +pnpm generate +``` + +이를 실행하면, `./built` 디렉토리 아래에 `.ts` 파일이 생성됩니다. diff --git a/packages/misskey-js/src/autogen/apiClientJSDoc.ts b/packages/misskey-js/src/autogen/apiClientJSDoc.ts index e799d4a0c5e0..fe250e418053 100644 --- a/packages/misskey-js/src/autogen/apiClientJSDoc.ts +++ b/packages/misskey-js/src/autogen/apiClientJSDoc.ts @@ -3040,6 +3040,17 @@ declare module '../api.js' { credential?: string | null, ): Promise>; + /** + * No description provided. + * + * **Credential required**: *Yes* / **Permission**: *write:notes* + */ + request( + endpoint: E, + params: P, + credential?: string | null, + ): Promise>; + /** * No description provided. * diff --git a/packages/misskey-js/src/autogen/endpoint.ts b/packages/misskey-js/src/autogen/endpoint.ts index be41951e4dbe..9d8e686c662f 100644 --- a/packages/misskey-js/src/autogen/endpoint.ts +++ b/packages/misskey-js/src/autogen/endpoint.ts @@ -408,6 +408,7 @@ import type { NotesConversationResponse, NotesCreateRequest, NotesCreateResponse, + NotesUpdateRequest, NotesDeleteRequest, NotesFavoritesCreateRequest, NotesFavoritesDeleteRequest, @@ -846,6 +847,7 @@ export type Endpoints = { 'notes/clips': { req: NotesClipsRequest; res: NotesClipsResponse }; 'notes/conversation': { req: NotesConversationRequest; res: NotesConversationResponse }; 'notes/create': { req: NotesCreateRequest; res: NotesCreateResponse }; + 'notes/update': { req: NotesUpdateRequest; res: EmptyResponse }; 'notes/delete': { req: NotesDeleteRequest; res: EmptyResponse }; 'notes/favorites/create': { req: NotesFavoritesCreateRequest; res: EmptyResponse }; 'notes/favorites/delete': { req: NotesFavoritesDeleteRequest; res: EmptyResponse }; diff --git a/packages/misskey-js/src/autogen/entities.ts b/packages/misskey-js/src/autogen/entities.ts index 357b5e9eaf68..6d745db8fd79 100644 --- a/packages/misskey-js/src/autogen/entities.ts +++ b/packages/misskey-js/src/autogen/entities.ts @@ -411,6 +411,7 @@ export type NotesConversationRequest = operations['notes___conversation']['reque export type NotesConversationResponse = operations['notes___conversation']['responses']['200']['content']['application/json']; export type NotesCreateRequest = operations['notes___create']['requestBody']['content']['application/json']; export type NotesCreateResponse = operations['notes___create']['responses']['200']['content']['application/json']; +export type NotesUpdateRequest = operations['notes___update']['requestBody']['content']['application/json']; export type NotesDeleteRequest = operations['notes___delete']['requestBody']['content']['application/json']; export type NotesFavoritesCreateRequest = operations['notes___favorites___create']['requestBody']['content']['application/json']; export type NotesFavoritesDeleteRequest = operations['notes___favorites___delete']['requestBody']['content']['application/json']; diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index db5efd4a0030..4bde2c144a4f 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -2631,6 +2631,15 @@ export type paths = { */ post: operations['notes___create']; }; + '/notes/update': { + /** + * notes/update + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *write:notes* + */ + post: operations['notes___update']; + }; '/notes/delete': { /** * notes/delete @@ -4036,8 +4045,12 @@ export type components = { /** Format: date-time */ createdAt: string; /** Format: date-time */ + updatedAt?: string; + updatedAtHistory?: string[]; + /** Format: date-time */ deletedAt?: string | null; text: string | null; + noteEditHistory?: (string | null)[]; cw?: string | null; /** Format: id */ userId: string; @@ -4776,6 +4789,14 @@ export type components = { gtlAvailable: boolean; ltlAvailable: boolean; canPublicNote: boolean; + canEditNote: boolean; + canInitiateConversation: boolean; + canCreateContent: boolean; + canUpdateContent: boolean; + canDeleteContent: boolean; + canPurgeAccount: boolean; + canUpdateAvatar: boolean; + canUpdateBanner: boolean; mentionLimit: number; canInvite: boolean; inviteLimit: number; @@ -21286,6 +21307,76 @@ export type operations = { }; }; }; + /** + * notes/update + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *write:notes* + */ + notes___update: { + requestBody: { + content: { + 'application/json': { + /** Format: misskey:id */ + noteId: string; + text: string; + fileIds?: string[]; + mediaIds?: string[]; + poll?: ({ + choices: string[]; + multiple?: boolean; + expiresAt?: number | null; + expiredAfter?: number | null; + }) | null; + cw: string | null; + /** @default false */ + disableRightClick?: boolean; + }; + }; + }; + responses: { + /** @description OK (without any results) */ + 204: { + content: never; + }; + /** @description Client error */ + 400: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Authentication error */ + 401: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Forbidden error */ + 403: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description I'm Ai */ + 418: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description To many requests */ + 429: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Internal server error */ + 500: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + }; + }; /** * notes/delete * @description No description provided.