diff --git a/packages/backend/src/server/api/stream/channels/antenna.ts b/packages/backend/src/server/api/stream/channels/antenna.ts index 53dc7f18b6..462463815e 100644 --- a/packages/backend/src/server/api/stream/channels/antenna.ts +++ b/packages/backend/src/server/api/stream/channels/antenna.ts @@ -16,6 +16,7 @@ class AntennaChannel extends Channel { public static requireCredential = true as const; public static kind = 'read:account'; private antennaId: string; + private idOnly: boolean; constructor( private noteEntityService: NoteEntityService, @@ -28,9 +29,10 @@ class AntennaChannel extends Channel { } @bindThis - public async init(params: JsonObject) { + public async init(params: any) { if (typeof params.antennaId !== 'string') return; - this.antennaId = params.antennaId; + this.antennaId = params.antennaId as string; + this.idOnly = !!(params.idOnly ?? false); // Subscribe stream this.subscriber.on(`antennaStream:${this.antennaId}`, this.onEvent); @@ -43,9 +45,13 @@ class AntennaChannel extends Channel { if (this.isNoteMutedOrBlocked(note)) return; - this.connection.cacheNote(note); - - this.send('note', note); + if (this.idOnly && ['public', 'home'].includes(note.visibility)) { + const idOnlyNote = { id: note.id }; + this.send('note', idOnlyNote); + } else { + this.connection.cacheNote(note); + this.send('note', note); + } } else { this.send(data.type, data.body); } diff --git a/packages/backend/src/server/api/stream/channels/channel.ts b/packages/backend/src/server/api/stream/channels/channel.ts index 7108e0cd6e..7437764083 100644 --- a/packages/backend/src/server/api/stream/channels/channel.ts +++ b/packages/backend/src/server/api/stream/channels/channel.ts @@ -16,6 +16,7 @@ class ChannelChannel extends Channel { public static shouldShare = false; public static requireCredential = false as const; private channelId: string; + private idOnly: boolean; constructor( private noteEntityService: NoteEntityService, @@ -28,9 +29,10 @@ class ChannelChannel extends Channel { } @bindThis - public async init(params: JsonObject) { + public async init(params: any) { if (typeof params.channelId !== 'string') return; - this.channelId = params.channelId; + this.channelId = params.channelId as string; + this.idOnly = !!(params.idOnly ?? false); // Subscribe stream this.subscriber.on('notesStream', this.onNote); @@ -49,9 +51,13 @@ class ChannelChannel extends Channel { } } - this.connection.cacheNote(note); - - this.send('note', note); + if (this.idOnly && ['public', 'home'].includes(note.visibility)) { + const idOnlyNote = { id: note.id }; + this.send('note', idOnlyNote); + } else { + this.connection.cacheNote(note); + this.send('note', note); + } } @bindThis diff --git a/packages/backend/src/server/api/stream/channels/global-timeline.ts b/packages/backend/src/server/api/stream/channels/global-timeline.ts index ed56fe0d40..8b12a8b170 100644 --- a/packages/backend/src/server/api/stream/channels/global-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/global-timeline.ts @@ -19,6 +19,7 @@ class GlobalTimelineChannel extends Channel { public static requireCredential = false as const; private withRenotes: boolean; private withFiles: boolean; + private idOnly: boolean; constructor( private metaService: MetaService, @@ -39,6 +40,7 @@ class GlobalTimelineChannel extends Channel { this.withRenotes = !!(params.withRenotes ?? true); this.withFiles = !!(params.withFiles ?? false); + this.idOnly = !!(params.idOnly ?? false); // Subscribe events this.subscriber.on('notesStream', this.onNote); @@ -62,9 +64,13 @@ class GlobalTimelineChannel extends Channel { } } - this.connection.cacheNote(note); - - this.send('note', note); + if (this.idOnly && ['public', 'home'].includes(note.visibility)) { + const idOnlyNote = { id: note.id }; + this.send('note', idOnlyNote); + } else { + this.connection.cacheNote(note); + this.send('note', note); + } } @bindThis diff --git a/packages/backend/src/server/api/stream/channels/home-timeline.ts b/packages/backend/src/server/api/stream/channels/home-timeline.ts index 66644ed58c..fedead59c5 100644 --- a/packages/backend/src/server/api/stream/channels/home-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/home-timeline.ts @@ -18,6 +18,7 @@ class HomeTimelineChannel extends Channel { public static kind = 'read:account'; private withRenotes: boolean; private withFiles: boolean; + private idOnly: boolean; constructor( private noteEntityService: NoteEntityService, @@ -33,6 +34,7 @@ class HomeTimelineChannel extends Channel { public async init(params: JsonObject) { this.withRenotes = !!(params.withRenotes ?? true); this.withFiles = !!(params.withFiles ?? false); + this.idOnly = !!(params.idOnly ?? false); this.subscriber.on('notesStream', this.onNote); } @@ -86,9 +88,13 @@ class HomeTimelineChannel extends Channel { } } - this.connection.cacheNote(note); - - this.send('note', note); + if (this.idOnly && ['public', 'home'].includes(note.visibility)) { + const idOnlyNote = { id: note.id }; + this.send('note', idOnlyNote); + } else { + this.connection.cacheNote(note); + this.send('note', note); + } } @bindThis diff --git a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts index 75bd13221f..1162cf1707 100644 --- a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts @@ -21,6 +21,7 @@ class HybridTimelineChannel extends Channel { private withRenotes: boolean; private withReplies: boolean; private withFiles: boolean; + private idOnly: boolean; constructor( private metaService: MetaService, @@ -42,6 +43,7 @@ class HybridTimelineChannel extends Channel { this.withRenotes = !!(params.withRenotes ?? true); this.withReplies = !!(params.withReplies ?? false); this.withFiles = !!(params.withFiles ?? false); + this.idOnly = !!(params.idOnly ?? false); // Subscribe events this.subscriber.on('notesStream', this.onNote); @@ -101,9 +103,13 @@ class HybridTimelineChannel extends Channel { } } - this.connection.cacheNote(note); - - this.send('note', note); + if (this.idOnly && ['public', 'home'].includes(note.visibility)) { + const idOnlyNote = { id: note.id }; + this.send('note', idOnlyNote); + } else { + this.connection.cacheNote(note); + this.send('note', note); + } } @bindThis diff --git a/packages/backend/src/server/api/stream/channels/local-timeline.ts b/packages/backend/src/server/api/stream/channels/local-timeline.ts index 491029f5de..aeb89ae9a0 100644 --- a/packages/backend/src/server/api/stream/channels/local-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/local-timeline.ts @@ -20,6 +20,7 @@ class LocalTimelineChannel extends Channel { private withRenotes: boolean; private withReplies: boolean; private withFiles: boolean; + private idOnly: boolean; constructor( private metaService: MetaService, @@ -41,6 +42,7 @@ class LocalTimelineChannel extends Channel { this.withRenotes = !!(params.withRenotes ?? true); this.withReplies = !!(params.withReplies ?? false); this.withFiles = !!(params.withFiles ?? false); + this.idOnly = !!(params.idOnly ?? false); // Subscribe events this.subscriber.on('notesStream', this.onNote); @@ -72,9 +74,13 @@ class LocalTimelineChannel extends Channel { } } - this.connection.cacheNote(note); - - this.send('note', note); + if (this.idOnly && ['public', 'home'].includes(note.visibility)) { + const idOnlyNote = { id: note.id }; + this.send('note', idOnlyNote); + } else { + this.connection.cacheNote(note); + this.send('note', note); + } } @bindThis diff --git a/packages/backend/src/server/api/stream/channels/role-timeline.ts b/packages/backend/src/server/api/stream/channels/role-timeline.ts index fcfa26c38b..2b99de71c9 100644 --- a/packages/backend/src/server/api/stream/channels/role-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/role-timeline.ts @@ -16,6 +16,7 @@ class RoleTimelineChannel extends Channel { public static shouldShare = false; public static requireCredential = false as const; private roleId: string; + private idOnly: boolean; constructor( private noteEntityService: NoteEntityService, @@ -32,6 +33,7 @@ class RoleTimelineChannel extends Channel { public async init(params: JsonObject) { if (typeof params.roleId !== 'string') return; this.roleId = params.roleId; + this.idOnly = !!(params.idOnly ?? false); this.subscriber.on(`roleTimelineStream:${this.roleId}`, this.onEvent); } @@ -48,7 +50,12 @@ class RoleTimelineChannel extends Channel { if (this.isNoteMutedOrBlocked(note)) return; - this.send('note', note); + if (this.idOnly && ['public', 'home'].includes(note.visibility)) { + const idOnlyNote = { id: note.id }; + this.send('note', idOnlyNote); + } else { + this.send('note', note); + } } else { this.send(data.type, data.body); } diff --git a/packages/backend/src/server/api/stream/channels/user-list.ts b/packages/backend/src/server/api/stream/channels/user-list.ts index 4f38351e94..a9d58e411b 100644 --- a/packages/backend/src/server/api/stream/channels/user-list.ts +++ b/packages/backend/src/server/api/stream/channels/user-list.ts @@ -22,6 +22,7 @@ class UserListChannel extends Channel { private listUsersClock: NodeJS.Timeout; private withFiles: boolean; private withRenotes: boolean; + private idOnly: boolean; constructor( private userListsRepository: UserListsRepository, @@ -42,6 +43,7 @@ class UserListChannel extends Channel { this.listId = params.listId; this.withFiles = !!(params.withFiles ?? false); this.withRenotes = !!(params.withRenotes ?? true); + this.idOnly = !!(params.idOnly ?? false); // Check existence and owner const listExist = await this.userListsRepository.exists({ @@ -118,9 +120,13 @@ class UserListChannel extends Channel { } } - this.connection.cacheNote(note); - - this.send('note', note); + if (this.idOnly && ['public', 'home'].includes(note.visibility)) { + const idOnlyNote = { id: note.id }; + this.send('note', idOnlyNote); + } else { + this.connection.cacheNote(note); + this.send('note', note); + } } @bindThis diff --git a/packages/backend/src/server/web/ClientServerService.ts b/packages/backend/src/server/web/ClientServerService.ts index 1a75096c4e..74d6544ecb 100644 --- a/packages/backend/src/server/web/ClientServerService.ts +++ b/packages/backend/src/server/web/ClientServerService.ts @@ -659,6 +659,81 @@ export class ClientServerService { } }); + fastify.get<{ Params: { note: string; } }>('/notes/:note.json', async (request, reply) => { + const note = await this.notesRepository.findOneBy({ + id: request.params.note, + visibility: In(['public', 'home']), + }); + + if (note) { + try { + const _note = await this.noteEntityService.pack(note, null); + reply.header('Content-Type', 'application/json; charset=utf-8'); + reply.header('Cache-Control', 'public, max-age=600'); + return reply.send(_note); + } catch (err) { + reply.header('Cache-Control', 'max-age=10, must-revalidate'); + if (err instanceof IdentifiableError) { + this.clientLoggerService.logger.error(`Internal error occurred in ${request.routeOptions.url}: ${err.message}`, { + path: request.routeOptions.url, + params: request.params, + query: request.query, + id: err.id, + error: { + message: err.message, + code: 'INTERNAL_ERROR', + stack: err.stack, + }, + }); + const httpStatusCode = err.id === '85ab9bd7-3a41-4530-959d-f07073900109' ? 403 : 500; + reply.code(httpStatusCode); + return reply.send({ + message: err.message, + code: 'INTERNAL_ERROR', + id: err.id, + kind: 'server', + httpStatusCode, + info: { + message: err.message, + code: err.name, + id: err.id, + }, + }); + } else { + const error = err as Error; + const errId = randomUUID(); + this.clientLoggerService.logger.error(`Internal error occurred in ${request.routeOptions.url}: ${error.message}`, { + path: request.routeOptions.url, + params: request.params, + query: request.query, + id: errId, + error: { + message: error.message, + code: error.name, + stack: error.stack, + }, + }); + reply.code(500); + return reply.send({ + message: 'Internal error occurred. Please contact us if the error persists.', + code: 'INTERNAL_ERROR', + id: 'b9f2a7f9-fe64-434b-9484-cb1f804d1a80', + kind: 'server', + httpStatusCode: 500, + info: { + message: error.message, + code: error.name, + id: errId, + }, + }); + } + } + } else { + reply.code(404); + return; + } + }); + // Page fastify.get<{ Params: { user: string; page: string; } }>('/@:user/pages/:page', async (request, reply) => { const { username, host } = Acct.parse(request.params.user); diff --git a/packages/backend/test/utils.ts b/packages/backend/test/utils.ts index 26de19eaf1..2c337ffff9 100644 --- a/packages/backend/test/utils.ts +++ b/packages/backend/test/utils.ts @@ -415,7 +415,7 @@ export const waitFire = async (user: UserToken if (timer) clearTimeout(timer); res(true); } - }, params); + }, { ...params, idOnly: false }); } catch (e) { rej(e); } diff --git a/packages/frontend/src/components/MkTimeline.vue b/packages/frontend/src/components/MkTimeline.vue index fb8eb4ae37..d958f82973 100644 --- a/packages/frontend/src/components/MkTimeline.vue +++ b/packages/frontend/src/components/MkTimeline.vue @@ -24,10 +24,11 @@ import MkNotes from '@/components/MkNotes.vue'; import MkPullToRefresh from '@/components/MkPullToRefresh.vue'; import { useStream } from '@/stream.js'; import * as sound from '@/scripts/sound.js'; -import { $i } from '@/account.js'; +import { $i, iAmModerator } from '@/account.js'; import { instance } from '@/instance.js'; import { defaultStore } from '@/store.js'; import { Paging } from '@/components/MkPagination.vue'; +import { generateClientTransactionId } from '@/scripts/misskey-api.js'; const props = withDefaults(defineProps<{ src: BasicTimelineType | 'mentions' | 'directs' | 'list' | 'antenna' | 'channel' | 'role'; @@ -72,9 +73,36 @@ const tlComponent = shallowRef>(); let tlNotesCount = 0; -function prepend(note) { +async function prepend(data) { if (tlComponent.value == null) return; + let note = data; + + // チェックするプロパティはなんでも良い + // idOnlyが有効でid以外が存在しない場合は取得する + if (!data.visibility) { + const initiateTime = Date.now(); + const res = await window.fetch(`/notes/${data.id}.json`, { + method: 'GET', + credentials: 'omit', + headers: { + 'Authorization': 'anonymous', + 'X-Client-Transaction-Id': generateClientTransactionId('misskey'), + }, + }).then(res => { + if (instance.googleAnalyticsId) { + gtagTime({ + name: 'api-get', + event_category: `/notes/${data.id}.json`, + value: Date.now() - initiateTime, + }); + } + return res; + }); + if (!res.ok) return; + note = await res.json(); + } + tlNotesCount++; if (instance.notesPerOneAd > 0 && tlNotesCount % instance.notesPerOneAd === 0) { @@ -93,6 +121,7 @@ function prepend(note) { let connection: Misskey.ChannelConnection | null = null; let connection2: Misskey.ChannelConnection | null = null; let paginationQuery: Paging | null = null; +const idOnly = !iAmModerator; const stream = useStream(); @@ -101,11 +130,13 @@ function connectChannel() { if (props.antenna == null) return; connection = stream.useChannel('antenna', { antennaId: props.antenna, + idOnly: idOnly, }); } else if (props.src === 'home') { connection = stream.useChannel('homeTimeline', { withRenotes: props.withRenotes, withFiles: props.onlyFiles ? true : undefined, + idOnly: idOnly, }); connection2 = stream.useChannel('main'); } else if (props.src === 'local') { @@ -113,17 +144,20 @@ function connectChannel() { withRenotes: props.withRenotes, withReplies: props.withReplies, withFiles: props.onlyFiles ? true : undefined, + idOnly: idOnly, }); } else if (props.src === 'social') { connection = stream.useChannel('hybridTimeline', { withRenotes: props.withRenotes, withReplies: props.withReplies, withFiles: props.onlyFiles ? true : undefined, + idOnly: idOnly, }); } else if (props.src === 'global') { connection = stream.useChannel('globalTimeline', { withRenotes: props.withRenotes, withFiles: props.onlyFiles ? true : undefined, + idOnly: idOnly, }); } else if (props.src === 'mentions') { connection = stream.useChannel('main'); @@ -142,16 +176,19 @@ function connectChannel() { withRenotes: props.withRenotes, withFiles: props.onlyFiles ? true : undefined, listId: props.list, + idOnly: idOnly, }); } else if (props.src === 'channel') { if (props.channel == null) return; connection = stream.useChannel('channel', { channelId: props.channel, + idOnly: idOnly, }); } else if (props.src === 'role') { if (props.role == null) return; connection = stream.useChannel('roleTimeline', { roleId: props.role, + idOnly: idOnly, }); } if (props.src !== 'directs' && props.src !== 'mentions') connection?.on('note', prepend); diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md index 01a3dbbb30..7a5fa004a1 100644 --- a/packages/misskey-js/etc/misskey-js.api.md +++ b/packages/misskey-js/etc/misskey-js.api.md @@ -632,6 +632,7 @@ export type Channels = { params: { withRenotes?: boolean; withFiles?: boolean; + idOnly?: boolean; }; events: { note: (payload: Note) => void; @@ -643,6 +644,7 @@ export type Channels = { withRenotes?: boolean; withReplies?: boolean; withFiles?: boolean; + idOnly?: boolean; }; events: { note: (payload: Note) => void; @@ -654,6 +656,7 @@ export type Channels = { withRenotes?: boolean; withReplies?: boolean; withFiles?: boolean; + idOnly?: boolean; }; events: { note: (payload: Note) => void; @@ -664,6 +667,7 @@ export type Channels = { params: { withRenotes?: boolean; withFiles?: boolean; + idOnly?: boolean; }; events: { note: (payload: Note) => void; @@ -675,6 +679,7 @@ export type Channels = { listId: string; withFiles?: boolean; withRenotes?: boolean; + idOnly?: boolean; }; events: { note: (payload: Note) => void; @@ -693,6 +698,7 @@ export type Channels = { roleTimeline: { params: { roleId: string; + idOnly?: boolean; }; events: { note: (payload: Note) => void; @@ -702,6 +708,7 @@ export type Channels = { antenna: { params: { antennaId: string; + idOnly?: boolean; }; events: { note: (payload: Note) => void; @@ -711,6 +718,7 @@ export type Channels = { channel: { params: { channelId: string; + idOnly?: boolean; }; events: { note: (payload: Note) => void; diff --git a/packages/misskey-js/src/streaming.types.ts b/packages/misskey-js/src/streaming.types.ts index 26a50f9fa4..6bdbd8a434 100644 --- a/packages/misskey-js/src/streaming.types.ts +++ b/packages/misskey-js/src/streaming.types.ts @@ -73,6 +73,7 @@ export type Channels = { params: { withRenotes?: boolean; withFiles?: boolean; + idOnly?: boolean, }; events: { note: (payload: Note) => void; @@ -84,6 +85,7 @@ export type Channels = { withRenotes?: boolean; withReplies?: boolean; withFiles?: boolean; + idOnly?: boolean, }; events: { note: (payload: Note) => void; @@ -95,6 +97,7 @@ export type Channels = { withRenotes?: boolean; withReplies?: boolean; withFiles?: boolean; + idOnly?: boolean, }; events: { note: (payload: Note) => void; @@ -105,6 +108,7 @@ export type Channels = { params: { withRenotes?: boolean; withFiles?: boolean; + idOnly?: boolean, }; events: { note: (payload: Note) => void; @@ -116,6 +120,7 @@ export type Channels = { listId: string; withFiles?: boolean; withRenotes?: boolean; + idOnly?: boolean, }; events: { note: (payload: Note) => void; @@ -134,6 +139,7 @@ export type Channels = { roleTimeline: { params: { roleId: string; + idOnly?: boolean, }; events: { note: (payload: Note) => void; @@ -143,6 +149,7 @@ export type Channels = { antenna: { params: { antennaId: string; + idOnly?: boolean, }; events: { note: (payload: Note) => void; @@ -152,6 +159,7 @@ export type Channels = { channel: { params: { channelId: string; + idOnly?: boolean, }; events: { note: (payload: Note) => void;