From e0f89ee7e2be3d78a422c0b732b58895fc775db1 Mon Sep 17 00:00:00 2001 From: anatawa12 Date: Sat, 2 Aug 2025 21:37:16 +0900 Subject: [PATCH 01/11] feat: add deleted_note table --- .../migration/1754137937997-DeletedNote.js | 30 +++++ packages/backend/src/di-symbols.ts | 1 + packages/backend/src/models/DeletedNote.ts | 115 ++++++++++++++++++ .../backend/src/models/RepositoryModule.ts | 9 ++ packages/backend/src/models/_.ts | 3 + packages/backend/src/postgres.ts | 2 + 6 files changed, 160 insertions(+) create mode 100644 packages/backend/migration/1754137937997-DeletedNote.js create mode 100644 packages/backend/src/models/DeletedNote.ts diff --git a/packages/backend/migration/1754137937997-DeletedNote.js b/packages/backend/migration/1754137937997-DeletedNote.js new file mode 100644 index 0000000000..107458c665 --- /dev/null +++ b/packages/backend/migration/1754137937997-DeletedNote.js @@ -0,0 +1,30 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class DeletedNote1754137937997 { + name = 'DeletedNote1754137937997' + + async up(queryRunner) { + await queryRunner.query(`CREATE TABLE "deleted_note" ("id" character varying(32) NOT NULL, "deletedAt" TIMESTAMP WITH TIME ZONE, "replyId" character varying(32), "renoteId" character varying(32), "userId" character varying(32) NOT NULL, "localOnly" boolean NOT NULL DEFAULT false, "uri" character varying(512), "url" character varying(512), "channelId" character varying(32), CONSTRAINT "PK_1cb67148b7b707a03c63b2165fc" PRIMARY KEY ("id")); COMMENT ON COLUMN "deleted_note"."replyId" IS 'The ID of reply target.'; COMMENT ON COLUMN "deleted_note"."renoteId" IS 'The ID of renote target.'; COMMENT ON COLUMN "deleted_note"."userId" IS 'The ID of author.'; COMMENT ON COLUMN "deleted_note"."uri" IS 'The URI of a note. it will be null when the note is local.'; COMMENT ON COLUMN "deleted_note"."url" IS 'The human readable url of a note. it will be null when the note is local.'; COMMENT ON COLUMN "deleted_note"."channelId" IS 'The ID of source channel.'`); + await queryRunner.query(`CREATE INDEX "IDX_12797cfa4c15d03d0dd649bc4b" ON "deleted_note" ("replyId") `); + await queryRunner.query(`CREATE INDEX "IDX_b6a4a8f31a98ddc5e07995c840" ON "deleted_note" ("renoteId") `); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_95c76b088692f600b7a5352a4b" ON "deleted_note" ("uri") `); + await queryRunner.query(`CREATE INDEX "IDX_cd2da11aa690f8e854e68058ce" ON "deleted_note" ("channelId") `); + await queryRunner.query(`CREATE INDEX "IDX_31ca16e5929958668bf1d9a2d5" ON "deleted_note" ("userId", "id" DESC) `); + await queryRunner.query(`ALTER TABLE "deleted_note" ADD CONSTRAINT "FK_d3b9dbab99de8644e4b0d5b7d59" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "deleted_note" ADD CONSTRAINT "FK_cd2da11aa690f8e854e68058cef" FOREIGN KEY ("channelId") REFERENCES "channel"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "deleted_note" DROP CONSTRAINT "FK_cd2da11aa690f8e854e68058cef"`); + await queryRunner.query(`ALTER TABLE "deleted_note" DROP CONSTRAINT "FK_d3b9dbab99de8644e4b0d5b7d59"`); + await queryRunner.query(`DROP INDEX "public"."IDX_31ca16e5929958668bf1d9a2d5"`); + await queryRunner.query(`DROP INDEX "public"."IDX_cd2da11aa690f8e854e68058ce"`); + await queryRunner.query(`DROP INDEX "public"."IDX_95c76b088692f600b7a5352a4b"`); + await queryRunner.query(`DROP INDEX "public"."IDX_b6a4a8f31a98ddc5e07995c840"`); + await queryRunner.query(`DROP INDEX "public"."IDX_12797cfa4c15d03d0dd649bc4b"`); + await queryRunner.query(`DROP TABLE "deleted_note"`); + } +} diff --git a/packages/backend/src/di-symbols.ts b/packages/backend/src/di-symbols.ts index c915133453..579f996feb 100644 --- a/packages/backend/src/di-symbols.ts +++ b/packages/backend/src/di-symbols.ts @@ -90,5 +90,6 @@ export const DI = { bubbleGameRecordsRepository: Symbol('bubbleGameRecordsRepository'), reversiGamesRepository: Symbol('reversiGamesRepository'), noteDraftsRepository: Symbol('noteDraftsRepository'), + deletedNotesRepository: Symbol('deletedNotesRepository'), //#endregion }; diff --git a/packages/backend/src/models/DeletedNote.ts b/packages/backend/src/models/DeletedNote.ts new file mode 100644 index 0000000000..fad5ef783d --- /dev/null +++ b/packages/backend/src/models/DeletedNote.ts @@ -0,0 +1,115 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Entity, Index, JoinColumn, Column, PrimaryColumn, ManyToOne } from 'typeorm'; +import { MiChannel } from '@/models/Channel.js'; +import { id } from './util/id.js'; +import { MiUser } from './User.js'; +import { MiNote } from './Note.js'; + +/** + * This model represents a deleted note in the Misskey system. + * Deleted note can be one of: + * - A note that was deleted by the user. + * - A remote note that is old and has been removed from the + * In both cases, we want to keep little information about the note to allow users to + * - see the reply / renote relationships of the note (if the note is in the reply / renote chain) + * - see the original url of the note if the note was remote + */ + +@Index(['userId', 'id']) // Note: this index is ("userId", "id" DESC) in production, but not in test. +@Entity('deleted_note') +export class MiDeletedNote { + // The id of the note must be same as the original note's id. + @PrimaryColumn(id()) + public id: string; + + // For remote notes that are deleted locally but not deleted on the remote server, this will be null. + // For actually deleted notes, this will be the time when the note was deleted. + @Column('timestamp with time zone', { + nullable: true, + }) + public deletedAt: Date | null; + + @Index() + @Column({ + ...id(), + nullable: true, + comment: 'The ID of reply target.', + }) + public replyId: MiNote['id'] | null; + + @ManyToOne(type => MiNote, { + createForeignKeyConstraints: false, + }) + @JoinColumn() + public reply: MiNote | null; + + @Index() + @Column({ + ...id(), + nullable: true, + comment: 'The ID of renote target.', + }) + public renoteId: MiNote['id'] | null; + + @ManyToOne(type => MiNote, { + createForeignKeyConstraints: false, + }) + @JoinColumn() + public renote: MiNote | null; + + @Column({ + ...id(), + comment: 'The ID of author.', + }) + public userId: MiUser['id']; + + @ManyToOne(type => MiUser, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public user: MiUser | null; + + @Column('boolean', { + default: false, + }) + public localOnly: boolean; + + @Index({ unique: true }) + @Column('varchar', { + length: 512, nullable: true, + comment: 'The URI of a note. it will be null when the note is local.', + }) + public uri: string | null; + + @Column('varchar', { + length: 512, nullable: true, + comment: 'The human readable url of a note. it will be null when the note is local.', + }) + public url: string | null; + + @Index() // for cascading + @Column({ + ...id(), + nullable: true, + comment: 'The ID of source channel.', + }) + public channelId: MiChannel['id'] | null; + + @ManyToOne(type => MiChannel, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public channel: MiChannel | null; + + constructor(data: Partial) { + if (data == null) return; + + for (const [k, v] of Object.entries(data)) { + (this as any)[k] = v; + } + } +} diff --git a/packages/backend/src/models/RepositoryModule.ts b/packages/backend/src/models/RepositoryModule.ts index 146dbbc3b8..5acf334ac4 100644 --- a/packages/backend/src/models/RepositoryModule.ts +++ b/packages/backend/src/models/RepositoryModule.ts @@ -43,6 +43,7 @@ import { MiNoteReaction, MiNoteThreadMuting, MiNoteDraft, + MiDeletedNote, MiPage, MiPageLike, MiPasswordResetRequest, @@ -147,6 +148,12 @@ const $noteDraftsRepository: Provider = { inject: [DI.db], }; +const $deletedNotesRepository: Provider = { + provide: DI.deletedNotesRepository, + useFactory: (db: DataSource) => db.getRepository(MiDeletedNote).extend(miRepository as MiRepository), + inject: [DI.db], +}; + const $pollsRepository: Provider = { provide: DI.pollsRepository, useFactory: (db: DataSource) => db.getRepository(MiPoll).extend(miRepository as MiRepository), @@ -550,6 +557,7 @@ const $reversiGamesRepository: Provider = { $noteThreadMutingsRepository, $noteReactionsRepository, $noteDraftsRepository, + $deletedNotesRepository, $pollsRepository, $pollVotesRepository, $userProfilesRepository, @@ -627,6 +635,7 @@ const $reversiGamesRepository: Provider = { $noteThreadMutingsRepository, $noteReactionsRepository, $noteDraftsRepository, + $deletedNotesRepository, $pollsRepository, $pollVotesRepository, $userProfilesRepository, diff --git a/packages/backend/src/models/_.ts b/packages/backend/src/models/_.ts index 84b5cbed0a..ee54f389e9 100644 --- a/packages/backend/src/models/_.ts +++ b/packages/backend/src/models/_.ts @@ -40,6 +40,7 @@ import { MiChatRoomMembership } from '@/models/ChatRoomMembership.js'; import { MiClip } from '@/models/Clip.js'; import { MiClipFavorite } from '@/models/ClipFavorite.js'; import { MiClipNote } from '@/models/ClipNote.js'; +import { MiDeletedNote } from '@/models/DeletedNote.js'; import { MiDriveFile } from '@/models/DriveFile.js'; import { MiDriveFolder } from '@/models/DriveFolder.js'; import { MiEmoji } from '@/models/Emoji.js'; @@ -175,6 +176,7 @@ export { MiClip, MiClipNote, MiClipFavorite, + MiDeletedNote, MiDriveFile, MiDriveFolder, MiEmoji, @@ -254,6 +256,7 @@ export type ChannelFavoritesRepository = Repository & MiRepos export type ClipsRepository = Repository & MiRepository; export type ClipNotesRepository = Repository & MiRepository; export type ClipFavoritesRepository = Repository & MiRepository; +export type DeletedNotesRepository = Repository & MiRepository; export type DriveFilesRepository = Repository & MiRepository; export type DriveFoldersRepository = Repository & MiRepository; export type EmojisRepository = Repository & MiRepository; diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts index f6cbbbe64c..a7e4f645bd 100644 --- a/packages/backend/src/postgres.ts +++ b/packages/backend/src/postgres.ts @@ -46,6 +46,7 @@ import { MiNoteFavorite } from '@/models/NoteFavorite.js'; import { MiNoteReaction } from '@/models/NoteReaction.js'; import { MiNoteThreadMuting } from '@/models/NoteThreadMuting.js'; import { MiNoteDraft } from '@/models/NoteDraft.js'; +import { MiDeletedNote } from '@/models/DeletedNote.js'; import { MiPage } from '@/models/Page.js'; import { MiPageLike } from '@/models/PageLike.js'; import { MiPasswordResetRequest } from '@/models/PasswordResetRequest.js'; @@ -212,6 +213,7 @@ export const entities = [ MiNoteReaction, MiNoteThreadMuting, MiNoteDraft, + MiDeletedNote, MiPage, MiPageLike, MiGalleryPost, From 0544f3fc3d5667b31e44d25bb0fab60d9d842bda Mon Sep 17 00:00:00 2001 From: anatawa12 Date: Sat, 2 Aug 2025 21:59:39 +0900 Subject: [PATCH 02/11] feat: add to 'deleted_note' table on remove --- .../backend/src/core/NoteDeleteService.ts | 27 +++++++++++--- .../CleanRemoteNotesProcessorService.ts | 37 ++++++++++++++++--- 2 files changed, 54 insertions(+), 10 deletions(-) diff --git a/packages/backend/src/core/NoteDeleteService.ts b/packages/backend/src/core/NoteDeleteService.ts index af1f0eda9a..a56614d3f2 100644 --- a/packages/backend/src/core/NoteDeleteService.ts +++ b/packages/backend/src/core/NoteDeleteService.ts @@ -3,10 +3,12 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Brackets, In, IsNull, Not } from 'typeorm'; +import { Brackets, DataSource, In, IsNull, Not } from 'typeorm'; import { Injectable, Inject } from '@nestjs/common'; import type { MiUser, MiLocalUser, MiRemoteUser } from '@/models/User.js'; -import type { MiNote, IMentionedRemoteUsers } from '@/models/Note.js'; +import { MiNote } from '@/models/Note.js'; +import { MiDeletedNote } from '@/models/DeletedNote.js'; +import type { IMentionedRemoteUsers } from '@/models/Note.js'; import type { InstancesRepository, MiMeta, NotesRepository, UsersRepository } from '@/models/_.js'; import { RelayService } from '@/core/RelayService.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; @@ -30,6 +32,9 @@ export class NoteDeleteService { @Inject(DI.config) private config: Config, + @Inject(DI.db) + private db: DataSource, + @Inject(DI.meta) private meta: MiMeta, @@ -110,9 +115,21 @@ export class NoteDeleteService { this.searchService.unindexNote(note); - await this.notesRepository.delete({ - id: note.id, - userId: user.id, + await this.db.transaction(async transaction => { + await transaction.delete(MiNote, { + id: note.id, + userId: user.id, + }); + await transaction.save(MiDeletedNote, { + id: note.id, + deletedAt: new Date(), + replyId: note.replyId, + renoteId: note.renoteId, + userId: note.userId, + localOnly: note.localOnly, + uri: note.uri, + url: note.url, + }); }); if (deleter && (note.userId !== deleter.id)) { diff --git a/packages/backend/src/queue/processors/CleanRemoteNotesProcessorService.ts b/packages/backend/src/queue/processors/CleanRemoteNotesProcessorService.ts index 5b682e20b8..122aee3af5 100644 --- a/packages/backend/src/queue/processors/CleanRemoteNotesProcessorService.ts +++ b/packages/backend/src/queue/processors/CleanRemoteNotesProcessorService.ts @@ -5,9 +5,11 @@ import { setTimeout } from 'node:timers/promises'; import { Inject, Injectable } from '@nestjs/common'; -import { And, In, IsNull, LessThan, MoreThan, Not } from 'typeorm'; +import { DataSource, In, IsNull, LessThan, Not } from 'typeorm'; import { DI } from '@/di-symbols.js'; -import type { MiMeta, MiNote, NoteFavoritesRepository, NotesRepository, UserNotePiningsRepository } from '@/models/_.js'; +import { MiNote } from '@/models/Note.js'; +import { MiDeletedNote } from '@/models/DeletedNote.js'; +import type { MiMeta, NoteFavoritesRepository, NotesRepository, UserNotePiningsRepository } from '@/models/_.js'; import type Logger from '@/logger.js'; import { bindThis } from '@/decorators.js'; import { IdService } from '@/core/IdService.js'; @@ -22,6 +24,9 @@ export class CleanRemoteNotesProcessorService { @Inject(DI.meta) private meta: MiMeta, + @Inject(DI.db) + private db: DataSource, + @Inject(DI.notesRepository) private notesRepository: NotesRepository, @@ -72,7 +77,17 @@ export class CleanRemoteNotesProcessorService { while (true) { const batchBeginAt = Date.now(); - let notes: Pick[] = await this.notesRepository.find({ + const selectColumns = [...[ + 'id', + 'replyId', + 'renoteId', + 'userId', + 'localOnly', + 'uri', + 'url', + 'channelId', + ] as const]; + let notes: Pick[] = await this.notesRepository.find({ where: { id: LessThan(cursor), userHost: Not(IsNull()), @@ -85,7 +100,7 @@ export class CleanRemoteNotesProcessorService { // https://github.com/misskey-dev/misskey/pull/16292#issuecomment-3139376314 id: -1, }, - select: ['id'], + select: selectColumns, }); const fetchedCount = notes.length; @@ -131,7 +146,19 @@ export class CleanRemoteNotesProcessorService { }); if (notes.length > 0) { - await this.notesRepository.delete(notes.map(note => note.id)); + await this.db.transaction(async (transaction) => { + await transaction.save(MiDeletedNote, notes.map(note => ({ + id: note.id, + deletedAt: null, // This is existing note on the remote, so we set deletedAt to null. + replyId: note.replyId, + renoteId: note.renoteId, + userId: note.userId, + localOnly: note.localOnly, + uri: note.uri, + url: note.url, + }))); + await transaction.delete(MiNote, notes.map(note => note.id)); + }); for (const note of notes) { const t = this.idService.parse(note.id).date.getTime(); From da43b554f2e033423b0075b38856109c0e955049 Mon Sep 17 00:00:00 2001 From: anatawa12 Date: Sat, 2 Aug 2025 22:03:00 +0900 Subject: [PATCH 03/11] feat: pack and response deleted note as deleted note --- .../backend/src/core/NoteDeleteService.ts | 1 + .../src/core/entities/NoteEntityService.ts | 121 +++++++++++++++++- .../backend/src/server/api/GetterService.ts | 27 +++- .../src/server/api/endpoints/notes/show.ts | 24 +++- 4 files changed, 163 insertions(+), 10 deletions(-) diff --git a/packages/backend/src/core/NoteDeleteService.ts b/packages/backend/src/core/NoteDeleteService.ts index a56614d3f2..7003f171e5 100644 --- a/packages/backend/src/core/NoteDeleteService.ts +++ b/packages/backend/src/core/NoteDeleteService.ts @@ -129,6 +129,7 @@ export class NoteDeleteService { localOnly: note.localOnly, uri: note.uri, url: note.url, + channelId: note.channelId, }); }); diff --git a/packages/backend/src/core/entities/NoteEntityService.ts b/packages/backend/src/core/entities/NoteEntityService.ts index 6871ba2c72..335bc165e0 100644 --- a/packages/backend/src/core/entities/NoteEntityService.ts +++ b/packages/backend/src/core/entities/NoteEntityService.ts @@ -11,7 +11,7 @@ import type { Packed } from '@/misc/json-schema.js'; import { awaitAll } from '@/misc/prelude/await-all.js'; import type { MiUser } from '@/models/User.js'; import type { MiNote } from '@/models/Note.js'; -import type { UsersRepository, NotesRepository, FollowingsRepository, PollsRepository, PollVotesRepository, NoteReactionsRepository, ChannelsRepository, MiMeta } from '@/models/_.js'; +import type { UsersRepository, NotesRepository, FollowingsRepository, PollsRepository, PollVotesRepository, NoteReactionsRepository, ChannelsRepository, MiMeta, DeletedNotesRepository, MiDeletedNote } from '@/models/_.js'; import { bindThis } from '@/decorators.js'; import { DebounceLoader } from '@/misc/loader.js'; import { IdService } from '@/core/IdService.js'; @@ -79,6 +79,9 @@ export class NoteEntityService implements OnModuleInit { @Inject(DI.notesRepository) private notesRepository: NotesRepository, + @Inject(DI.deletedNotesRepository) + private deletedNotesRepository: DeletedNotesRepository, + @Inject(DI.followingsRepository) private followingsRepository: FollowingsRepository, @@ -377,7 +380,19 @@ export class NoteEntityService implements OnModuleInit { }, options); const meId = me ? me.id : null; - const note = typeof src === 'object' ? src : await this.noteLoader.load(src); + let note: MiNote; + if (typeof src === 'object') { + note = src; + } else { + try { + note = await this.noteLoader.load(src); + } catch (err) { + if (err instanceof EntityNotFoundError) { + return this.packDeletedNote(src); + } + throw err; + } + } const host = note.userHost; const bufferedReactions = opts._hint_?.bufferedReactions != null @@ -448,20 +463,20 @@ export class NoteEntityService implements OnModuleInit { clippedCount: note.clippedCount, // そもそもJOINしていない場合はundefined、JOINしたけど存在していなかった場合はnullで区別される - reply: (note.replyId && note.reply === null) ? null : note.replyId ? nullIfEntityNotFound(this.pack(note.reply ?? note.replyId, me, { + reply: note.replyId ? this.pack(note.reply ?? note.replyId, me, { detail: false, skipHide: opts.skipHide, withReactionAndUserPairCache: opts.withReactionAndUserPairCache, _hint_: options?._hint_, - })) : undefined, + }) : undefined, // そもそもJOINしていない場合はundefined、JOINしたけど存在していなかった場合はnullで区別される - renote: (note.renoteId && note.renote === null) ? null : note.renoteId ? nullIfEntityNotFound(this.pack(note.renote ?? note.renoteId, me, { + renote: note.renoteId ? this.pack(note.renote ?? note.renoteId, me, { detail: true, skipHide: opts.skipHide, withReactionAndUserPairCache: opts.withReactionAndUserPairCache, _hint_: options?._hint_, - })) : undefined, + }) : undefined, poll: note.hasPoll ? this.populatePoll(note, meId) : undefined, @@ -484,6 +499,100 @@ export class NoteEntityService implements OnModuleInit { return packed; } + public async packDeletedNote( + srcId: MiNote['id'] | MiDeletedNote, + me?: { id: MiUser['id'] } | null | undefined, + options?: { + detail?: boolean; + skipHide?: boolean; + withReactionAndUserPairCache?: boolean; + _hint_?: { + bufferedReactions: Map; pairs: ([MiUser['id'], string])[] }> | null; + myReactions: Map; + packedFiles: Map | null>; + packedUsers: Map> + }; + }, + ): Promise> { + const opts = Object.assign({ + detail: true, + skipHide: false, + withReactionAndUserPairCache: false, + }, options); + + const deletedNote = typeof srcId === 'object' ? srcId : await this.deletedNotesRepository.findOneOrFail({ + where: { + id: srcId, + }, + relations: ['user', 'renote', 'reply', 'channel'], + }); + + const packedUsers = options?._hint_?.packedUsers; + + const channel = deletedNote.channelId + ? deletedNote.channel ?? await this.channelsRepository.findOneBy({ id: deletedNote.channelId }) + : null; + + return await awaitAll({ + id: deletedNote.id, + createdAt: this.idService.parse(deletedNote.id).date.toISOString(), + deletedAt: deletedNote.deletedAt?.toISOString() ?? undefined, + userId: deletedNote.userId, + user: packedUsers?.get(deletedNote.userId) ?? this.userEntityService.pack(deletedNote.user ?? deletedNote.userId, me), + text: deletedNote.deletedAt ? "Deleted note" : "Forgotten remote note. View on remote instance to see contents.", + cw: null, + visibility: 'public', + localOnly: deletedNote.localOnly, + reactionAcceptance: 'likeOnly', + visibleUserIds: undefined, + renoteCount: 0, + repliesCount: 0, + reactionCount: 0, + reactions: {}, + reactionEmojis: {}, + reactionAndUserPairCache: [], + emojis: {}, + tags: undefined, + fileIds: [], + files: [], + replyId: deletedNote.replyId, + renoteId: deletedNote.renoteId, + channelId: deletedNote.channelId ?? undefined, + channel: channel ? { + id: channel.id, + name: channel.name, + color: channel.color, + isSensitive: channel.isSensitive, + allowRenoteToExternal: channel.allowRenoteToExternal, + userId: channel.userId, + } : undefined, + mentions: undefined, + hasPoll: undefined, + uri: deletedNote.uri ?? undefined, + url: deletedNote.url ?? undefined, + + ...(opts.detail ? { + clippedCount: 0, + + reply: deletedNote.replyId ? this.pack(deletedNote.reply ?? deletedNote.replyId, me, { + detail: false, + skipHide: opts.skipHide, + withReactionAndUserPairCache: opts.withReactionAndUserPairCache, + _hint_: options?._hint_, + }) : undefined, + + renote: deletedNote.renoteId ? this.pack(deletedNote.renote ?? deletedNote.renoteId, me, { + detail: true, + skipHide: opts.skipHide, + withReactionAndUserPairCache: opts.withReactionAndUserPairCache, + _hint_: options?._hint_, + }) : undefined, + + poll: undefined, + } : {}), + }); + } + @bindThis public async packMany( notes: MiNote[], diff --git a/packages/backend/src/server/api/GetterService.ts b/packages/backend/src/server/api/GetterService.ts index 8f4213dfb6..439396a62f 100644 --- a/packages/backend/src/server/api/GetterService.ts +++ b/packages/backend/src/server/api/GetterService.ts @@ -5,7 +5,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; -import type { NotesRepository, UsersRepository } from '@/models/_.js'; +import type { DeletedNotesRepository, NotesRepository, UsersRepository } from '@/models/_.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; import type { MiLocalUser, MiRemoteUser, MiUser } from '@/models/User.js'; import type { MiNote } from '@/models/Note.js'; @@ -21,6 +21,9 @@ export class GetterService { @Inject(DI.notesRepository) private notesRepository: NotesRepository, + @Inject(DI.deletedNotesRepository) + private deletedNotesRepository: DeletedNotesRepository, + private userEntityService: UserEntityService, ) { } @@ -39,6 +42,17 @@ export class GetterService { return note; } + @bindThis + public async getDeletedNote(noteId: MiNote['id']) { + const note = await this.deletedNotesRepository.findOneBy({ id: noteId }); + + if (note == null) { + throw new IdentifiableError('f2d7e5b8-9d79-4996-b996-89b538a1b71f', 'No such deleted note.'); + } + + return note; + } + @bindThis public async getNoteWithRelations(noteId: MiNote['id']) { const note = await this.notesRepository.findOne({ where: { id: noteId }, relations: ['user', 'reply', 'renote', 'reply.user', 'renote.user'] }); @@ -50,6 +64,17 @@ export class GetterService { return note; } + @bindThis + public async getDeletedNoteWithRelations(noteId: MiNote['id']) { + const note = await this.deletedNotesRepository.findOne({ where: { id: noteId }, relations: ['user', 'reply', 'renote', 'reply.user', 'renote.user'] }); + + if (note == null) { + throw new IdentifiableError('f2d7e5b8-9d79-4996-b996-89b538a1b71f', 'No such deleted note.'); + } + + return note; + } + /** * Get user for API processing */ diff --git a/packages/backend/src/server/api/endpoints/notes/show.ts b/packages/backend/src/server/api/endpoints/notes/show.ts index cae0e752da..d8596fe120 100644 --- a/packages/backend/src/server/api/endpoints/notes/show.ts +++ b/packages/backend/src/server/api/endpoints/notes/show.ts @@ -9,6 +9,8 @@ import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { GetterService } from '@/server/api/GetterService.js'; import { DI } from '@/di-symbols.js'; import { MiMeta } from '@/models/Meta.js'; +import { MiNote } from '@/models/Note.js'; +import { IdentifiableError } from '@/misc/identifiable-error.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -55,10 +57,26 @@ export default class extends Endpoint { // eslint- private getterService: GetterService, ) { super(meta, paramDef, async (ps, me) => { - const note = await this.getterService.getNoteWithRelations(ps.noteId).catch(err => { - if (err.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); + let note: MiNote | void; + try { + note = await this.getterService.getNoteWithRelations(ps.noteId); + } catch (err) { + if (err instanceof IdentifiableError && err.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') { + try { + const deletedNote = await this.getterService.getDeletedNoteWithRelations(ps.noteId); + + return await this.noteEntityService.packDeletedNote(deletedNote, me, { + detail: true, + }); + } catch (err) { + if (err instanceof IdentifiableError && err.id === 'f2d7e5b8-9d79-4996-b996-89b538a1b71f') { + throw new ApiError(meta.errors.noSuchNote); + } + throw err; + } + } throw err; - }); + } if (note.user!.requireSigninToViewContents && me == null) { throw new ApiError(meta.errors.signinRequired); From 14515994fd07d3a5feed61dca7dd7e60a9063f06 Mon Sep 17 00:00:00 2001 From: anatawa12 Date: Sat, 2 Aug 2025 22:21:11 +0900 Subject: [PATCH 04/11] chore: display deleted note with relationship information as "deleted Note" in frontend --- packages/frontend/src/components/MkNote.vue | 3 ++- packages/frontend/src/components/MkNoteDetailed.vue | 3 ++- packages/frontend/src/components/MkSubNoteContent.vue | 4 ++-- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue index b9cb37e99a..741954e57a 100644 --- a/packages/frontend/src/components/MkNote.vue +++ b/packages/frontend/src/components/MkNote.vue @@ -64,8 +64,9 @@ SPDX-License-Identifier: AGPL-3.0-only
({{ i18n.ts.private }}) + ({{ i18n.ts.deletedNote }}) ({{ i18n.ts.private }}) + ({{ i18n.ts.deletedNote }})
({{ i18n.ts.private }}) - ({{ i18n.ts.deletedNote }}) - + ({{ i18n.ts.deletedNote }}) + RN: ...
From 82e8c5ff258092f9014200b1fe61b9c2f4e04438 Mon Sep 17 00:00:00 2001 From: anatawa12 Date: Sat, 2 Aug 2025 22:47:07 +0900 Subject: [PATCH 05/11] chore: make DeletedNote hold replyUserId and renoteUserId --- .../backend/migration/1754137937997-DeletedNote.js | 2 +- packages/backend/src/core/NoteDeleteService.ts | 2 ++ packages/backend/src/models/DeletedNote.ts | 14 ++++++++++++++ .../processors/CleanRemoteNotesProcessorService.ts | 4 ++++ 4 files changed, 21 insertions(+), 1 deletion(-) diff --git a/packages/backend/migration/1754137937997-DeletedNote.js b/packages/backend/migration/1754137937997-DeletedNote.js index 107458c665..9d8e486e1b 100644 --- a/packages/backend/migration/1754137937997-DeletedNote.js +++ b/packages/backend/migration/1754137937997-DeletedNote.js @@ -7,7 +7,7 @@ export class DeletedNote1754137937997 { name = 'DeletedNote1754137937997' async up(queryRunner) { - await queryRunner.query(`CREATE TABLE "deleted_note" ("id" character varying(32) NOT NULL, "deletedAt" TIMESTAMP WITH TIME ZONE, "replyId" character varying(32), "renoteId" character varying(32), "userId" character varying(32) NOT NULL, "localOnly" boolean NOT NULL DEFAULT false, "uri" character varying(512), "url" character varying(512), "channelId" character varying(32), CONSTRAINT "PK_1cb67148b7b707a03c63b2165fc" PRIMARY KEY ("id")); COMMENT ON COLUMN "deleted_note"."replyId" IS 'The ID of reply target.'; COMMENT ON COLUMN "deleted_note"."renoteId" IS 'The ID of renote target.'; COMMENT ON COLUMN "deleted_note"."userId" IS 'The ID of author.'; COMMENT ON COLUMN "deleted_note"."uri" IS 'The URI of a note. it will be null when the note is local.'; COMMENT ON COLUMN "deleted_note"."url" IS 'The human readable url of a note. it will be null when the note is local.'; COMMENT ON COLUMN "deleted_note"."channelId" IS 'The ID of source channel.'`); + await queryRunner.query(`CREATE TABLE "deleted_note" ("id" character varying(32) NOT NULL, "deletedAt" TIMESTAMP WITH TIME ZONE, "replyId" character varying(32), "renoteId" character varying(32), "userId" character varying(32) NOT NULL, "localOnly" boolean NOT NULL DEFAULT false, "uri" character varying(512), "url" character varying(512), "channelId" character varying(32), "replyUserId" character varying(32), "renoteUserId" character varying(32), CONSTRAINT "PK_1cb67148b7b707a03c63b2165fc" PRIMARY KEY ("id")); COMMENT ON COLUMN "deleted_note"."replyId" IS 'The ID of reply target.'; COMMENT ON COLUMN "deleted_note"."renoteId" IS 'The ID of renote target.'; COMMENT ON COLUMN "deleted_note"."userId" IS 'The ID of author.'; COMMENT ON COLUMN "deleted_note"."uri" IS 'The URI of a note. it will be null when the note is local.'; COMMENT ON COLUMN "deleted_note"."url" IS 'The human readable url of a note. it will be null when the note is local.'; COMMENT ON COLUMN "deleted_note"."channelId" IS 'The ID of source channel.'; COMMENT ON COLUMN "deleted_note"."replyUserId" IS '[Denormalized]'; COMMENT ON COLUMN "deleted_note"."renoteUserId" IS '[Denormalized]'`); await queryRunner.query(`CREATE INDEX "IDX_12797cfa4c15d03d0dd649bc4b" ON "deleted_note" ("replyId") `); await queryRunner.query(`CREATE INDEX "IDX_b6a4a8f31a98ddc5e07995c840" ON "deleted_note" ("renoteId") `); await queryRunner.query(`CREATE UNIQUE INDEX "IDX_95c76b088692f600b7a5352a4b" ON "deleted_note" ("uri") `); diff --git a/packages/backend/src/core/NoteDeleteService.ts b/packages/backend/src/core/NoteDeleteService.ts index 7003f171e5..b213a3c4e1 100644 --- a/packages/backend/src/core/NoteDeleteService.ts +++ b/packages/backend/src/core/NoteDeleteService.ts @@ -130,6 +130,8 @@ export class NoteDeleteService { uri: note.uri, url: note.url, channelId: note.channelId, + replyUserId: note.replyUserId, + renoteUserId: note.renoteUserId, }); }); diff --git a/packages/backend/src/models/DeletedNote.ts b/packages/backend/src/models/DeletedNote.ts index fad5ef783d..e281741ead 100644 --- a/packages/backend/src/models/DeletedNote.ts +++ b/packages/backend/src/models/DeletedNote.ts @@ -99,6 +99,20 @@ export class MiDeletedNote { }) public channelId: MiChannel['id'] | null; + @Column({ + ...id(), + nullable: true, + comment: '[Denormalized]', + }) + public replyUserId: MiUser['id'] | null; + + @Column({ + ...id(), + nullable: true, + comment: '[Denormalized]', + }) + public renoteUserId: MiUser['id'] | null; + @ManyToOne(type => MiChannel, { onDelete: 'CASCADE', }) diff --git a/packages/backend/src/queue/processors/CleanRemoteNotesProcessorService.ts b/packages/backend/src/queue/processors/CleanRemoteNotesProcessorService.ts index 122aee3af5..9436b1a1ca 100644 --- a/packages/backend/src/queue/processors/CleanRemoteNotesProcessorService.ts +++ b/packages/backend/src/queue/processors/CleanRemoteNotesProcessorService.ts @@ -86,6 +86,8 @@ export class CleanRemoteNotesProcessorService { 'uri', 'url', 'channelId', + 'replyUserId', + 'renoteUserId', ] as const]; let notes: Pick[] = await this.notesRepository.find({ where: { @@ -156,6 +158,8 @@ export class CleanRemoteNotesProcessorService { localOnly: note.localOnly, uri: note.uri, url: note.url, + replyUserId: note.replyUserId, + renoteUserId: note.renoteUserId, }))); await transaction.delete(MiNote, notes.map(note => note.id)); }); From 912cc0a86b6cfaa00dda099285dc0e4758d2acb3 Mon Sep 17 00:00:00 2001 From: anatawa12 Date: Sat, 2 Aug 2025 22:58:45 +0900 Subject: [PATCH 06/11] chore: allow resolving conversation relates to deleted note --- .../src/core/entities/NoteEntityService.ts | 65 ++++++++++--------- .../backend/src/server/api/GetterService.ts | 23 +++---- .../api/endpoints/notes/conversation.ts | 16 ++--- .../src/server/api/endpoints/notes/show.ts | 28 ++------ 4 files changed, 59 insertions(+), 73 deletions(-) diff --git a/packages/backend/src/core/entities/NoteEntityService.ts b/packages/backend/src/core/entities/NoteEntityService.ts index 335bc165e0..834e22c6a2 100644 --- a/packages/backend/src/core/entities/NoteEntityService.ts +++ b/packages/backend/src/core/entities/NoteEntityService.ts @@ -57,6 +57,18 @@ async function nullIfEntityNotFound(promise: Promise): Promise { } } +type PackSingleOptions = { + detail?: boolean; + skipHide?: boolean; + withReactionAndUserPairCache?: boolean; + _hint_?: { + bufferedReactions: Map; pairs: ([MiUser['id'], string])[] }> | null; + myReactions: Map; + packedFiles: Map | null>; + packedUsers: Map> + }; +}; + @Injectable() export class NoteEntityService implements OnModuleInit { private userEntityService: UserEntityService; @@ -357,21 +369,24 @@ export class NoteEntityService implements OnModuleInit { return fileIds.map(id => packedFiles.get(id)).filter(x => x != null); } + @bindThis + public async packMayDeleted( + src: MiNote | MiDeletedNote, + me?: { id: MiUser['id'] } | null | undefined, + options?: PackSingleOptions, + ): Promise> { + if ('deletedAt' in src) { + return this.packDeletedNote(src, me, options); + } else { + return this.pack(src, me, options); + } + } + @bindThis public async pack( src: MiNote['id'] | MiNote, me?: { id: MiUser['id'] } | null | undefined, - options?: { - detail?: boolean; - skipHide?: boolean; - withReactionAndUserPairCache?: boolean; - _hint_?: { - bufferedReactions: Map; pairs: ([MiUser['id'], string])[] }> | null; - myReactions: Map; - packedFiles: Map | null>; - packedUsers: Map> - }; - }, + options?: PackSingleOptions, ): Promise> { const opts = Object.assign({ detail: true, @@ -462,7 +477,6 @@ export class NoteEntityService implements OnModuleInit { ...(opts.detail ? { clippedCount: note.clippedCount, - // そもそもJOINしていない場合はundefined、JOINしたけど存在していなかった場合はnullで区別される reply: note.replyId ? this.pack(note.reply ?? note.replyId, me, { detail: false, skipHide: opts.skipHide, @@ -470,7 +484,6 @@ export class NoteEntityService implements OnModuleInit { _hint_: options?._hint_, }) : undefined, - // そもそもJOINしていない場合はundefined、JOINしたけど存在していなかった場合はnullで区別される renote: note.renoteId ? this.pack(note.renote ?? note.renoteId, me, { detail: true, skipHide: opts.skipHide, @@ -502,17 +515,7 @@ export class NoteEntityService implements OnModuleInit { public async packDeletedNote( srcId: MiNote['id'] | MiDeletedNote, me?: { id: MiUser['id'] } | null | undefined, - options?: { - detail?: boolean; - skipHide?: boolean; - withReactionAndUserPairCache?: boolean; - _hint_?: { - bufferedReactions: Map; pairs: ([MiUser['id'], string])[] }> | null; - myReactions: Map; - packedFiles: Map | null>; - packedUsers: Map> - }; - }, + options?: PackSingleOptions, ): Promise> { const opts = Object.assign({ detail: true, @@ -595,7 +598,7 @@ export class NoteEntityService implements OnModuleInit { @bindThis public async packMany( - notes: MiNote[], + notes: (MiNote | MiDeletedNote)[], me?: { id: MiUser['id'] } | null | undefined, options?: { detail?: boolean; @@ -604,7 +607,9 @@ export class NoteEntityService implements OnModuleInit { ) { if (notes.length === 0) return []; - const bufferedReactions = this.meta.enableReactionsBuffering ? await this.reactionsBufferingService.getMany([...getAppearNoteIds(notes)]) : null; + const liveNotes = notes.filter((n): n is MiNote => !('deletedAt' in n)); + + const bufferedReactions = this.meta.enableReactionsBuffering ? await this.reactionsBufferingService.getMany([...getAppearNoteIds(liveNotes)]) : null; const meId = me ? me.id : null; const myReactionsMap = new Map(); @@ -614,7 +619,7 @@ export class NoteEntityService implements OnModuleInit { // パフォーマンスのためノートが作成されてから2秒以上経っていない場合はリアクションを取得しない const oldId = this.idService.gen(Date.now() - 2000); - for (const note of notes) { + for (const note of liveNotes) { if (isPureRenote(note)) { const reactionsCount = Object.values(this.reactionsBufferingService.mergeReactions(note.renote.reactions, bufferedReactions?.get(note.renote.id)?.deltas ?? {})).reduce((a, b) => a + b, 0); if (reactionsCount === 0) { @@ -662,9 +667,9 @@ export class NoteEntityService implements OnModuleInit { } } - await this.customEmojiService.prefetchEmojis(this.aggregateNoteEmojis(notes)); + await this.customEmojiService.prefetchEmojis(this.aggregateNoteEmojis(liveNotes)); // TODO: 本当は renote とか reply がないのに renoteId とか replyId があったらここで解決しておく - const fileIds = notes.map(n => [n.fileIds, n.renote?.fileIds, n.reply?.fileIds]).flat(2).filter(x => x != null); + const fileIds = liveNotes.map(n => [n.fileIds, n.renote?.fileIds, n.reply?.fileIds]).flat(2).filter(x => x != null); const packedFiles = fileIds.length > 0 ? await this.driveFileEntityService.packManyByIdsMap(fileIds) : new Map(); const users = [ ...notes.map(({ user, userId }) => user ?? userId), @@ -674,7 +679,7 @@ export class NoteEntityService implements OnModuleInit { const packedUsers = await this.userEntityService.packMany(users, me) .then(users => new Map(users.map(u => [u.id, u]))); - return await Promise.all(notes.map(n => this.pack(n, me, { + return await Promise.all(notes.map(n => this.packMayDeleted(n, me, { ...options, _hint_: { bufferedReactions, diff --git a/packages/backend/src/server/api/GetterService.ts b/packages/backend/src/server/api/GetterService.ts index 439396a62f..6c273395ca 100644 --- a/packages/backend/src/server/api/GetterService.ts +++ b/packages/backend/src/server/api/GetterService.ts @@ -9,6 +9,7 @@ import type { DeletedNotesRepository, NotesRepository, UsersRepository } from '@ import { IdentifiableError } from '@/misc/identifiable-error.js'; import type { MiLocalUser, MiRemoteUser, MiUser } from '@/models/User.js'; import type { MiNote } from '@/models/Note.js'; +import type { MiDeletedNote } from '@/models/DeletedNote.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { bindThis } from '@/decorators.js'; @@ -43,19 +44,17 @@ export class GetterService { } @bindThis - public async getDeletedNote(noteId: MiNote['id']) { - const note = await this.deletedNotesRepository.findOneBy({ id: noteId }); + public async getMayDeletedNoteOrNull(noteId: MiNote['id']): Promise { + let note: MiNote | MiDeletedNote | null = await this.notesRepository.findOneBy({ id: noteId }); - if (note == null) { - throw new IdentifiableError('f2d7e5b8-9d79-4996-b996-89b538a1b71f', 'No such deleted note.'); - } + note ??= await this.deletedNotesRepository.findOneBy({ id: noteId }); return note; } @bindThis - public async getNoteWithRelations(noteId: MiNote['id']) { - const note = await this.notesRepository.findOne({ where: { id: noteId }, relations: ['user', 'reply', 'renote', 'reply.user', 'renote.user'] }); + public async getMayDeletedNote(noteId: MiNote['id']): Promise<(MiNote | MiDeletedNote)> { + const note = await this.getMayDeletedNoteOrNull(noteId); if (note == null) { throw new IdentifiableError('9725d0ce-ba28-4dde-95a7-2cbb2c15de24', 'No such note.'); @@ -65,14 +64,16 @@ export class GetterService { } @bindThis - public async getDeletedNoteWithRelations(noteId: MiNote['id']) { - const note = await this.deletedNotesRepository.findOne({ where: { id: noteId }, relations: ['user', 'reply', 'renote', 'reply.user', 'renote.user'] }); + public async getMayDeletedNoteWithRelations(noteId: MiNote['id']): Promise<(MiNote | MiDeletedNote) & { user: NonNullable }> { + let note: MiNote | MiDeletedNote | null = await this.notesRepository.findOne({ where: { id: noteId }, relations: ['user', 'reply', 'renote', 'reply.user', 'renote.user'] }); + + note ??= await this.deletedNotesRepository.findOne({ where: { id: noteId }, relations: ['user', 'reply', 'renote', 'reply.user', 'renote.user'] }); if (note == null) { - throw new IdentifiableError('f2d7e5b8-9d79-4996-b996-89b538a1b71f', 'No such deleted note.'); + throw new IdentifiableError('9725d0ce-ba28-4dde-95a7-2cbb2c15de24', 'No such note.'); } - return note; + return note as (MiNote | MiDeletedNote) & { user: object }; } /** diff --git a/packages/backend/src/server/api/endpoints/notes/conversation.ts b/packages/backend/src/server/api/endpoints/notes/conversation.ts index d13fd5e82e..4091def71f 100644 --- a/packages/backend/src/server/api/endpoints/notes/conversation.ts +++ b/packages/backend/src/server/api/endpoints/notes/conversation.ts @@ -3,12 +3,11 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import type { MiNote } from '@/models/Note.js'; -import type { NotesRepository } from '@/models/_.js'; +import type { MiDeletedNote } from '@/models/DeletedNote.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; -import { DI } from '@/di-symbols.js'; import { GetterService } from '@/server/api/GetterService.js'; import { ApiError } from '../../error.js'; @@ -49,24 +48,21 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( - @Inject(DI.notesRepository) - private notesRepository: NotesRepository, - private noteEntityService: NoteEntityService, private getterService: GetterService, ) { super(meta, paramDef, async (ps, me) => { - const note = await this.getterService.getNote(ps.noteId).catch(err => { + const note = await this.getterService.getMayDeletedNote(ps.noteId).catch(err => { if (err.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); throw err; }); - const conversation: MiNote[] = []; + const conversation: (MiNote | MiDeletedNote)[] = []; let i = 0; - const get = async (id: any) => { + const get = async (id: MiNote['id']) => { i++; - const p = await this.notesRepository.findOneBy({ id }); + const p = await this.getterService.getMayDeletedNoteOrNull(id); if (p == null) return; if (i > ps.offset!) { diff --git a/packages/backend/src/server/api/endpoints/notes/show.ts b/packages/backend/src/server/api/endpoints/notes/show.ts index d8596fe120..f73862d0d5 100644 --- a/packages/backend/src/server/api/endpoints/notes/show.ts +++ b/packages/backend/src/server/api/endpoints/notes/show.ts @@ -57,28 +57,12 @@ export default class extends Endpoint { // eslint- private getterService: GetterService, ) { super(meta, paramDef, async (ps, me) => { - let note: MiNote | void; - try { - note = await this.getterService.getNoteWithRelations(ps.noteId); - } catch (err) { - if (err instanceof IdentifiableError && err.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') { - try { - const deletedNote = await this.getterService.getDeletedNoteWithRelations(ps.noteId); - - return await this.noteEntityService.packDeletedNote(deletedNote, me, { - detail: true, - }); - } catch (err) { - if (err instanceof IdentifiableError && err.id === 'f2d7e5b8-9d79-4996-b996-89b538a1b71f') { - throw new ApiError(meta.errors.noSuchNote); - } - throw err; - } - } + const note = await this.getterService.getMayDeletedNoteWithRelations(ps.noteId).catch(err => { + if (err.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); throw err; - } + }); - if (note.user!.requireSigninToViewContents && me == null) { + if (note.user.requireSigninToViewContents && me == null) { throw new ApiError(meta.errors.signinRequired); } @@ -86,11 +70,11 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.signinRequired); } - if (this.serverSettings.ugcVisibilityForVisitor === 'local' && note.userHost != null && me == null) { + if (this.serverSettings.ugcVisibilityForVisitor === 'local' && note.user.host != null && me == null) { throw new ApiError(meta.errors.signinRequired); } - return await this.noteEntityService.pack(note, me, { + return await this.noteEntityService.packMayDeleted(note, me, { detail: true, }); }); From 96151b00da16806d4c82b5334da1cb9542ce922c Mon Sep 17 00:00:00 2001 From: anatawa12 Date: Sat, 2 Aug 2025 23:17:00 +0900 Subject: [PATCH 07/11] chore: show ban icon for reacting, renoting and replying button on note --- packages/frontend/src/components/MkNote.vue | 15 +++++++++--- .../src/components/MkNoteDetailed.vue | 15 +++++++++--- .../frontend/src/utility/get-note-menu.ts | 24 ++++++++++++++++++- 3 files changed, 47 insertions(+), 7 deletions(-) diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue index 741954e57a..544e42d693 100644 --- a/packages/frontend/src/components/MkNote.vue +++ b/packages/frontend/src/components/MkNote.vue @@ -125,12 +125,15 @@ SPDX-License-Identifier: AGPL-3.0-only