From 023fa30280e561e9921a2c83138af4cac01068ab Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Sun, 22 Sep 2024 12:53:13 +0900 Subject: refactor/perf(backend): provide metadata statically (#14601) * wip * Update ReactionService.ts * Update ApiCallService.ts * Update timeline.ts * Update GlobalModule.ts * Update GlobalModule.ts * Update NoteEntityService.ts * wip * wip * wip * Update ApPersonService.ts * wip * Update GlobalModule.ts * Update mock-resolver.ts * Update RoleService.ts * Update activitypub.ts * Update activitypub.ts * Update activitypub.ts * Update activitypub.ts * Update activitypub.ts * clean up * Update utils.ts * Update UtilityService.ts * Revert "Update utils.ts" This reverts commit a27d4be764b78c1b5a9eac685e261fee49331d89. * Revert "Update UtilityService.ts" This reverts commit e5fd9e004c482cf099252201c0c1aa888e001430. * vuwa- * Revert "vuwa-" This reverts commit 0c3bd12472b4b9938cdff2d6f131e6800bc3724c. * Update entry.ts * Update entry.ts * Update entry.ts * Update entry.ts * Update jest.setup.ts --- packages/backend/src/core/NoteCreateService.ts | 60 +++++++++++--------------- 1 file changed, 26 insertions(+), 34 deletions(-) (limited to 'packages/backend/src/core/NoteCreateService.ts') diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 1d8d248322..18efc9d562 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -8,13 +8,12 @@ import * as mfm from 'mfm-js'; import { In, DataSource, IsNull, LessThan } from 'typeorm'; import * as Redis from 'ioredis'; import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; -import RE2 from 're2'; import { extractMentions } from '@/misc/extract-mentions.js'; import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js'; import { extractHashtags } from '@/misc/extract-hashtags.js'; import type { IMentionedRemoteUsers } from '@/models/Note.js'; import { MiNote } from '@/models/Note.js'; -import type { ChannelFollowingsRepository, ChannelsRepository, FollowingsRepository, InstancesRepository, MiFollowing, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserListMembershipsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js'; +import type { ChannelFollowingsRepository, ChannelsRepository, FollowingsRepository, InstancesRepository, MiFollowing, MiMeta, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserListMembershipsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js'; import type { MiDriveFile } from '@/models/DriveFile.js'; import type { MiApp } from '@/models/App.js'; import { concat } from '@/misc/prelude/array.js'; @@ -23,11 +22,8 @@ import type { MiUser, MiLocalUser, MiRemoteUser } from '@/models/User.js'; import type { IPoll } from '@/models/Poll.js'; import { MiPoll } from '@/models/Poll.js'; import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js'; -import { checkWordMute } from '@/misc/check-word-mute.js'; import type { MiChannel } from '@/models/Channel.js'; import { normalizeForSearch } from '@/misc/normalize-for-search.js'; -import { MemorySingleCache } from '@/misc/cache.js'; -import type { MiUserProfile } from '@/models/UserProfile.js'; import { RelayService } from '@/core/RelayService.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; import { DI } from '@/di-symbols.js'; @@ -51,7 +47,6 @@ import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js'; import { bindThis } from '@/decorators.js'; import { DB_MAX_NOTE_TEXT_LENGTH } from '@/const.js'; import { RoleService } from '@/core/RoleService.js'; -import { MetaService } from '@/core/MetaService.js'; import { SearchService } from '@/core/SearchService.js'; import { FeaturedService } from '@/core/FeaturedService.js'; import { FanoutTimelineService } from '@/core/FanoutTimelineService.js'; @@ -156,6 +151,9 @@ export class NoteCreateService implements OnApplicationShutdown { @Inject(DI.config) private config: Config, + @Inject(DI.meta) + private meta: MiMeta, + @Inject(DI.db) private db: DataSource, @@ -210,7 +208,6 @@ export class NoteCreateService implements OnApplicationShutdown { private apDeliverManagerService: ApDeliverManagerService, private apRendererService: ApRendererService, private roleService: RoleService, - private metaService: MetaService, private searchService: SearchService, private notesChart: NotesChart, private perUserNotesChart: PerUserNotesChart, @@ -251,10 +248,8 @@ export class NoteCreateService implements OnApplicationShutdown { if (data.channel != null) data.visibleUsers = []; if (data.channel != null) data.localOnly = true; - const meta = await this.metaService.fetch(); - if (data.visibility === 'public' && data.channel == null) { - const sensitiveWords = meta.sensitiveWords; + const sensitiveWords = this.meta.sensitiveWords; if (this.utilityService.isKeyWordIncluded(data.cw ?? data.text ?? '', sensitiveWords)) { data.visibility = 'home'; } else if ((await this.roleService.getUserPolicies(user.id)).canPublicNote === false) { @@ -262,17 +257,17 @@ export class NoteCreateService implements OnApplicationShutdown { } } - const hasProhibitedWords = await this.checkProhibitedWordsContain({ + const hasProhibitedWords = this.checkProhibitedWordsContain({ cw: data.cw, text: data.text, pollChoices: data.poll?.choices, - }, meta.prohibitedWords); + }, this.meta.prohibitedWords); if (hasProhibitedWords) { throw new IdentifiableError('689ee33f-f97c-479a-ac49-1b9f8140af99', 'Note contains prohibited words'); } - const inSilencedInstance = this.utilityService.isSilencedHost(meta.silencedHosts, user.host); + const inSilencedInstance = this.utilityService.isSilencedHost(this.meta.silencedHosts, user.host); if (data.visibility === 'public' && inSilencedInstance && user.host !== null) { data.visibility = 'home'; @@ -365,7 +360,7 @@ export class NoteCreateService implements OnApplicationShutdown { } // if the host is media-silenced, custom emojis are not allowed - if (this.utilityService.isMediaSilencedHost(meta.mediaSilencedHosts, user.host)) emojis = []; + if (this.utilityService.isMediaSilencedHost(this.meta.mediaSilencedHosts, user.host)) emojis = []; tags = tags.filter(tag => Array.from(tag).length <= 128).splice(0, 32); @@ -506,10 +501,8 @@ export class NoteCreateService implements OnApplicationShutdown { host: MiUser['host']; isBot: MiUser['isBot']; }, data: Option, silent: boolean, tags: string[], mentionedUsers: MinimumUser[]) { - const meta = await this.metaService.fetch(); - this.notesChart.update(note, true); - if (note.visibility !== 'specified' && (meta.enableChartsForRemoteUser || (user.host == null))) { + if (note.visibility !== 'specified' && (this.meta.enableChartsForRemoteUser || (user.host == null))) { this.perUserNotesChart.update(user, note, true); } @@ -517,7 +510,7 @@ export class NoteCreateService implements OnApplicationShutdown { if (this.userEntityService.isRemoteUser(user)) { this.federatedInstanceService.fetch(user.host).then(async i => { this.instancesRepository.increment({ id: i.id }, 'notesCount', 1); - if ((await this.metaService.fetch()).enableChartsForFederatedInstances) { + if (this.meta.enableChartsForFederatedInstances) { this.instanceChart.updateNote(i.host, note, true); } }); @@ -853,15 +846,14 @@ export class NoteCreateService implements OnApplicationShutdown { @bindThis private async pushToTl(note: MiNote, user: { id: MiUser['id']; host: MiUser['host']; }) { - const meta = await this.metaService.fetch(); - if (!meta.enableFanoutTimeline) return; + if (!this.meta.enableFanoutTimeline) return; const r = this.redisForTimelines.pipeline(); if (note.channelId) { this.fanoutTimelineService.push(`channelTimeline:${note.channelId}`, note.id, this.config.perChannelMaxNoteCacheCount, r); - this.fanoutTimelineService.push(`userTimelineWithChannel:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax : meta.perRemoteUserUserTimelineCacheMax, r); + this.fanoutTimelineService.push(`userTimelineWithChannel:${user.id}`, note.id, note.userHost == null ? this.meta.perLocalUserUserTimelineCacheMax : this.meta.perRemoteUserUserTimelineCacheMax, r); const channelFollowings = await this.channelFollowingsRepository.find({ where: { @@ -871,9 +863,9 @@ export class NoteCreateService implements OnApplicationShutdown { }); for (const channelFollowing of channelFollowings) { - this.fanoutTimelineService.push(`homeTimeline:${channelFollowing.followerId}`, note.id, meta.perUserHomeTimelineCacheMax, r); + this.fanoutTimelineService.push(`homeTimeline:${channelFollowing.followerId}`, note.id, this.meta.perUserHomeTimelineCacheMax, r); if (note.fileIds.length > 0) { - this.fanoutTimelineService.push(`homeTimelineWithFiles:${channelFollowing.followerId}`, note.id, meta.perUserHomeTimelineCacheMax / 2, r); + this.fanoutTimelineService.push(`homeTimelineWithFiles:${channelFollowing.followerId}`, note.id, this.meta.perUserHomeTimelineCacheMax / 2, r); } } } else { @@ -911,9 +903,9 @@ export class NoteCreateService implements OnApplicationShutdown { if (!following.withReplies) continue; } - this.fanoutTimelineService.push(`homeTimeline:${following.followerId}`, note.id, meta.perUserHomeTimelineCacheMax, r); + this.fanoutTimelineService.push(`homeTimeline:${following.followerId}`, note.id, this.meta.perUserHomeTimelineCacheMax, r); if (note.fileIds.length > 0) { - this.fanoutTimelineService.push(`homeTimelineWithFiles:${following.followerId}`, note.id, meta.perUserHomeTimelineCacheMax / 2, r); + this.fanoutTimelineService.push(`homeTimelineWithFiles:${following.followerId}`, note.id, this.meta.perUserHomeTimelineCacheMax / 2, r); } } @@ -930,25 +922,25 @@ export class NoteCreateService implements OnApplicationShutdown { if (!userListMembership.withReplies) continue; } - this.fanoutTimelineService.push(`userListTimeline:${userListMembership.userListId}`, note.id, meta.perUserListTimelineCacheMax, r); + this.fanoutTimelineService.push(`userListTimeline:${userListMembership.userListId}`, note.id, this.meta.perUserListTimelineCacheMax, r); if (note.fileIds.length > 0) { - this.fanoutTimelineService.push(`userListTimelineWithFiles:${userListMembership.userListId}`, note.id, meta.perUserListTimelineCacheMax / 2, r); + this.fanoutTimelineService.push(`userListTimelineWithFiles:${userListMembership.userListId}`, note.id, this.meta.perUserListTimelineCacheMax / 2, r); } } // 自分自身のHTL if (note.userHost == null) { if (note.visibility !== 'specified' || !note.visibleUserIds.some(v => v === user.id)) { - this.fanoutTimelineService.push(`homeTimeline:${user.id}`, note.id, meta.perUserHomeTimelineCacheMax, r); + this.fanoutTimelineService.push(`homeTimeline:${user.id}`, note.id, this.meta.perUserHomeTimelineCacheMax, r); if (note.fileIds.length > 0) { - this.fanoutTimelineService.push(`homeTimelineWithFiles:${user.id}`, note.id, meta.perUserHomeTimelineCacheMax / 2, r); + this.fanoutTimelineService.push(`homeTimelineWithFiles:${user.id}`, note.id, this.meta.perUserHomeTimelineCacheMax / 2, r); } } } // 自分自身以外への返信 if (isReply(note)) { - this.fanoutTimelineService.push(`userTimelineWithReplies:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax : meta.perRemoteUserUserTimelineCacheMax, r); + this.fanoutTimelineService.push(`userTimelineWithReplies:${user.id}`, note.id, note.userHost == null ? this.meta.perLocalUserUserTimelineCacheMax : this.meta.perRemoteUserUserTimelineCacheMax, r); if (note.visibility === 'public' && note.userHost == null) { this.fanoutTimelineService.push('localTimelineWithReplies', note.id, 300, r); @@ -957,9 +949,9 @@ export class NoteCreateService implements OnApplicationShutdown { } } } else { - this.fanoutTimelineService.push(`userTimeline:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax : meta.perRemoteUserUserTimelineCacheMax, r); + this.fanoutTimelineService.push(`userTimeline:${user.id}`, note.id, note.userHost == null ? this.meta.perLocalUserUserTimelineCacheMax : this.meta.perRemoteUserUserTimelineCacheMax, r); if (note.fileIds.length > 0) { - this.fanoutTimelineService.push(`userTimelineWithFiles:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax / 2 : meta.perRemoteUserUserTimelineCacheMax / 2, r); + this.fanoutTimelineService.push(`userTimelineWithFiles:${user.id}`, note.id, note.userHost == null ? this.meta.perLocalUserUserTimelineCacheMax / 2 : this.meta.perRemoteUserUserTimelineCacheMax / 2, r); } if (note.visibility === 'public' && note.userHost == null) { @@ -1018,9 +1010,9 @@ export class NoteCreateService implements OnApplicationShutdown { } } - public async checkProhibitedWordsContain(content: Parameters[0], prohibitedWords?: string[]) { + public checkProhibitedWordsContain(content: Parameters[0], prohibitedWords?: string[]) { if (prohibitedWords == null) { - prohibitedWords = (await this.metaService.fetch()).prohibitedWords; + prohibitedWords = this.meta.prohibitedWords; } if ( -- cgit v1.2.3-freya From 7134d24c1f25859e7e092f757ecd327469d75a8f Mon Sep 17 00:00:00 2001 From: KOBA789 Date: Thu, 26 Sep 2024 10:25:20 +0900 Subject: perf(backend): Defer instance metadata update (#14558) * Defer instance metadata update * Fix last new line * Fix typo * Add license notice * Fix syntax * Perform deferred jobs on shutdown * Fix missing async/await * Fix typo :) * Update collapsed-queue.ts --------- Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com> --- packages/backend/src/core/NoteCreateService.ts | 25 ++++++++-- packages/backend/src/misc/collapsed-queue.ts | 44 +++++++++++++++++ .../src/queue/processors/InboxProcessorService.ts | 55 ++++++++++++++++++---- 3 files changed, 111 insertions(+), 13 deletions(-) create mode 100644 packages/backend/src/misc/collapsed-queue.ts (limited to 'packages/backend/src/core/NoteCreateService.ts') diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 18efc9d562..89e3eafa0e 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -55,6 +55,7 @@ import { UserBlockingService } from '@/core/UserBlockingService.js'; import { isReply } from '@/misc/is-reply.js'; import { trackPromise } from '@/misc/promise-tracker.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; +import { CollapsedQueue } from '@/misc/collapsed-queue.js'; type NotificationType = 'reply' | 'renote' | 'quote' | 'mention'; @@ -146,6 +147,7 @@ type Option = { @Injectable() export class NoteCreateService implements OnApplicationShutdown { #shutdownController = new AbortController(); + private updateNotesCountQueue: CollapsedQueue; constructor( @Inject(DI.config) @@ -215,7 +217,9 @@ export class NoteCreateService implements OnApplicationShutdown { private instanceChart: InstanceChart, private utilityService: UtilityService, private userBlockingService: UserBlockingService, - ) { } + ) { + this.updateNotesCountQueue = new CollapsedQueue(60 * 1000 * 5, this.collapseNotesCount, this.performUpdateNotesCount); + } @bindThis public async create(user: { @@ -509,7 +513,7 @@ export class NoteCreateService implements OnApplicationShutdown { // Register host if (this.userEntityService.isRemoteUser(user)) { this.federatedInstanceService.fetch(user.host).then(async i => { - this.instancesRepository.increment({ id: i.id }, 'notesCount', 1); + this.updateNotesCountQueue.enqueue(i.id, 1); if (this.meta.enableChartsForFederatedInstances) { this.instanceChart.updateNote(i.host, note, true); } @@ -1028,12 +1032,23 @@ export class NoteCreateService implements OnApplicationShutdown { } @bindThis - public dispose(): void { + private collapseNotesCount(oldValue: number, newValue: number) { + return oldValue + newValue; + } + + @bindThis + private async performUpdateNotesCount(id: MiNote['id'], incrBy: number) { + await this.instancesRepository.increment({ id: id }, 'notesCount', incrBy); + } + + @bindThis + public async dispose(): Promise { this.#shutdownController.abort(); + await this.updateNotesCountQueue.performAllNow(); } @bindThis - public onApplicationShutdown(signal?: string | undefined): void { - this.dispose(); + public async onApplicationShutdown(signal?: string | undefined): Promise { + await this.dispose(); } } diff --git a/packages/backend/src/misc/collapsed-queue.ts b/packages/backend/src/misc/collapsed-queue.ts new file mode 100644 index 0000000000..5bc20a78ae --- /dev/null +++ b/packages/backend/src/misc/collapsed-queue.ts @@ -0,0 +1,44 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +type Job = { + value: V; + timer: NodeJS.Timeout; +}; + +// TODO: redis使えるようにする +export class CollapsedQueue { + private jobs: Map> = new Map(); + + constructor( + private timeout: number, + private collapse: (oldValue: V, newValue: V) => V, + private perform: (key: K, value: V) => Promise, + ) {} + + enqueue(key: K, value: V) { + if (this.jobs.has(key)) { + const old = this.jobs.get(key)!; + const merged = this.collapse(old.value, value); + this.jobs.set(key, { ...old, value: merged }); + } else { + const timer = setTimeout(() => { + const job = this.jobs.get(key)!; + this.jobs.delete(key); + this.perform(key, job.value); + }, this.timeout); + this.jobs.set(key, { value, timer }); + } + } + + async performAllNow() { + const entries = [...this.jobs.entries()]; + this.jobs.clear(); + for (const [_key, job] of entries) { + clearTimeout(job.timer); + } + await Promise.allSettled(entries.map(([key, job]) => this.perform(key, job.value))); + } +} diff --git a/packages/backend/src/queue/processors/InboxProcessorService.ts b/packages/backend/src/queue/processors/InboxProcessorService.ts index 2df37bedf4..68999b5d17 100644 --- a/packages/backend/src/queue/processors/InboxProcessorService.ts +++ b/packages/backend/src/queue/processors/InboxProcessorService.ts @@ -4,7 +4,7 @@ */ import { URL } from 'node:url'; -import { Inject, Injectable } from '@nestjs/common'; +import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; import httpSignature from '@peertube/http-signature'; import * as Bull from 'bullmq'; import type Logger from '@/logger.js'; @@ -25,14 +25,22 @@ import { JsonLdService } from '@/core/activitypub/JsonLdService.js'; import { ApInboxService } from '@/core/activitypub/ApInboxService.js'; import { bindThis } from '@/decorators.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; -import { QueueLoggerService } from '../QueueLoggerService.js'; -import type { InboxJobData } from '../types.js'; +import { CollapsedQueue } from '@/misc/collapsed-queue.js'; +import { MiNote } from '@/models/Note.js'; import { MiMeta } from '@/models/Meta.js'; import { DI } from '@/di-symbols.js'; +import { QueueLoggerService } from '../QueueLoggerService.js'; +import type { InboxJobData } from '../types.js'; + +type UpdateInstanceJob = { + latestRequestReceivedAt: Date, + shouldUnsuspend: boolean, +}; @Injectable() -export class InboxProcessorService { +export class InboxProcessorService implements OnApplicationShutdown { private logger: Logger; + private updateInstanceQueue: CollapsedQueue; constructor( @Inject(DI.meta) @@ -51,6 +59,7 @@ export class InboxProcessorService { private queueLoggerService: QueueLoggerService, ) { this.logger = this.queueLoggerService.logger.createSubLogger('inbox'); + this.updateInstanceQueue = new CollapsedQueue(60 * 1000 * 5, this.collapseUpdateInstanceJobs, this.performUpdateInstance); } @bindThis @@ -187,11 +196,9 @@ export class InboxProcessorService { // Update stats this.federatedInstanceService.fetch(authUser.user.host).then(i => { - this.federatedInstanceService.update(i.id, { + this.updateInstanceQueue.enqueue(i.id, { latestRequestReceivedAt: new Date(), - isNotResponding: false, - // もしサーバーが死んでるために配信が止まっていた場合には自動的に復活させてあげる - suspensionState: i.suspensionState === 'autoSuspendedForNotResponding' ? 'none' : undefined, + shouldUnsuspend: i.suspensionState === 'autoSuspendedForNotResponding', }); this.fetchInstanceMetadataService.fetchInstanceMetadata(i); @@ -227,4 +234,36 @@ export class InboxProcessorService { } return 'ok'; } + + @bindThis + public collapseUpdateInstanceJobs(oldJob: UpdateInstanceJob, newJob: UpdateInstanceJob) { + const latestRequestReceivedAt = oldJob.latestRequestReceivedAt < newJob.latestRequestReceivedAt + ? newJob.latestRequestReceivedAt + : oldJob.latestRequestReceivedAt; + const shouldUnsuspend = oldJob.shouldUnsuspend || newJob.shouldUnsuspend; + return { + latestRequestReceivedAt, + shouldUnsuspend, + }; + } + + @bindThis + public async performUpdateInstance(id: string, job: UpdateInstanceJob) { + await this.federatedInstanceService.update(id, { + latestRequestReceivedAt: new Date(), + isNotResponding: false, + // もしサーバーが死んでるために配信が止まっていた場合には自動的に復活させてあげる + suspensionState: job.shouldUnsuspend ? 'none' : undefined, + }); + } + + @bindThis + public async dispose(): Promise { + await this.updateInstanceQueue.performAllNow(); + } + + @bindThis + async onApplicationShutdown(signal?: string) { + await this.dispose(); + } } -- cgit v1.2.3-freya From ebff2eec87d2c55896dbd3625848101c16dbbdbb Mon Sep 17 00:00:00 2001 From: Hazel K Date: Sun, 29 Sep 2024 23:24:22 -0400 Subject: track latest note for each user --- packages/backend/src/core/NoteCreateService.ts | 25 +++++++++++++++- packages/backend/src/core/NoteDeleteService.ts | 41 ++++++++++++++++++++++++-- 2 files changed, 63 insertions(+), 3 deletions(-) (limited to 'packages/backend/src/core/NoteCreateService.ts') diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index c252336f99..17631eea89 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -14,7 +14,8 @@ import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mf import { extractHashtags } from '@/misc/extract-hashtags.js'; import type { IMentionedRemoteUsers } from '@/models/Note.js'; import { MiNote } from '@/models/Note.js'; -import type { ChannelFollowingsRepository, ChannelsRepository, FollowingsRepository, InstancesRepository, MiFollowing, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserListMembershipsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js'; +import { LatestNote } from '@/models/LatestNote.js'; +import type { ChannelFollowingsRepository, ChannelsRepository, FollowingsRepository, InstancesRepository, LatestNotesRepository, MiFollowing, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserListMembershipsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js'; import type { MiDriveFile } from '@/models/DriveFile.js'; import type { MiApp } from '@/models/App.js'; import { concat } from '@/misc/prelude/array.js'; @@ -170,6 +171,9 @@ export class NoteCreateService implements OnApplicationShutdown { @Inject(DI.notesRepository) private notesRepository: NotesRepository, + @Inject(DI.latestNotesRepository) + private latestNotesRepository: LatestNotesRepository, + @Inject(DI.mutingsRepository) private mutingsRepository: MutingsRepository, @@ -514,6 +518,8 @@ export class NoteCreateService implements OnApplicationShutdown { await this.notesRepository.insert(insert); } + await this.updateLatestNote(insert); + return insert; } catch (e) { // duplicate key error @@ -1125,4 +1131,21 @@ export class NoteCreateService implements OnApplicationShutdown { public onApplicationShutdown(signal?: string | undefined): void { this.dispose(); } + + private async updateLatestNote(note: MiNote) { + // Ignore DMs + if (note.visibility === 'specified') return; + + // Make sure that this isn't an *older* post. + // We can get older posts through replies, lookups, etc. + const currentLatest = await this.latestNotesRepository.findOneBy({ userId: note.userId }); + if (currentLatest != null && currentLatest.userId >= note.id) return; + + // Record this as the latest note for the given user + const latestNote = new LatestNote({ + userId: note.userId, + noteId: note.id, + }); + await this.latestNotesRepository.upsert(latestNote, ['userId']); + } } diff --git a/packages/backend/src/core/NoteDeleteService.ts b/packages/backend/src/core/NoteDeleteService.ts index 7ce6d7c605..898e164966 100644 --- a/packages/backend/src/core/NoteDeleteService.ts +++ b/packages/backend/src/core/NoteDeleteService.ts @@ -3,11 +3,12 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Brackets, In } from 'typeorm'; +import { Brackets, In, 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 type { InstancesRepository, NotesRepository, UsersRepository } from '@/models/_.js'; +import { LatestNote } from '@/models/LatestNote.js'; +import type { InstancesRepository, LatestNotesRepository, NotesRepository, UsersRepository } from '@/models/_.js'; import { RelayService } from '@/core/RelayService.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; import { DI } from '@/di-symbols.js'; @@ -38,6 +39,9 @@ export class NoteDeleteService { @Inject(DI.notesRepository) private notesRepository: NotesRepository, + @Inject(DI.latestNotesRepository) + private latestNotesRepository: LatestNotesRepository, + @Inject(DI.instancesRepository) private instancesRepository: InstancesRepository, @@ -148,6 +152,8 @@ export class NoteDeleteService { userId: user.id, }); + await this.updateLatestNote(note); + if (deleter && (note.userId !== deleter.id)) { const user = await this.usersRepository.findOneByOrFail({ id: note.userId }); this.moderationLogService.log(deleter, 'deleteNote', { @@ -229,4 +235,35 @@ export class NoteDeleteService { this.apDeliverManagerService.deliverToUser(user, content, remoteUser); } } + + private async updateLatestNote(note: MiNote) { + // If it's a DM, then it can't possibly be the latest note so we can safely skip this. + if (note.visibility === 'specified') return; + + // Find the newest remaining note for the user + const nextLatest = await this.notesRepository + .createQueryBuilder() + .select() + .where({ + userId: note.userId, + visibility: Not('specified'), + }) + .orderBy({ id: 'DESC' }) + .getOne(); + if (!nextLatest) return; + + // Record it as the latest + const latestNote = new LatestNote({ + userId: note.userId, + noteId: nextLatest.id, + }); + + // We use an upsert because this deleted note might not have been the newest. + // In that case, the latest note may already be populated for this user. + // We want postgres to do nothing instead of replacing the value or returning an error. + await this.latestNotesRepository.upsert(latestNote, { + conflictPaths: ['userId'], + skipUpdateIfNoValuesChanged: true, + }); + } } -- cgit v1.2.3-freya From acc0c7867fcc44650f9ada06286f35a103ceec1e Mon Sep 17 00:00:00 2001 From: Hazel K Date: Mon, 30 Sep 2024 13:29:15 -0400 Subject: exclude boosts from featured timeline --- packages/backend/src/core/NoteCreateService.ts | 4 ++++ packages/backend/src/core/NoteDeleteService.ts | 15 +++++++++++++-- 2 files changed, 17 insertions(+), 2 deletions(-) (limited to 'packages/backend/src/core/NoteCreateService.ts') diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 17631eea89..0af65b81b1 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -63,6 +63,7 @@ import { isReply } from '@/misc/is-reply.js'; import { trackPromise } from '@/misc/promise-tracker.js'; import { isUserRelated } from '@/misc/is-user-related.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; +import { isQuote, isRenote } from '@/misc/is-renote.js'; type NotificationType = 'reply' | 'renote' | 'quote' | 'mention'; @@ -1136,6 +1137,9 @@ export class NoteCreateService implements OnApplicationShutdown { // Ignore DMs if (note.visibility === 'specified') return; + // Ignore pure renotes + if (isRenote(note) && !isQuote(note)) return; + // Make sure that this isn't an *older* post. // We can get older posts through replies, lookups, etc. const currentLatest = await this.latestNotesRepository.findOneBy({ userId: note.userId }); diff --git a/packages/backend/src/core/NoteDeleteService.ts b/packages/backend/src/core/NoteDeleteService.ts index 898e164966..de753a3aa2 100644 --- a/packages/backend/src/core/NoteDeleteService.ts +++ b/packages/backend/src/core/NoteDeleteService.ts @@ -240,14 +240,25 @@ export class NoteDeleteService { // If it's a DM, then it can't possibly be the latest note so we can safely skip this. if (note.visibility === 'specified') return; - // Find the newest remaining note for the user + // Find the newest remaining note for the user. + // We exclude DMs and pure renotes. const nextLatest = await this.notesRepository - .createQueryBuilder() + .createQueryBuilder('note') .select() .where({ userId: note.userId, visibility: Not('specified'), }) + .andWhere(` + ( + note."renoteId" IS NULL + OR note.text IS NOT NULL + OR note.cw IS NOT NULL + OR note."replyId" IS NOT NULL + OR note."hasPoll" + OR note."fileIds" != '{}' + ) + `) .orderBy({ id: 'DESC' }) .getOne(); if (!nextLatest) return; -- cgit v1.2.3-freya From ef7cde6bc6a2159f0fd041d26a3cb77cb0d53be9 Mon Sep 17 00:00:00 2001 From: Hazel K Date: Wed, 2 Oct 2024 11:38:21 -0400 Subject: fixes from peer review --- packages/backend/src/core/NoteCreateService.ts | 5 +++-- packages/backend/src/core/NoteDeleteService.ts | 20 +++++++++++++------- .../frontend/src/components/FollowingFeedEntry.vue | 2 +- packages/frontend/src/navbar.ts | 2 +- packages/frontend/src/pages/following-feed.vue | 22 +++++++++++----------- packages/frontend/src/pages/timeline.vue | 2 +- packages/frontend/vite.replaceIcons.ts | 1 - 7 files changed, 30 insertions(+), 24 deletions(-) (limited to 'packages/backend/src/core/NoteCreateService.ts') diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 0af65b81b1..daf0894cfd 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -1134,7 +1134,8 @@ export class NoteCreateService implements OnApplicationShutdown { } private async updateLatestNote(note: MiNote) { - // Ignore DMs + // Ignore DMs. + // Followers-only posts are *included*, as this table is used to back the "following" feed. if (note.visibility === 'specified') return; // Ignore pure renotes @@ -1143,7 +1144,7 @@ export class NoteCreateService implements OnApplicationShutdown { // Make sure that this isn't an *older* post. // We can get older posts through replies, lookups, etc. const currentLatest = await this.latestNotesRepository.findOneBy({ userId: note.userId }); - if (currentLatest != null && currentLatest.userId >= note.id) return; + if (currentLatest != null && currentLatest.noteId >= note.id) return; // Record this as the latest note for the given user const latestNote = new LatestNote({ diff --git a/packages/backend/src/core/NoteDeleteService.ts b/packages/backend/src/core/NoteDeleteService.ts index de753a3aa2..3f86f41942 100644 --- a/packages/backend/src/core/NoteDeleteService.ts +++ b/packages/backend/src/core/NoteDeleteService.ts @@ -240,6 +240,10 @@ export class NoteDeleteService { // If it's a DM, then it can't possibly be the latest note so we can safely skip this. if (note.visibility === 'specified') return; + // Check if the deleted note was possibly the latest for the user + const hasLatestNote = await this.latestNotesRepository.existsBy({ userId: note.userId }); + if (hasLatestNote) return; + // Find the newest remaining note for the user. // We exclude DMs and pure renotes. const nextLatest = await this.notesRepository @@ -269,12 +273,14 @@ export class NoteDeleteService { noteId: nextLatest.id, }); - // We use an upsert because this deleted note might not have been the newest. - // In that case, the latest note may already be populated for this user. - // We want postgres to do nothing instead of replacing the value or returning an error. - await this.latestNotesRepository.upsert(latestNote, { - conflictPaths: ['userId'], - skipUpdateIfNoValuesChanged: true, - }); + // When inserting the latest note, it's possible that another worker has "raced" the insert and already added a newer note. + // We must use orIgnore() to ensure that the query ignores conflicts, otherwise an exception may be thrown. + await this.latestNotesRepository + .createQueryBuilder('latest') + .insert() + .into(LatestNote) + .values(latestNote) + .orIgnore() + .execute(); } } diff --git a/packages/frontend/src/components/FollowingFeedEntry.vue b/packages/frontend/src/components/FollowingFeedEntry.vue index 29434de021..7f5abaa9cc 100644 --- a/packages/frontend/src/components/FollowingFeedEntry.vue +++ b/packages/frontend/src/components/FollowingFeedEntry.vue @@ -18,7 +18,7 @@ SPDX-License-Identifier: AGPL-3.0-only
- +
diff --git a/packages/frontend/src/navbar.ts b/packages/frontend/src/navbar.ts index 2d67a29a24..b6385b5ad2 100644 --- a/packages/frontend/src/navbar.ts +++ b/packages/frontend/src/navbar.ts @@ -70,7 +70,7 @@ export const navbarItemDef = reactive({ }, following: { title: i18n.ts.following, - icon: 'ti ti-user-check', + icon: 'ph-user-check ph-bold ph-lg', to: '/following-feed', }, lists: { diff --git a/packages/frontend/src/pages/following-feed.vue b/packages/frontend/src/pages/following-feed.vue index 56f722e9d3..9a78cbdadf 100644 --- a/packages/frontend/src/pages/following-feed.vue +++ b/packages/frontend/src/pages/following-feed.vue @@ -26,7 +26,7 @@ SPDX-License-Identifier: AGPL-3.0-only - +
@@ -62,7 +62,7 @@ import FollowingFeedEntry from '@/components/FollowingFeedEntry.vue'; import MkNotes from '@/components/MkNotes.vue'; import MkUserInfo from '@/components/MkUserInfo.vue'; import { misskeyApi } from '@/scripts/misskey-api.js'; -import {useRouter} from "@/router/supplier.js"; +import { useRouter } from '@/router/supplier.js'; const props = withDefaults(defineProps<{ initialTab?: FollowingFeedTab, @@ -79,17 +79,17 @@ const currentTab: Ref = ref(props.initialTab); const mutualsOnly: Ref = computed(() => currentTab.value === mutualsTab); // We have to disable the per-user feed on small displays, and it must be done through JS instead of CSS. -// Otherwise, the second column will resources in the background. -const desktopMediaQuery = window.matchMedia('(min-width: 750px)'); -const isDesktop: Ref = ref(desktopMediaQuery.matches); -desktopMediaQuery.addEventListener('change', () => isDesktop.value = desktopMediaQuery.matches); +// Otherwise, the second column will waste resources in the background. +const wideViewportQuery = window.matchMedia('(min-width: 750px)'); +const isWideViewport: Ref = ref(wideViewportQuery.matches); +wideViewportQuery.addEventListener('change', () => isWideViewport.value = wideViewportQuery.matches); const selectedUserError: Ref = ref(''); const selectedUserId: Ref = ref(''); const selectedUser: Ref = ref(null); async function userSelected(user: Misskey.entities.UserLite): Promise { - if (isDesktop.value) { + if (isWideViewport.value) { await showUserNotes(user.id); } else { if (user.host) { @@ -139,7 +139,7 @@ async function onListReady(): Promise { // This just gets the first user ID const selectedNote: Misskey.entities.Note = latestNotesPaging.value.items.values().next().value; - // Wait for 1 second to match the animation effects. + // Wait for 1 second to match the animation effects in MkHorizontalSwipe, MkPullToRefresh, and MkPagination. // Otherwise, the page appears to load "backwards". await new Promise(resolve => setTimeout(resolve, 1000)); await showUserNotes(selectedNote.userId); @@ -179,19 +179,19 @@ const headerActions: PageHeaderItem[] = [ const headerTabs = computed(() => [ { key: followingTab, - icon: 'ti ti-user-check', + icon: 'ph-user-check ph-bold ph-lg', title: i18n.ts.following, } satisfies Tab, { key: mutualsTab, - icon: 'ti ti-user-heart', + icon: 'ph-user-switch ph-bold ph-lg', title: i18n.ts.mutuals, } satisfies Tab, ]); definePageMetadata(() => ({ title: i18n.ts.following, - icon: 'ti ti-user-check', + icon: 'ph-user-check ph-bold ph-lg', })); diff --git a/packages/frontend/src/pages/timeline.vue b/packages/frontend/src/pages/timeline.vue index 7dc63a887a..55e453b38a 100644 --- a/packages/frontend/src/pages/timeline.vue +++ b/packages/frontend/src/pages/timeline.vue @@ -312,7 +312,7 @@ const headerTabs = computed(() => [...(defaultStore.reactiveState.pinnedUserList icon: basicTimelineIconClass(tl), iconOnly: true, })), { - icon: 'ti ti-user-check', + icon: 'ph-user-check ph-bold ph-lg', title: i18n.ts.following, iconOnly: true, onClick: () => router.push('/following-feed'), diff --git a/packages/frontend/vite.replaceIcons.ts b/packages/frontend/vite.replaceIcons.ts index 1be76f07c7..0282d3202b 100644 --- a/packages/frontend/vite.replaceIcons.ts +++ b/packages/frontend/vite.replaceIcons.ts @@ -348,7 +348,6 @@ export function pluginReplaceIcons() { 'ti ti-user-circle': 'ph-user-circle ph-bold ph-lg', 'ti ti-user-edit': 'ph-user-list ph-bold ph-lg', 'ti ti-user-exclamation': 'ph-warning-circle ph-bold ph-lg', - 'ti ti-user-heart': 'ph-user-switch ph-bold ph-lg', 'ti ti-user-off': 'ph-user-minus ph-bold ph-lg', 'ti ti-user-plus': 'ph-user-plus ph-bold ph-lg', 'ti ti-user-search': 'ph-user-circle ph-bold ph-lg', -- cgit v1.2.3-freya From 3842a1ee8c32d136b97b2a0b2b7c0f8f1e733fe7 Mon Sep 17 00:00:00 2001 From: Hazel K Date: Thu, 3 Oct 2024 20:05:15 -0400 Subject: fix length validation --- packages/backend/src/core/NoteCreateService.ts | 12 ++++++++++++ packages/backend/src/core/NoteEditService.ts | 12 ++++++++++++ packages/backend/src/server/api/endpoints/notes/create.ts | 3 ++- packages/backend/src/server/api/endpoints/notes/edit.ts | 4 +++- 4 files changed, 29 insertions(+), 2 deletions(-) (limited to 'packages/backend/src/core/NoteCreateService.ts') diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index c252336f99..beaa75e737 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -346,6 +346,18 @@ export class NoteCreateService implements OnApplicationShutdown { data.text = null; } + if (data.cw) { + if (data.cw.length > DB_MAX_NOTE_TEXT_LENGTH) { + data.cw = data.cw.slice(0, DB_MAX_NOTE_TEXT_LENGTH); + } + data.cw = data.cw.trim(); + if (data.cw === '') { + data.cw = null; + } + } else { + data.cw = null; + } + let tags = data.apHashtags; let emojis = data.apEmojis; let mentionedUsers = data.apMentions; diff --git a/packages/backend/src/core/NoteEditService.ts b/packages/backend/src/core/NoteEditService.ts index f42d14b466..aecd37aeb9 100644 --- a/packages/backend/src/core/NoteEditService.ts +++ b/packages/backend/src/core/NoteEditService.ts @@ -371,6 +371,18 @@ export class NoteEditService implements OnApplicationShutdown { data.text = null; } + if (data.cw) { + if (data.cw.length > DB_MAX_NOTE_TEXT_LENGTH) { + data.cw = data.cw.slice(0, DB_MAX_NOTE_TEXT_LENGTH); + } + data.cw = data.cw.trim(); + if (data.cw === '') { + data.cw = null; + } + } else { + data.cw = null; + } + let tags = data.apHashtags; let emojis = data.apEmojis; let mentionedUsers = data.apMentions; diff --git a/packages/backend/src/server/api/endpoints/notes/create.ts b/packages/backend/src/server/api/endpoints/notes/create.ts index 626f03b758..a4c2e28129 100644 --- a/packages/backend/src/server/api/endpoints/notes/create.ts +++ b/packages/backend/src/server/api/endpoints/notes/create.ts @@ -252,7 +252,8 @@ export default class extends Endpoint { // eslint- private noteCreateService: NoteCreateService, ) { super(meta, paramDef, async (ps, me) => { - if (ps.text && (ps.text.length > this.config.maxNoteLength)) { + const contentLength = (ps.text?.length ?? 0) + (ps.cw?.length ?? 0); + if (contentLength > this.config.maxNoteLength) { throw new ApiError(meta.errors.maxLength); } diff --git a/packages/backend/src/server/api/endpoints/notes/edit.ts b/packages/backend/src/server/api/endpoints/notes/edit.ts index 835cbc14fa..b9be145caf 100644 --- a/packages/backend/src/server/api/endpoints/notes/edit.ts +++ b/packages/backend/src/server/api/endpoints/notes/edit.ts @@ -297,9 +297,11 @@ export default class extends Endpoint { // eslint- private noteEditService: NoteEditService, ) { super(meta, paramDef, async (ps, me) => { - if (ps.text && (ps.text.length > this.config.maxNoteLength)) { + const contentLength = (ps.text?.length ?? 0) + (ps.cw?.length ?? 0); + if (contentLength > this.config.maxNoteLength) { throw new ApiError(meta.errors.maxLength); } + let visibleUsers: MiUser[] = []; if (ps.visibleUserIds) { visibleUsers = await this.usersRepository.findBy({ -- cgit v1.2.3-freya From fb9b6b120839b132def959662ff9248ecdd07d87 Mon Sep 17 00:00:00 2001 From: dakkar Date: Fri, 11 Oct 2024 12:20:08 +0100 Subject: thank you linters --- packages/backend/src/core/NoteCreateService.ts | 2 +- packages/backend/src/core/NoteEditService.ts | 2 +- packages/backend/src/core/WebhookTestService.ts | 19 +++++++++++++++++++ .../src/core/entities/NotificationEntityService.ts | 4 ++-- packages/backend/src/models/json-schema/user.ts | 4 ++++ .../src/server/api/endpoints/notes/search-by-tag.ts | 6 ++++-- .../src/server/api/stream/channels/bubble-timeline.ts | 9 ++++----- .../backend/src/server/web/ClientServerService.ts | 2 +- 8 files changed, 36 insertions(+), 12 deletions(-) (limited to 'packages/backend/src/core/NoteCreateService.ts') diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 17325d62b5..29f7dd917d 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -576,7 +576,7 @@ export class NoteCreateService implements OnApplicationShutdown { // ハッシュタグ更新 if (data.visibility === 'public' || data.visibility === 'home') { - if (user.isBot && meta.enableBotTrending) { + if (user.isBot && this.meta.enableBotTrending) { this.hashtagService.updateHashtags(user, tags); } else if (!user.isBot) { this.hashtagService.updateHashtags(user, tags); diff --git a/packages/backend/src/core/NoteEditService.ts b/packages/backend/src/core/NoteEditService.ts index 1f861fd868..48c68777e3 100644 --- a/packages/backend/src/core/NoteEditService.ts +++ b/packages/backend/src/core/NoteEditService.ts @@ -13,7 +13,7 @@ import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mf import { extractHashtags } from '@/misc/extract-hashtags.js'; import type { IMentionedRemoteUsers } from '@/models/Note.js'; import { MiNote } from '@/models/Note.js'; -import type { NoteEditRepository, ChannelFollowingsRepository, ChannelsRepository, FollowingsRepository, InstancesRepository, MiFollowing, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserListMembershipsRepository, UserProfilesRepository, UsersRepository, PollsRepository } from '@/models/_.js'; +import type { NoteEditRepository, ChannelFollowingsRepository, ChannelsRepository, FollowingsRepository, InstancesRepository, MiFollowing, MiMeta, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserListMembershipsRepository, UserProfilesRepository, UsersRepository, PollsRepository } from '@/models/_.js'; import type { MiDriveFile } from '@/models/DriveFile.js'; import type { MiApp } from '@/models/App.js'; import { concat } from '@/misc/prelude/array.js'; diff --git a/packages/backend/src/core/WebhookTestService.ts b/packages/backend/src/core/WebhookTestService.ts index c2764f30e8..a41f41c44d 100644 --- a/packages/backend/src/core/WebhookTestService.ts +++ b/packages/backend/src/core/WebhookTestService.ts @@ -53,16 +53,22 @@ function generateDummyUser(override?: Partial): MiUser { avatar: null, bannerId: null, banner: null, + backgroundId: null, + background: null, avatarUrl: null, bannerUrl: null, + backgroundUrl: null, avatarBlurhash: null, bannerBlurhash: null, + backgroundBlurhash: null, avatarDecorations: [], tags: [], isSuspended: false, isLocked: false, + isSilenced: false, isBot: false, isCat: true, + speakAsCat: true, isRoot: false, isExplorable: true, isHibernated: false, @@ -76,6 +82,9 @@ function generateDummyUser(override?: Partial): MiUser { uri: null, followersUri: null, token: null, + approved: true, + signupReason: null, + noindex: false, ...override, }; } @@ -118,6 +127,7 @@ function generateDummyNote(override?: Partial): MiNote { replyUserHost: null, renoteUserId: null, renoteUserHost: null, + updatedAt: null, ...override, }; } @@ -182,9 +192,15 @@ function toPackedUserLite(user: MiUser, override?: Packed<'UserLite'>): Packed<' })), isBot: user.isBot, isCat: user.isCat, + speakAsCat: user.speakAsCat, emojis: user.emojis, onlineStatus: 'active', badgeRoles: [], + noindex: user.noindex, + isModerator: false, + isAdmin: false, + isSystem: false, + isSilenced: user.isSilenced, ...override, }; } @@ -201,6 +217,8 @@ function toPackedUserDetailedNotMe(user: MiUser, override?: Packed<'UserDetailed lastFetchedAt: user.lastFetchedAt?.toISOString() ?? null, bannerUrl: user.bannerUrl, bannerBlurhash: user.bannerBlurhash, + backgroundUrl: user.backgroundUrl, + backgroundBlurhash: user.backgroundBlurhash, isLocked: user.isLocked, isSilenced: false, isSuspended: user.isSuspended, @@ -236,6 +254,7 @@ function toPackedUserDetailedNotMe(user: MiUser, override?: Packed<'UserDetailed isRenoteMuted: false, notify: 'none', withReplies: true, + ListenBrainz: null, ...override, }; } diff --git a/packages/backend/src/core/entities/NotificationEntityService.ts b/packages/backend/src/core/entities/NotificationEntityService.ts index 8bfa2cc623..bbaf0cb7c8 100644 --- a/packages/backend/src/core/entities/NotificationEntityService.ts +++ b/packages/backend/src/core/entities/NotificationEntityService.ts @@ -59,7 +59,7 @@ export class NotificationEntityService implements OnModuleInit { async #packInternal ( src: T, meId: MiUser['id'], - + options: { checkValidNotifier?: boolean; }, @@ -236,7 +236,7 @@ export class NotificationEntityService implements OnModuleInit { public async pack( src: MiNotification | MiGroupedNotification, meId: MiUser['id'], - + options: { checkValidNotifier?: boolean; }, diff --git a/packages/backend/src/models/json-schema/user.ts b/packages/backend/src/models/json-schema/user.ts index fa93b019bd..9ed9ef828e 100644 --- a/packages/backend/src/models/json-schema/user.ts +++ b/packages/backend/src/models/json-schema/user.ts @@ -142,6 +142,10 @@ export const packedUserLiteSchema = { type: 'boolean', nullable: false, optional: true, }, + isSilenced: { + type: 'boolean', + nullable: false, optional: false, + }, instance: { type: 'object', nullable: false, optional: true, diff --git a/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts b/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts index e01b09fb57..f079ab608d 100644 --- a/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts +++ b/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts @@ -155,8 +155,10 @@ export default class extends Endpoint { // eslint- notes = notes.filter(note => { if (note.user?.isSilenced && me && followings && note.userId !== me.id && !followings[note.userId]) return false; if (note.user?.isSuspended) return false; - if (this.utilityService.isFederationAllowedHost(note.userHost)) return false; - if (this.utilityService.isSilencedHost(this.serverSettings.silencedHosts, note.userHost)) return false; + if (note.userHost) { + if (this.utilityService.isFederationAllowedHost(note.userHost)) return false; + if (this.utilityService.isSilencedHost(this.serverSettings.silencedHosts, note.userHost)) return false; + } return true; }); diff --git a/packages/backend/src/server/api/stream/channels/bubble-timeline.ts b/packages/backend/src/server/api/stream/channels/bubble-timeline.ts index 48c8535b83..8693f0c6ac 100644 --- a/packages/backend/src/server/api/stream/channels/bubble-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/bubble-timeline.ts @@ -5,7 +5,6 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; -import type { MiMeta } from '@/models/_.js'; import type { Packed } from '@/misc/json-schema.js'; import { MetaService } from '@/core/MetaService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; @@ -23,11 +22,10 @@ class BubbleTimelineChannel extends Channel { private withRenotes: boolean; private withFiles: boolean; private withBots: boolean; + private instance: MiMeta; constructor( - @Inject(DI.meta) - private serverSettings: MiMeta, - + private metaService: MetaService, private roleService: RoleService, private noteEntityService: NoteEntityService, @@ -46,6 +44,7 @@ class BubbleTimelineChannel extends Channel { this.withRenotes = !!(params.withRenotes ?? true); this.withFiles = !!(params.withFiles ?? false); this.withBots = !!(params.withBots ?? true); + this.instance = await this.metaService.fetch(); // Subscribe events this.subscriber.on('notesStream', this.onNote); @@ -56,7 +55,7 @@ class BubbleTimelineChannel extends Channel { if (this.withFiles && (note.fileIds == null || note.fileIds.length === 0)) return; if (!this.withBots && note.user.isBot) return; - if (!(note.user.host != null && this.serverSettings.bubbleInstances.includes(note.user.host) && note.visibility === 'public' )) return; + if (!(note.user.host != null && this.instance.bubbleInstances.includes(note.user.host) && note.visibility === 'public' )) return; if (note.channelId != null) return; diff --git a/packages/backend/src/server/web/ClientServerService.ts b/packages/backend/src/server/web/ClientServerService.ts index 24ab8b23df..4b760db89d 100644 --- a/packages/backend/src/server/web/ClientServerService.ts +++ b/packages/backend/src/server/web/ClientServerService.ts @@ -160,7 +160,7 @@ export class ClientServerService { }, { // 空文字列の場合右辺を使いたいため // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - 'src': instance.app512IconUrl || '/static-assets/icons/512.png', + 'src': this.meta.app512IconUrl || '/static-assets/icons/512.png', 'sizes': '300x300', 'type': 'image/png', 'purpose': 'any', -- cgit v1.2.3-freya From fea993f6b2e6b8d34f0ed07cf1a565c3d725e7e6 Mon Sep 17 00:00:00 2001 From: Hazel K Date: Tue, 8 Oct 2024 16:37:44 -0400 Subject: correct name of `SkLatestNote` --- packages/backend/src/core/NoteCreateService.ts | 4 ++-- packages/backend/src/core/NoteDeleteService.ts | 6 +++--- packages/backend/src/models/LatestNote.ts | 4 ++-- packages/backend/src/models/RepositoryModule.ts | 4 ++-- packages/backend/src/models/_.ts | 6 +++--- packages/backend/src/postgres.ts | 4 ++-- packages/backend/src/server/api/endpoints/notes/following.ts | 4 ++-- 7 files changed, 16 insertions(+), 16 deletions(-) (limited to 'packages/backend/src/core/NoteCreateService.ts') diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index ef0047ca90..03701c33e5 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -14,7 +14,7 @@ import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mf import { extractHashtags } from '@/misc/extract-hashtags.js'; import type { IMentionedRemoteUsers } from '@/models/Note.js'; import { MiNote } from '@/models/Note.js'; -import { LatestNote } from '@/models/LatestNote.js'; +import { SkLatestNote } from '@/models/LatestNote.js'; import type { ChannelFollowingsRepository, ChannelsRepository, FollowingsRepository, InstancesRepository, LatestNotesRepository, MiFollowing, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserListMembershipsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js'; import type { MiDriveFile } from '@/models/DriveFile.js'; import type { MiApp } from '@/models/App.js'; @@ -1159,7 +1159,7 @@ export class NoteCreateService implements OnApplicationShutdown { if (currentLatest != null && currentLatest.noteId >= note.id) return; // Record this as the latest note for the given user - const latestNote = new LatestNote({ + const latestNote = new SkLatestNote({ userId: note.userId, noteId: note.id, }); diff --git a/packages/backend/src/core/NoteDeleteService.ts b/packages/backend/src/core/NoteDeleteService.ts index 3f86f41942..b81e7f6471 100644 --- a/packages/backend/src/core/NoteDeleteService.ts +++ b/packages/backend/src/core/NoteDeleteService.ts @@ -7,7 +7,7 @@ import { Brackets, In, 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 { LatestNote } from '@/models/LatestNote.js'; +import { SkLatestNote } from '@/models/LatestNote.js'; import type { InstancesRepository, LatestNotesRepository, NotesRepository, UsersRepository } from '@/models/_.js'; import { RelayService } from '@/core/RelayService.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; @@ -268,7 +268,7 @@ export class NoteDeleteService { if (!nextLatest) return; // Record it as the latest - const latestNote = new LatestNote({ + const latestNote = new SkLatestNote({ userId: note.userId, noteId: nextLatest.id, }); @@ -278,7 +278,7 @@ export class NoteDeleteService { await this.latestNotesRepository .createQueryBuilder('latest') .insert() - .into(LatestNote) + .into(SkLatestNote) .values(latestNote) .orIgnore() .execute(); diff --git a/packages/backend/src/models/LatestNote.ts b/packages/backend/src/models/LatestNote.ts index 1163ff3bc0..d1c96adae2 100644 --- a/packages/backend/src/models/LatestNote.ts +++ b/packages/backend/src/models/LatestNote.ts @@ -13,7 +13,7 @@ import { MiNote } from '@/models/Note.js'; * DMs are not counted. */ @Entity('latest_note') -export class LatestNote { +export class SkLatestNote { @PrimaryColumn({ name: 'user_id', type: 'varchar' as const, @@ -44,7 +44,7 @@ export class LatestNote { }) public note: MiNote | null; - constructor(data?: Partial) { + constructor(data?: Partial) { if (!data) return; for (const [k, v] of Object.entries(data)) { diff --git a/packages/backend/src/models/RepositoryModule.ts b/packages/backend/src/models/RepositoryModule.ts index f44334d84e..eb45b9a631 100644 --- a/packages/backend/src/models/RepositoryModule.ts +++ b/packages/backend/src/models/RepositoryModule.ts @@ -7,7 +7,7 @@ import type { Provider } from '@nestjs/common'; import { Module } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; import { - LatestNote, + SkLatestNote, MiAbuseReportNotificationRecipient, MiAbuseUserReport, MiAccessToken, @@ -121,7 +121,7 @@ const $avatarDecorationsRepository: Provider = { const $latestNotesRepository: Provider = { provide: DI.latestNotesRepository, - useFactory: (db: DataSource) => db.getRepository(LatestNote).extend(miRepository as MiRepository), + useFactory: (db: DataSource) => db.getRepository(SkLatestNote).extend(miRepository as MiRepository), inject: [DI.db], }; diff --git a/packages/backend/src/models/_.ts b/packages/backend/src/models/_.ts index 9e01f4b6d7..ac2dd62aa2 100644 --- a/packages/backend/src/models/_.ts +++ b/packages/backend/src/models/_.ts @@ -10,7 +10,7 @@ import { RelationIdLoader } from 'typeorm/query-builder/relation-id/RelationIdLo import { RawSqlResultsToEntityTransformer } from 'typeorm/query-builder/transformer/RawSqlResultsToEntityTransformer.js'; import { ObjectUtils } from 'typeorm/util/ObjectUtils.js'; import { OrmUtils } from 'typeorm/util/OrmUtils.js'; -import { LatestNote } from '@/models/LatestNote.js'; +import { SkLatestNote } from '@/models/LatestNote.js'; import { MiAbuseUserReport } from '@/models/AbuseUserReport.js'; import { MiAbuseReportNotificationRecipient } from '@/models/AbuseReportNotificationRecipient.js'; import { MiAccessToken } from '@/models/AccessToken.js'; @@ -127,7 +127,7 @@ export const miRepository = { } satisfies MiRepository; export { - LatestNote, + SkLatestNote, MiAbuseUserReport, MiAbuseReportNotificationRecipient, MiAccessToken, @@ -226,7 +226,7 @@ export type GalleryPostsRepository = Repository & MiRepository & MiRepository; export type InstancesRepository = Repository & MiRepository; export type MetasRepository = Repository & MiRepository; -export type LatestNotesRepository = Repository & MiRepository; +export type LatestNotesRepository = Repository & MiRepository; export type ModerationLogsRepository = Repository & MiRepository; export type MutingsRepository = Repository & MiRepository; export type RenoteMutingsRepository = Repository & MiRepository; diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts index 0d17b3d046..2d66e6e445 100644 --- a/packages/backend/src/postgres.ts +++ b/packages/backend/src/postgres.ts @@ -83,7 +83,7 @@ import { MiReversiGame } from '@/models/ReversiGame.js'; import { Config } from '@/config.js'; import MisskeyLogger from '@/logger.js'; import { bindThis } from '@/decorators.js'; -import { LatestNote } from '@/models/LatestNote.js'; +import { SkLatestNote } from '@/models/LatestNote.js'; pg.types.setTypeParser(20, Number); @@ -131,7 +131,7 @@ class MyCustomLogger implements Logger { } export const entities = [ - LatestNote, + SkLatestNote, MiAnnouncement, MiAnnouncementRead, MiMeta, diff --git a/packages/backend/src/server/api/endpoints/notes/following.ts b/packages/backend/src/server/api/endpoints/notes/following.ts index 436160f250..1d9ce9704e 100644 --- a/packages/backend/src/server/api/endpoints/notes/following.ts +++ b/packages/backend/src/server/api/endpoints/notes/following.ts @@ -4,7 +4,7 @@ */ import { Inject, Injectable } from '@nestjs/common'; -import { LatestNote, MiFollowing } from '@/models/_.js'; +import { SkLatestNote, MiFollowing } from '@/models/_.js'; import type { NotesRepository } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; @@ -57,7 +57,7 @@ export default class extends Endpoint { // eslint- .setParameter('me', me.id) // Limit to latest notes - .innerJoin(LatestNote, 'latest', 'note.id = latest.note_id') + .innerJoin(SkLatestNote, 'latest', 'note.id = latest.note_id') // Avoid N+1 queries from the "pack" method .innerJoinAndSelect('note.user', 'user') -- cgit v1.2.3-freya From 463b9ac59def86dd1b9065cbe7382325c1e5824e Mon Sep 17 00:00:00 2001 From: Hazel K Date: Wed, 9 Oct 2024 15:09:55 -0400 Subject: add filters for following feed --- locales/en-US.yml | 3 + locales/index.d.ts | 12 +++ locales/ja-JP.yml | 3 + .../1728420772835-track-latest-note-type.js | 8 +- packages/backend/src/core/NoteCreateService.ts | 13 +-- packages/backend/src/core/NoteDeleteService.ts | 26 ++++-- packages/backend/src/misc/is-renote.ts | 15 ++++ packages/backend/src/models/LatestNote.ts | 13 +++ packages/backend/src/postgres.ts | 10 ++- .../src/server/api/endpoints/notes/following.ts | 21 +++++ .../src/server/api/endpoints/users/notes.ts | 50 +++++++++++- packages/backend/test/unit/misc/is-renote.ts | 23 +++++- packages/backend/test/unit/models/LatestNote.ts | 66 ++++++++++++++++ .../frontend/src/components/SkUserRecentNotes.vue | 21 ++--- packages/frontend/src/pages/following-feed.vue | 92 +++++++++++----------- packages/misskey-js/src/autogen/types.ts | 14 ++++ 16 files changed, 318 insertions(+), 72 deletions(-) create mode 100644 packages/backend/test/unit/models/LatestNote.ts (limited to 'packages/backend/src/core/NoteCreateService.ts') diff --git a/locales/en-US.yml b/locales/en-US.yml index 2fb4700fcf..215519d153 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -1263,6 +1263,9 @@ authentication: "Authentication" authenticationRequiredToContinue: "Please authenticate to continue" dateAndTime: "Timestamp" showRenotes: "Show boosts" +showQuotes: "Show quotes" +showReplies: "Show replies" +showNonPublicNotes: "Show non-public" edited: "Edited" notificationRecieveConfig: "Notification Settings" mutualFollow: "Mutual follow" diff --git a/locales/index.d.ts b/locales/index.d.ts index 6d6ee68c1c..e89165066a 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -5065,6 +5065,18 @@ export interface Locale extends ILocale { * ブーストを表示 */ "showRenotes": string; + /** + * Show quotes + */ + "showQuotes": string; + /** + * Show replies + */ + "showReplies": string; + /** + * Show non-public + */ + "showNonPublicNotes": string; /** * 編集済み */ diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index f2c3d67133..957c49f367 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1262,6 +1262,9 @@ authentication: "認証" authenticationRequiredToContinue: "続けるには認証を行ってください" dateAndTime: "日時" showRenotes: "ブーストを表示" +showQuotes: "Show quotes" +showReplies: "Show replies" +showNonPublicNotes: "Show non-public" edited: "編集済み" notificationRecieveConfig: "通知の受信設定" mutualFollow: "相互フォロー" diff --git a/packages/backend/migration/1728420772835-track-latest-note-type.js b/packages/backend/migration/1728420772835-track-latest-note-type.js index cef379c7f3..8f8198707e 100644 --- a/packages/backend/migration/1728420772835-track-latest-note-type.js +++ b/packages/backend/migration/1728420772835-track-latest-note-type.js @@ -11,14 +11,14 @@ export class TrackLatestNoteType1728420772835 { await queryRunner.query(`ALTER TABLE "latest_note" ADD "isPublic" boolean NOT NULL DEFAULT false`); await queryRunner.query(`ALTER TABLE "latest_note" ADD "isReply" boolean NOT NULL DEFAULT false`); await queryRunner.query(`ALTER TABLE "latest_note" ADD "isQuote" boolean NOT NULL DEFAULT false`); - await queryRunner.query(`ALTER TABLE "latest_note" ADD CONSTRAINT "PK_a44ac8ca9cb916faeefc0912abd" PRIMARY KEY ("user_id", "isPublic", "isReply", "isQuote")`); + await queryRunner.query(`ALTER TABLE "latest_note" ADD CONSTRAINT "PK_a44ac8ca9cb916faeefc0912abd" PRIMARY KEY ("user_id", is_public, is_reply, is_quote)`); } async down(queryRunner) { await queryRunner.query(`ALTER TABLE "latest_note" DROP CONSTRAINT "PK_a44ac8ca9cb916faeefc0912abd"`); - await queryRunner.query(`ALTER TABLE "latest_note" DROP COLUMN "isQuote"`); - await queryRunner.query(`ALTER TABLE "latest_note" DROP COLUMN "isReply"`); - await queryRunner.query(`ALTER TABLE "latest_note" DROP COLUMN "isPublic"`); + await queryRunner.query(`ALTER TABLE "latest_note" DROP COLUMN is_quote`); + await queryRunner.query(`ALTER TABLE "latest_note" DROP COLUMN is_reply`); + await queryRunner.query(`ALTER TABLE "latest_note" DROP COLUMN is_public`); await queryRunner.query(`ALTER TABLE "latest_note" ADD CONSTRAINT "PK_f619b62bfaafabe68f52fb50c9a" PRIMARY KEY ("user_id")`); } } diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 03701c33e5..cbc9dcaf8f 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -63,7 +63,7 @@ import { isReply } from '@/misc/is-reply.js'; import { trackPromise } from '@/misc/promise-tracker.js'; import { isUserRelated } from '@/misc/is-user-related.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; -import { isQuote, isRenote } from '@/misc/is-renote.js'; +import { isPureRenote } from '@/misc/is-renote.js'; type NotificationType = 'reply' | 'renote' | 'quote' | 'mention'; @@ -1151,18 +1151,21 @@ export class NoteCreateService implements OnApplicationShutdown { if (note.visibility === 'specified') return; // Ignore pure renotes - if (isRenote(note) && !isQuote(note)) return; + if (isPureRenote(note)) return; + + // Compute the compound key of the entry to check + const key = SkLatestNote.keyFor(note); // Make sure that this isn't an *older* post. // We can get older posts through replies, lookups, etc. - const currentLatest = await this.latestNotesRepository.findOneBy({ userId: note.userId }); + const currentLatest = await this.latestNotesRepository.findOneBy(key); if (currentLatest != null && currentLatest.noteId >= note.id) return; // Record this as the latest note for the given user const latestNote = new SkLatestNote({ - userId: note.userId, + ...key, noteId: note.id, }); - await this.latestNotesRepository.upsert(latestNote, ['userId']); + await this.latestNotesRepository.upsert(latestNote, ['userId', 'isPublic', 'isReply', 'isQuote']); } } diff --git a/packages/backend/src/core/NoteDeleteService.ts b/packages/backend/src/core/NoteDeleteService.ts index b81e7f6471..fa77caabd1 100644 --- a/packages/backend/src/core/NoteDeleteService.ts +++ b/packages/backend/src/core/NoteDeleteService.ts @@ -6,7 +6,7 @@ import { Brackets, In, 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, IMentionedRemoteUsers } from '@/models/Note.js'; import { SkLatestNote } from '@/models/LatestNote.js'; import type { InstancesRepository, LatestNotesRepository, NotesRepository, UsersRepository } from '@/models/_.js'; import { RelayService } from '@/core/RelayService.js'; @@ -25,7 +25,7 @@ import { bindThis } from '@/decorators.js'; import { MetaService } from '@/core/MetaService.js'; import { SearchService } from '@/core/SearchService.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; -import { isQuote, isRenote } from '@/misc/is-renote.js'; +import { isPureRenote, isQuote, isRenote } from '@/misc/is-renote.js'; @Injectable() export class NoteDeleteService { @@ -240,8 +240,14 @@ export class NoteDeleteService { // If it's a DM, then it can't possibly be the latest note so we can safely skip this. if (note.visibility === 'specified') return; + // If it's a pure renote, then it can't possibly be the latest note so we can safely skip this. + if (isPureRenote(note)) return; + + // Compute the compound key of the entry to check + const key = SkLatestNote.keyFor(note); + // Check if the deleted note was possibly the latest for the user - const hasLatestNote = await this.latestNotesRepository.existsBy({ userId: note.userId }); + const hasLatestNote = await this.latestNotesRepository.existsBy(key); if (hasLatestNote) return; // Find the newest remaining note for the user. @@ -250,8 +256,16 @@ export class NoteDeleteService { .createQueryBuilder('note') .select() .where({ - userId: note.userId, - visibility: Not('specified'), + userId: key.userId, + visibility: key.isPublic + ? 'public' + : Not('specified'), + replyId: key.isReply + ? Not(null) + : null, + renoteId: key.isQuote + ? Not(null) + : null, }) .andWhere(` ( @@ -269,7 +283,7 @@ export class NoteDeleteService { // Record it as the latest const latestNote = new SkLatestNote({ - userId: note.userId, + ...key, noteId: nextLatest.id, }); diff --git a/packages/backend/src/misc/is-renote.ts b/packages/backend/src/misc/is-renote.ts index 48f821806c..c128fded14 100644 --- a/packages/backend/src/misc/is-renote.ts +++ b/packages/backend/src/misc/is-renote.ts @@ -23,6 +23,17 @@ type Quote = hasPoll: true }); +type PureRenote = + Renote & { + text: null, + cw: null, + replyId: null, + hasPoll: false, + fileIds: { + length: 0, + }, + }; + export function isRenote(note: MiNote): note is Renote { return note.renoteId != null; } @@ -36,6 +47,10 @@ export function isQuote(note: Renote): note is Quote { note.fileIds.length > 0; } +export function isPureRenote(note: MiNote): note is PureRenote { + return isRenote(note) && !isQuote(note); +} + type PackedRenote = Packed<'Note'> & { renoteId: NonNullable['renoteId']> diff --git a/packages/backend/src/models/LatestNote.ts b/packages/backend/src/models/LatestNote.ts index f7b0ca6a23..d36a4d568a 100644 --- a/packages/backend/src/models/LatestNote.ts +++ b/packages/backend/src/models/LatestNote.ts @@ -6,6 +6,7 @@ import { PrimaryColumn, Entity, JoinColumn, Column, ManyToOne } from 'typeorm'; import { MiUser } from '@/models/User.js'; import { MiNote } from '@/models/Note.js'; +import { isQuote, isRenote } from '@/misc/is-renote.js'; /** * Maps a user to the most recent post by that user. @@ -69,4 +70,16 @@ export class SkLatestNote { (this as Record)[k] = v; } } + + /** + * Generates a compound key matching a provided note. + */ + static keyFor(note: MiNote) { + return { + userId: note.userId, + isPublic: note.visibility === 'public', + isReply: note.replyId != null, + isQuote: isRenote(note) && isQuote(note), + }; + } } diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts index 2d66e6e445..eaa0eac57c 100644 --- a/packages/backend/src/postgres.ts +++ b/packages/backend/src/postgres.ts @@ -92,6 +92,8 @@ export const dbLogger = new MisskeyLogger('db'); const sqlLogger = dbLogger.createSubLogger('sql', 'gray'); class MyCustomLogger implements Logger { + private readonly isDevelopment = process.env.NODE_ENV === 'development'; + @bindThis private highlight(sql: string) { return highlight.highlight(sql, { @@ -101,7 +103,13 @@ class MyCustomLogger implements Logger { @bindThis public logQuery(query: string, parameters?: any[]) { - sqlLogger.info(this.highlight(query).substring(0, 100)); + let message = this.highlight(query); + + if (!this.isDevelopment) { + message = message.substring(0, 100); + } + + sqlLogger.info(message); } @bindThis diff --git a/packages/backend/src/server/api/endpoints/notes/following.ts b/packages/backend/src/server/api/endpoints/notes/following.ts index 56e0fcd03c..9606c0f19e 100644 --- a/packages/backend/src/server/api/endpoints/notes/following.ts +++ b/packages/backend/src/server/api/endpoints/notes/following.ts @@ -33,6 +33,11 @@ export const paramDef = { type: 'object', properties: { mutualsOnly: { type: 'boolean', default: false }, + filesOnly: { type: 'boolean', default: false }, + includeNonPublic: { type: 'boolean', default: true }, + includeReplies: { type: 'boolean', default: false }, + includeQuotes: { type: 'boolean', default: true }, + limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, @@ -76,6 +81,22 @@ export default class extends Endpoint { // eslint- query.innerJoin(MiFollowing, 'mutuals', 'latest.user_id = mutuals."followerId" AND mutuals."followeeId" = :me'); } + // Limit to files, if requested + if (ps.filesOnly) { + query.andWhere('note."fileIds" != \'{}\''); + } + + // Match selected note types. + if (!ps.includeNonPublic) { + query.andWhere('latest.is_public'); + } + if (!ps.includeReplies) { + query.andWhere('latest.is_reply = false'); + } + if (!ps.includeQuotes) { + query.andWhere('latest.is_quote = false'); + } + // Respect blocks and mutes this.queryService.generateBlockedUserQuery(query, me); this.queryService.generateMutedUserQuery(query, me); diff --git a/packages/backend/src/server/api/endpoints/users/notes.ts b/packages/backend/src/server/api/endpoints/users/notes.ts index cc76c12f1d..884760a88f 100644 --- a/packages/backend/src/server/api/endpoints/users/notes.ts +++ b/packages/backend/src/server/api/endpoints/users/notes.ts @@ -17,6 +17,7 @@ import { MiLocalUser } from '@/models/User.js'; import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js'; import { FanoutTimelineName } from '@/core/FanoutTimelineService.js'; import { ApiError } from '@/server/api/error.js'; +import { isQuote, isRenote } from '@/misc/is-renote.js'; export const meta = { tags: ['users', 'notes'], @@ -51,7 +52,10 @@ export const paramDef = { properties: { userId: { type: 'string', format: 'misskey:id' }, withReplies: { type: 'boolean', default: false }, + withRepliesToSelf: { type: 'boolean', default: true }, + withQuotes: { type: 'boolean', default: true }, withRenotes: { type: 'boolean', default: true }, + withNonPublic: { type: 'boolean', default: true }, withChannelNotes: { type: 'boolean', default: false }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, sinceId: { type: 'string', format: 'misskey:id' }, @@ -103,6 +107,10 @@ export default class extends Endpoint { // eslint- withChannelNotes: ps.withChannelNotes, withFiles: ps.withFiles, withRenotes: ps.withRenotes, + withQuotes: ps.withQuotes, + withNonPublic: ps.withNonPublic, + withRepliesToOthers: ps.withReplies, + withRepliesToSelf: ps.withRepliesToSelf, }, me); return await this.noteEntityService.packMany(timeline, me); @@ -132,6 +140,11 @@ export default class extends Endpoint { // eslint- if (note.visibility === 'specified' && (!me || (me.id !== note.userId && !note.visibleUserIds.some(v => v === me.id)))) return false; if (note.visibility === 'followers' && !isFollowing && !isSelf) return false; + // These are handled by DB fallback, but we duplicate them here in case a timeline was already populated with notes + if (!ps.withRepliesToSelf && note.reply?.userId === note.userId) return false; + if (!ps.withQuotes && isRenote(note) && isQuote(note)) return false; + if (!ps.withNonPublic && note.visibility !== 'public') return false; + return true; }, dbFallback: async (untilId, sinceId, limit) => await this.getFromDb({ @@ -142,6 +155,10 @@ export default class extends Endpoint { // eslint- withChannelNotes: ps.withChannelNotes, withFiles: ps.withFiles, withRenotes: ps.withRenotes, + withQuotes: ps.withQuotes, + withNonPublic: ps.withNonPublic, + withRepliesToOthers: ps.withReplies, + withRepliesToSelf: ps.withRepliesToSelf, }, me), }); @@ -157,6 +174,10 @@ export default class extends Endpoint { // eslint- withChannelNotes: boolean, withFiles: boolean, withRenotes: boolean, + withQuotes: boolean, + withNonPublic: boolean, + withRepliesToOthers: boolean, + withRepliesToSelf: boolean, }, me: MiLocalUser | null) { const isSelf = me && (me.id === ps.userId); @@ -188,7 +209,9 @@ export default class extends Endpoint { // eslint- query.andWhere('note.fileIds != \'{}\''); } - if (ps.withRenotes === false) { + if (!ps.withRenotes && !ps.withQuotes) { + query.andWhere('note.renoteId IS NULL'); + } else if (!ps.withRenotes) { query.andWhere(new Brackets(qb => { qb.orWhere('note.userId != :userId', { userId: ps.userId }); qb.orWhere('note.renoteId IS NULL'); @@ -196,6 +219,31 @@ export default class extends Endpoint { // eslint- qb.orWhere('note.fileIds != \'{}\''); qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); })); + } else if (!ps.withQuotes) { + query.andWhere(` + ( + note."renoteId" IS NULL + OR ( + note.text IS NULL + AND note.cw IS NULL + AND note."replyId" IS NULL + AND note."hasPoll" IS FALSE + AND note."fileIds" = '{}' + ) + ) + `); + } + + if (!ps.withRepliesToOthers && !ps.withRepliesToSelf) { + query.andWhere('reply.id IS NULL'); + } else if (!ps.withRepliesToOthers) { + query.andWhere('(reply.id IS NULL OR reply."userId" = note."userId")'); + } else if (!ps.withRepliesToSelf) { + query.andWhere('(reply.id IS NULL OR reply."userId" != note."userId")'); + } + + if (!ps.withNonPublic) { + query.andWhere('note.visibility = \'public\''); } return await query.limit(ps.limit).getMany(); diff --git a/packages/backend/test/unit/misc/is-renote.ts b/packages/backend/test/unit/misc/is-renote.ts index 080271e404..4da00bcf25 100644 --- a/packages/backend/test/unit/misc/is-renote.ts +++ b/packages/backend/test/unit/misc/is-renote.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { isQuote, isRenote } from '@/misc/is-renote.js'; +import { isPureRenote, isQuote, isRenote } from '@/misc/is-renote.js'; import { MiNote } from '@/models/Note.js'; const base: MiNote = { @@ -86,4 +86,25 @@ describe('misc:is-renote', () => { expect(isRenote(note)).toBe(true); expect(isQuote(note as any)).toBe(true); }); + + describe('isPureRenote', () => { + it('should return true when note is pure renote', () => { + const note = new MiNote({ renoteId: 'abc123' }); + const result = isPureRenote(note); + expect(result).toBeTruthy(); + }); + + it('should return false when note is quote', () => { + const note = new MiNote({ renoteId: 'abc123', text: 'text' }); + const result = isPureRenote(note); + expect(result).toBeFalsy(); + + }); + + it('should return false when note is not renote', () => { + const note = new MiNote({ renoteId: null }); + const result = isPureRenote(note); + expect(result).toBeFalsy(); + }); + }); }); diff --git a/packages/backend/test/unit/models/LatestNote.ts b/packages/backend/test/unit/models/LatestNote.ts new file mode 100644 index 0000000000..f1ea8c95d2 --- /dev/null +++ b/packages/backend/test/unit/models/LatestNote.ts @@ -0,0 +1,66 @@ +import { SkLatestNote } from '@/models/LatestNote.js'; +import { MiNote } from '@/models/Note.js'; + +describe(SkLatestNote, () => { + describe('keyFor', () => { + it('should include userId', () => { + const note = new MiNote({ userId: 'abc123' }); + const key = SkLatestNote.keyFor(note); + expect(key.userId).toBe(note.userId); + }); + + it('should include isPublic when is public', () => { + const note = new MiNote({ visibility: 'public' }); + const key = SkLatestNote.keyFor(note); + expect(key.isPublic).toBeTruthy(); + }); + + it('should include isPublic when is home-only', () => { + const note = new MiNote({ visibility: 'home' }); + const key = SkLatestNote.keyFor(note); + expect(key.isPublic).toBeFalsy(); + }); + + it('should include isPublic when is followers-only', () => { + const note = new MiNote({ visibility: 'followers' }); + const key = SkLatestNote.keyFor(note); + expect(key.isPublic).toBeFalsy(); + }); + + it('should include isPublic when is specified', () => { + const note = new MiNote({ visibility: 'specified' }); + const key = SkLatestNote.keyFor(note); + expect(key.isPublic).toBeFalsy(); + }); + + it('should include isReply when is reply', () => { + const note = new MiNote({ replyId: 'abc123' }); + const key = SkLatestNote.keyFor(note); + expect(key.isReply).toBeTruthy(); + }); + + it('should include isReply when is not reply', () => { + const note = new MiNote({ replyId: null }); + const key = SkLatestNote.keyFor(note); + expect(key.isReply).toBeFalsy(); + }); + + it('should include isQuote when is quote', () => { + const note = new MiNote({ renoteId: 'abc123', text: 'text' }); + const key = SkLatestNote.keyFor(note); + expect(key.isQuote).toBeTruthy(); + }); + + it('should include isQuote when is reblog', () => { + const note = new MiNote({ renoteId: 'abc123' }); + const key = SkLatestNote.keyFor(note); + expect(key.isQuote).toBeFalsy(); + }); + + it('should include isQuote when is neither quote nor reblog', () => { + const note = new MiNote({ renoteId: null }); + const key = SkLatestNote.keyFor(note); + expect(key.isQuote).toBeFalsy(); + }); + }); +}); diff --git a/packages/frontend/src/components/SkUserRecentNotes.vue b/packages/frontend/src/components/SkUserRecentNotes.vue index 1d124b4932..31580075ef 100644 --- a/packages/frontend/src/components/SkUserRecentNotes.vue +++ b/packages/frontend/src/components/SkUserRecentNotes.vue @@ -24,16 +24,13 @@ import MkPullToRefresh from '@/components/MkPullToRefresh.vue'; import { Paging } from '@/components/MkPagination.vue'; import { misskeyApi } from '@/scripts/misskey-api.js'; -const props = withDefaults(defineProps<{ +const props = defineProps<{ userId: string; - withRenotes?: boolean; - withReplies?: boolean; - onlyFiles?: boolean; -}>(), { - withRenotes: false, - withReplies: true, - onlyFiles: false, -}); + withNonPublic: boolean; + withQuotes: boolean; + withReplies: boolean; + onlyFiles: boolean; +}>(); const loadError: Ref = ref(null); const user: Ref = ref(null); @@ -43,9 +40,13 @@ const pagination: Paging<'users/notes'> = { limit: 10, params: computed(() => ({ userId: props.userId, - withRenotes: props.withRenotes, + withNonPublic: props.withNonPublic, + withRenotes: false, + withQuotes: props.withQuotes, withReplies: props.withReplies, + withRepliesToSelf: props.withReplies, withFiles: props.onlyFiles, + allowPartial: true, })), }; diff --git a/packages/frontend/src/pages/following-feed.vue b/packages/frontend/src/pages/following-feed.vue index 9050cd93f8..f460086ff0 100644 --- a/packages/frontend/src/pages/following-feed.vue +++ b/packages/frontend/src/pages/following-feed.vue @@ -30,7 +30,7 @@ SPDX-License-Identifier: AGPL-3.0-only
- +
@@ -162,54 +162,58 @@ const latestNotesPagination: Paging<'notes/following'> = { limit: 20, params: computed(() => ({ mutualsOnly: mutualsOnly.value, + filesOnly: onlyFiles.value, + includeNonPublic: withNonPublic.value, + includeReplies: withReplies.value, + includeQuotes: withQuotes.value, })), }; -const withUserRenotes = ref(false); -const withUserReplies = ref(true); -const withOnlyFiles = ref(false); +const withNonPublic = ref(false); +const withQuotes = ref(false); +const withReplies = ref(false); +const onlyFiles = ref(false); -const headerActions = computed(() => { - const actions: PageHeaderItem[] = [ - { - icon: 'ti ti-refresh', - text: i18n.ts.reload, - handler: () => reload(), +const headerActions: PageHeaderItem[] = [ + { + icon: 'ti ti-refresh', + text: i18n.ts.reload, + handler: () => reload(), + }, + { + icon: 'ti ti-dots', + text: i18n.ts.options, + handler: (ev) => { + os.popupMenu([ + { + type: 'switch', + text: i18n.ts.showNonPublicNotes, + ref: withNonPublic, + }, + { + type: 'switch', + text: i18n.ts.showQuotes, + ref: withQuotes, + }, + { + type: 'switch', + text: i18n.ts.showReplies, + ref: withReplies, + disabled: onlyFiles, + }, + { + type: 'divider', + }, + { + type: 'switch', + text: i18n.ts.fileAttachedOnly, + ref: onlyFiles, + disabled: withReplies, + }, + ], ev.currentTarget ?? ev.target); }, - ]; - - if (isWideViewport.value) { - actions.push({ - icon: 'ti ti-dots', - text: i18n.ts.options, - handler: (ev) => { - os.popupMenu([ - { - type: 'switch', - text: i18n.ts.showRenotes, - ref: withUserRenotes, - }, { - type: 'switch', - text: i18n.ts.showRepliesToOthersInTimeline, - ref: withUserReplies, - disabled: withOnlyFiles, - }, - { - type: 'divider', - }, - { - type: 'switch', - text: i18n.ts.fileAttachedOnly, - ref: withOnlyFiles, - disabled: withUserReplies, - }, - ], ev.currentTarget ?? ev.target); - }, - }); - } - - return actions; -}); + }, +]; const headerTabs = computed(() => [ { diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index 4bebaf8d9a..cedf0cad7d 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -22296,6 +22296,14 @@ export type operations = { 'application/json': { /** @default false */ mutualsOnly?: boolean; + /** @default false */ + filesOnly?: boolean; + /** @default true */ + includeNonPublic?: boolean; + /** @default false */ + includeReplies?: boolean; + /** @default true */ + includeQuotes?: boolean; /** @default 10 */ limit?: number; /** Format: misskey:id */ @@ -27228,7 +27236,13 @@ export type operations = { /** @default false */ withReplies?: boolean; /** @default true */ + withRepliesToSelf?: boolean; + /** @default true */ + withQuotes?: boolean; + /** @default true */ withRenotes?: boolean; + /** @default true */ + withNonPublic?: boolean; /** @default false */ withChannelNotes?: boolean; /** @default 10 */ -- cgit v1.2.3-freya From c55af9c3b334dd5f81b03805e1e72e0824f8589b Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Sun, 13 Oct 2024 10:31:41 -0400 Subject: update latest note in background (don't await the result) --- packages/backend/src/core/NoteCreateService.ts | 13 ++++++++++--- packages/backend/src/core/NoteDeleteService.ts | 8 +++++++- 2 files changed, 17 insertions(+), 4 deletions(-) (limited to 'packages/backend/src/core/NoteCreateService.ts') diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index cbc9dcaf8f..55c55589ad 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -531,8 +531,6 @@ export class NoteCreateService implements OnApplicationShutdown { await this.notesRepository.insert(insert); } - await this.updateLatestNote(insert); - return insert; } catch (e) { // duplicate key error @@ -815,6 +813,9 @@ export class NoteCreateService implements OnApplicationShutdown { }); } + // Update the Latest Note index / following feed + this.updateLatestNoteBG(note); + // Register to search database if (!user.noindex) this.index(note); } @@ -1145,7 +1146,13 @@ export class NoteCreateService implements OnApplicationShutdown { this.dispose(); } - private async updateLatestNote(note: MiNote) { + private updateLatestNoteBG(note: MiNote): void { + this + .updateLatestNote(note) + .catch(err => console.error('Unhandled exception while updating latest_note (after create):', err)); + } + + private async updateLatestNote(note: MiNote): Promise { // Ignore DMs. // Followers-only posts are *included*, as this table is used to back the "following" feed. if (note.visibility === 'specified') return; diff --git a/packages/backend/src/core/NoteDeleteService.ts b/packages/backend/src/core/NoteDeleteService.ts index fa77caabd1..3f1eef39c9 100644 --- a/packages/backend/src/core/NoteDeleteService.ts +++ b/packages/backend/src/core/NoteDeleteService.ts @@ -152,7 +152,7 @@ export class NoteDeleteService { userId: user.id, }); - await this.updateLatestNote(note); + this.updateLatestNoteBG(note); if (deleter && (note.userId !== deleter.id)) { const user = await this.usersRepository.findOneByOrFail({ id: note.userId }); @@ -236,6 +236,12 @@ export class NoteDeleteService { } } + private updateLatestNoteBG(note: MiNote): void { + this + .updateLatestNote(note) + .catch(err => console.error('Unhandled exception while updating latest_note (after delete):', err)); + } + private async updateLatestNote(note: MiNote) { // If it's a DM, then it can't possibly be the latest note so we can safely skip this. if (note.visibility === 'specified') return; -- cgit v1.2.3-freya From 93cf2f9045e4af485a29690313b7d74b0f54cab6 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Sun, 13 Oct 2024 18:11:16 -0400 Subject: factor out latest_note logic into LatestNoteService --- packages/backend/src/core/CoreModule.ts | 6 ++ packages/backend/src/core/LatestNoteService.ts | 139 +++++++++++++++++++++++++ packages/backend/src/core/NoteCreateService.ts | 41 +------- packages/backend/src/core/NoteDeleteService.ts | 84 ++------------- packages/backend/src/models/LatestNote.ts | 12 +++ 5 files changed, 167 insertions(+), 115 deletions(-) create mode 100644 packages/backend/src/core/LatestNoteService.ts (limited to 'packages/backend/src/core/NoteCreateService.ts') diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index 049d858189..1d807d2aa0 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -42,6 +42,7 @@ import { ModerationLogService } from './ModerationLogService.js'; import { NoteCreateService } from './NoteCreateService.js'; import { NoteEditService } from './NoteEditService.js'; import { NoteDeleteService } from './NoteDeleteService.js'; +import { LatestNoteService } from './LatestNoteService.js'; import { NotePiningService } from './NotePiningService.js'; import { NoteReadService } from './NoteReadService.js'; import { NotificationService } from './NotificationService.js'; @@ -185,6 +186,7 @@ const $ModerationLogService: Provider = { provide: 'ModerationLogService', useEx const $NoteCreateService: Provider = { provide: 'NoteCreateService', useExisting: NoteCreateService }; const $NoteEditService: Provider = { provide: 'NoteEditService', useExisting: NoteEditService }; const $NoteDeleteService: Provider = { provide: 'NoteDeleteService', useExisting: NoteDeleteService }; +const $LatestNoteService: Provider = { provide: 'LatestNoteService', useExisting: LatestNoteService }; const $NotePiningService: Provider = { provide: 'NotePiningService', useExisting: NotePiningService }; const $NoteReadService: Provider = { provide: 'NoteReadService', useExisting: NoteReadService }; const $NotificationService: Provider = { provide: 'NotificationService', useExisting: NotificationService }; @@ -335,6 +337,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp NoteCreateService, NoteEditService, NoteDeleteService, + LatestNoteService, NotePiningService, NoteReadService, NotificationService, @@ -481,6 +484,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp $NoteCreateService, $NoteEditService, $NoteDeleteService, + $LatestNoteService, $NotePiningService, $NoteReadService, $NotificationService, @@ -628,6 +632,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp NoteCreateService, NoteEditService, NoteDeleteService, + LatestNoteService, NotePiningService, NoteReadService, NotificationService, @@ -773,6 +778,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp $NoteCreateService, $NoteEditService, $NoteDeleteService, + $LatestNoteService, $NotePiningService, $NoteReadService, $NotificationService, diff --git a/packages/backend/src/core/LatestNoteService.ts b/packages/backend/src/core/LatestNoteService.ts new file mode 100644 index 0000000000..c379805506 --- /dev/null +++ b/packages/backend/src/core/LatestNoteService.ts @@ -0,0 +1,139 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Not } from 'typeorm'; +import { MiNote } from '@/models/Note.js'; +import { isPureRenote } from '@/misc/is-renote.js'; +import { SkLatestNote } from '@/models/LatestNote.js'; +import { DI } from '@/di-symbols.js'; +import type { LatestNotesRepository, NotesRepository } from '@/models/_.js'; +import { LoggerService } from '@/core/LoggerService.js'; +import Logger from '@/logger.js'; + +@Injectable() +export class LatestNoteService { + private readonly logger: Logger; + + constructor( + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + @Inject(DI.latestNotesRepository) + private latestNotesRepository: LatestNotesRepository, + + loggerService: LoggerService, + ) { + this.logger = loggerService.getLogger('LatestNoteService'); + } + + handleUpdatedNoteBG(before: MiNote, after: MiNote): void { + this + .handleUpdatedNote(before, after) + .catch(err => this.logger.error('Unhandled exception while updating latest_note (after update):', err)); + } + + async handleUpdatedNote(before: MiNote, after: MiNote): Promise { + // If the key didn't change, then there's nothing to update + if (SkLatestNote.areEquivalent(before, after)) return; + + // Simulate update as delete + create + await this.handleDeletedNote(before); + await this.handleCreatedNote(after); + } + + handleCreatedNoteBG(note: MiNote): void { + this + .handleCreatedNote(note) + .catch(err => this.logger.error('Unhandled exception while updating latest_note (after create):', err)); + } + + async handleCreatedNote(note: MiNote): Promise { + // Ignore DMs. + // Followers-only posts are *included*, as this table is used to back the "following" feed. + if (note.visibility === 'specified') return; + + // Ignore pure renotes + if (isPureRenote(note)) return; + + // Compute the compound key of the entry to check + const key = SkLatestNote.keyFor(note); + + // Make sure that this isn't an *older* post. + // We can get older posts through replies, lookups, updates, etc. + const currentLatest = await this.latestNotesRepository.findOneBy(key); + if (currentLatest != null && currentLatest.noteId >= note.id) return; + + // Record this as the latest note for the given user + const latestNote = new SkLatestNote({ + ...key, + noteId: note.id, + }); + await this.latestNotesRepository.upsert(latestNote, ['userId', 'isPublic', 'isReply', 'isQuote']); + } + + handleDeletedNoteBG(note: MiNote): void { + this + .handleDeletedNote(note) + .catch(err => this.logger.error('Unhandled exception while updating latest_note (after delete):', err)); + } + + async handleDeletedNote(note: MiNote): Promise { + // If it's a DM, then it can't possibly be the latest note so we can safely skip this. + if (note.visibility === 'specified') return; + + // If it's a pure renote, then it can't possibly be the latest note so we can safely skip this. + if (isPureRenote(note)) return; + + // Compute the compound key of the entry to check + const key = SkLatestNote.keyFor(note); + + // Check if the deleted note was possibly the latest for the user + const existingLatest = await this.latestNotesRepository.findOneBy(key); + if (existingLatest == null || existingLatest.noteId !== note.id) return; + + // Find the newest remaining note for the user. + // We exclude DMs and pure renotes. + const nextLatest = await this.notesRepository + .createQueryBuilder('note') + .select() + .where({ + userId: key.userId, + visibility: key.isPublic + ? 'public' + : Not('specified'), + replyId: key.isReply + ? Not(null) + : null, + renoteId: key.isQuote + ? Not(null) + : null, + }) + .andWhere(` + ( + note."renoteId" IS NULL + OR note.text IS NOT NULL + OR note.cw IS NOT NULL + OR note."replyId" IS NOT NULL + OR note."hasPoll" + OR note."fileIds" != '{}' + ) + `) + .orderBy({ id: 'DESC' }) + .getOne(); + if (!nextLatest) return; + + // Record it as the latest + const latestNote = new SkLatestNote({ + ...key, + noteId: nextLatest.id, + }); + + // When inserting the latest note, it's possible that another worker has "raced" the insert and already added a newer note. + // We must use orIgnore() to ensure that the query ignores conflicts, otherwise an exception may be thrown. + await this.latestNotesRepository + .createQueryBuilder('latest') + .insert() + .into(SkLatestNote) + .values(latestNote) + .orIgnore() + .execute(); + } +} diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 55c55589ad..cd497a7d87 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -14,8 +14,7 @@ import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mf import { extractHashtags } from '@/misc/extract-hashtags.js'; import type { IMentionedRemoteUsers } from '@/models/Note.js'; import { MiNote } from '@/models/Note.js'; -import { SkLatestNote } from '@/models/LatestNote.js'; -import type { ChannelFollowingsRepository, ChannelsRepository, FollowingsRepository, InstancesRepository, LatestNotesRepository, MiFollowing, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserListMembershipsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js'; +import type { ChannelFollowingsRepository, ChannelsRepository, FollowingsRepository, InstancesRepository, MiFollowing, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserListMembershipsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js'; import type { MiDriveFile } from '@/models/DriveFile.js'; import type { MiApp } from '@/models/App.js'; import { concat } from '@/misc/prelude/array.js'; @@ -63,7 +62,7 @@ import { isReply } from '@/misc/is-reply.js'; import { trackPromise } from '@/misc/promise-tracker.js'; import { isUserRelated } from '@/misc/is-user-related.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; -import { isPureRenote } from '@/misc/is-renote.js'; +import { LatestNoteService } from '@/core/LatestNoteService.js'; type NotificationType = 'reply' | 'renote' | 'quote' | 'mention'; @@ -172,9 +171,6 @@ export class NoteCreateService implements OnApplicationShutdown { @Inject(DI.notesRepository) private notesRepository: NotesRepository, - @Inject(DI.latestNotesRepository) - private latestNotesRepository: LatestNotesRepository, - @Inject(DI.mutingsRepository) private mutingsRepository: MutingsRepository, @@ -226,6 +222,7 @@ export class NoteCreateService implements OnApplicationShutdown { private utilityService: UtilityService, private userBlockingService: UserBlockingService, private cacheService: CacheService, + private latestNoteService: LatestNoteService, ) { } @bindThis @@ -814,7 +811,7 @@ export class NoteCreateService implements OnApplicationShutdown { } // Update the Latest Note index / following feed - this.updateLatestNoteBG(note); + this.latestNoteService.handleCreatedNoteBG(note); // Register to search database if (!user.noindex) this.index(note); @@ -1145,34 +1142,4 @@ export class NoteCreateService implements OnApplicationShutdown { public onApplicationShutdown(signal?: string | undefined): void { this.dispose(); } - - private updateLatestNoteBG(note: MiNote): void { - this - .updateLatestNote(note) - .catch(err => console.error('Unhandled exception while updating latest_note (after create):', err)); - } - - private async updateLatestNote(note: MiNote): Promise { - // Ignore DMs. - // Followers-only posts are *included*, as this table is used to back the "following" feed. - if (note.visibility === 'specified') return; - - // Ignore pure renotes - if (isPureRenote(note)) return; - - // Compute the compound key of the entry to check - const key = SkLatestNote.keyFor(note); - - // Make sure that this isn't an *older* post. - // We can get older posts through replies, lookups, etc. - const currentLatest = await this.latestNotesRepository.findOneBy(key); - if (currentLatest != null && currentLatest.noteId >= note.id) return; - - // Record this as the latest note for the given user - const latestNote = new SkLatestNote({ - ...key, - noteId: note.id, - }); - await this.latestNotesRepository.upsert(latestNote, ['userId', 'isPublic', 'isReply', 'isQuote']); - } } diff --git a/packages/backend/src/core/NoteDeleteService.ts b/packages/backend/src/core/NoteDeleteService.ts index 3f1eef39c9..bdafad3f02 100644 --- a/packages/backend/src/core/NoteDeleteService.ts +++ b/packages/backend/src/core/NoteDeleteService.ts @@ -3,12 +3,11 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Brackets, In, Not } from 'typeorm'; +import { Brackets, In } from 'typeorm'; import { Injectable, Inject } from '@nestjs/common'; import type { MiUser, MiLocalUser, MiRemoteUser } from '@/models/User.js'; import { MiNote, IMentionedRemoteUsers } from '@/models/Note.js'; -import { SkLatestNote } from '@/models/LatestNote.js'; -import type { InstancesRepository, LatestNotesRepository, NotesRepository, UsersRepository } from '@/models/_.js'; +import type { InstancesRepository, NotesRepository, UsersRepository } from '@/models/_.js'; import { RelayService } from '@/core/RelayService.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; import { DI } from '@/di-symbols.js'; @@ -20,12 +19,12 @@ import { GlobalEventService } from '@/core/GlobalEventService.js'; import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; import { MetaService } from '@/core/MetaService.js'; import { SearchService } from '@/core/SearchService.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; -import { isPureRenote, isQuote, isRenote } from '@/misc/is-renote.js'; +import { isQuote, isRenote } from '@/misc/is-renote.js'; +import { LatestNoteService } from '@/core/LatestNoteService.js'; @Injectable() export class NoteDeleteService { @@ -39,14 +38,10 @@ export class NoteDeleteService { @Inject(DI.notesRepository) private notesRepository: NotesRepository, - @Inject(DI.latestNotesRepository) - private latestNotesRepository: LatestNotesRepository, - @Inject(DI.instancesRepository) private instancesRepository: InstancesRepository, private userEntityService: UserEntityService, - private noteEntityService: NoteEntityService, private globalEventService: GlobalEventService, private relayService: RelayService, private federatedInstanceService: FederatedInstanceService, @@ -58,6 +53,7 @@ export class NoteDeleteService { private notesChart: NotesChart, private perUserNotesChart: PerUserNotesChart, private instanceChart: InstanceChart, + private latestNoteService: LatestNoteService, ) {} /** @@ -152,7 +148,7 @@ export class NoteDeleteService { userId: user.id, }); - this.updateLatestNoteBG(note); + this.latestNoteService.handleDeletedNoteBG(note); if (deleter && (note.userId !== deleter.id)) { const user = await this.usersRepository.findOneByOrFail({ id: note.userId }); @@ -235,72 +231,4 @@ export class NoteDeleteService { this.apDeliverManagerService.deliverToUser(user, content, remoteUser); } } - - private updateLatestNoteBG(note: MiNote): void { - this - .updateLatestNote(note) - .catch(err => console.error('Unhandled exception while updating latest_note (after delete):', err)); - } - - private async updateLatestNote(note: MiNote) { - // If it's a DM, then it can't possibly be the latest note so we can safely skip this. - if (note.visibility === 'specified') return; - - // If it's a pure renote, then it can't possibly be the latest note so we can safely skip this. - if (isPureRenote(note)) return; - - // Compute the compound key of the entry to check - const key = SkLatestNote.keyFor(note); - - // Check if the deleted note was possibly the latest for the user - const hasLatestNote = await this.latestNotesRepository.existsBy(key); - if (hasLatestNote) return; - - // Find the newest remaining note for the user. - // We exclude DMs and pure renotes. - const nextLatest = await this.notesRepository - .createQueryBuilder('note') - .select() - .where({ - userId: key.userId, - visibility: key.isPublic - ? 'public' - : Not('specified'), - replyId: key.isReply - ? Not(null) - : null, - renoteId: key.isQuote - ? Not(null) - : null, - }) - .andWhere(` - ( - note."renoteId" IS NULL - OR note.text IS NOT NULL - OR note.cw IS NOT NULL - OR note."replyId" IS NOT NULL - OR note."hasPoll" - OR note."fileIds" != '{}' - ) - `) - .orderBy({ id: 'DESC' }) - .getOne(); - if (!nextLatest) return; - - // Record it as the latest - const latestNote = new SkLatestNote({ - ...key, - noteId: nextLatest.id, - }); - - // When inserting the latest note, it's possible that another worker has "raced" the insert and already added a newer note. - // We must use orIgnore() to ensure that the query ignores conflicts, otherwise an exception may be thrown. - await this.latestNotesRepository - .createQueryBuilder('latest') - .insert() - .into(SkLatestNote) - .values(latestNote) - .orIgnore() - .execute(); - } } diff --git a/packages/backend/src/models/LatestNote.ts b/packages/backend/src/models/LatestNote.ts index d36a4d568a..ff8815bd42 100644 --- a/packages/backend/src/models/LatestNote.ts +++ b/packages/backend/src/models/LatestNote.ts @@ -82,4 +82,16 @@ export class SkLatestNote { isQuote: isRenote(note) && isQuote(note), }; } + + /** + * Checks if two notes would produce equivalent compound keys. + */ + static areEquivalent(first: MiNote, second: MiNote): boolean { + return ( + first.userId === second.userId && + first.visibility === second.visibility && + (first.replyId != null) === (second.replyId != null) && + (isRenote(first) && isQuote(first)) === (isRenote(second) && isQuote(second)) + ); + } } -- cgit v1.2.3-freya From 9b063478825c381f36f5781a484b80ddc3ac4a04 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Tue, 15 Oct 2024 21:31:34 -0400 Subject: fix TS errors in NoteCreateService / NoteEditService --- packages/backend/src/core/NoteCreateService.ts | 1 - packages/backend/src/core/NoteEditService.ts | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) (limited to 'packages/backend/src/core/NoteCreateService.ts') diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 41c1e3f66f..2a38ed80b7 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -13,7 +13,6 @@ import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mf import { extractHashtags } from '@/misc/extract-hashtags.js'; import type { IMentionedRemoteUsers } from '@/models/Note.js'; import { MiNote } from '@/models/Note.js'; -import { LatestNote } from '@/models/LatestNote.js'; import type { ChannelFollowingsRepository, ChannelsRepository, FollowingsRepository, InstancesRepository, MiFollowing, MiMeta, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserListMembershipsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js'; import type { MiDriveFile } from '@/models/DriveFile.js'; import type { MiApp } from '@/models/App.js'; diff --git a/packages/backend/src/core/NoteEditService.ts b/packages/backend/src/core/NoteEditService.ts index 4c2b88f8dc..df45595da9 100644 --- a/packages/backend/src/core/NoteEditService.ts +++ b/packages/backend/src/core/NoteEditService.ts @@ -219,6 +219,7 @@ export class NoteEditService implements OnApplicationShutdown { private userBlockingService: UserBlockingService, private cacheService: CacheService, private latestNoteService: LatestNoteService, + private noteCreateService: NoteCreateService, ) { this.updateNotesCountQueue = new CollapsedQueue(60 * 1000 * 5, this.collapseNotesCount, this.performUpdateNotesCount); } -- cgit v1.2.3-freya From 560ee43dcf2b76cce4b69a449fcd8b9601b7d68d Mon Sep 17 00:00:00 2001 From: Hazel K Date: Mon, 7 Oct 2024 21:03:31 -0400 Subject: separate character limits for local and remote notes --- .config/ci.yml | 8 +++++++- .config/docker_example.yml | 8 +++++++- .config/example.yml | 8 +++++++- .../1728348353115-soft-limit-drive-comment.js | 16 +++++++++++++++ packages/backend/src/config.ts | 11 ++++++++++- packages/backend/src/const.ts | 19 ------------------ packages/backend/src/core/NoteCreateService.ts | 13 +++++++----- packages/backend/src/core/NoteEditService.ts | 13 +++++++----- .../src/core/activitypub/models/ApImageService.ts | 6 ++++-- .../backend/src/core/entities/MetaEntityService.ts | 3 +++ packages/backend/src/models/DriveFile.ts | 4 +--- packages/backend/src/models/json-schema/meta.ts | 12 +++++++++++ .../backend/src/server/NodeinfoServerService.ts | 3 +++ .../src/server/api/endpoints/drive/files/create.ts | 17 ++++++++++++++-- .../src/server/api/endpoints/drive/files/update.ts | 16 +++++++++++++-- .../api/endpoints/drive/files/upload-from-url.ts | 23 +++++++++++++++++++--- .../src/server/api/endpoints/notes/create.ts | 2 +- packages/backend/test/e2e/note.ts | 4 +++- packages/misskey-js/src/autogen/types.ts | 3 +++ 19 files changed, 142 insertions(+), 47 deletions(-) create mode 100644 packages/backend/migration/1728348353115-soft-limit-drive-comment.js (limited to 'packages/backend/src/core/NoteCreateService.ts') diff --git a/.config/ci.yml b/.config/ci.yml index 19ffe18d2c..ab6bbd5773 100644 --- a/.config/ci.yml +++ b/.config/ci.yml @@ -167,8 +167,14 @@ id: 'aidx' # IP address family used for outgoing request (ipv4, ipv6 or dual) #outgoingAddressFamily: ipv4 -# Amount of characters that can be used when writing notes (maximum: 100000, minimum: 1) +# Amount of characters that can be used when writing notes. Longer notes will be rejected. (minimum: 1) maxNoteLength: 3000 +# Amount of characters that will be saved for remote notes. Longer notes will be truncated to this length. (minimum: 1) +maxRemoteNoteLength: 100000 +# Amount of characters that can be used when writing media descriptions (alt text). Longer descriptions will be rejected. (minimum: 1) +maxAltTextLength: 20000 +# Amount of characters that will be saved for remote media descriptions (alt text). Longer descriptions will be truncated to this length. (minimum: 1) +maxRemoteAltTextLength: 100000 # Proxy for HTTP/HTTPS #proxy: http://127.0.0.1:3128 diff --git a/.config/docker_example.yml b/.config/docker_example.yml index 3a344e3089..8bd555dffc 100644 --- a/.config/docker_example.yml +++ b/.config/docker_example.yml @@ -250,8 +250,14 @@ id: 'aidx' # IP address family used for outgoing request (ipv4, ipv6 or dual) #outgoingAddressFamily: ipv4 -# Amount of characters that can be used when writing notes (maximum: 100000, minimum: 1) +# Amount of characters that can be used when writing notes. Longer notes will be rejected. (maximum: 100000, minimum: 1) maxNoteLength: 3000 +# Amount of characters that will be saved for remote notes. Longer notes will be truncated to this length. (maximum: 100000, minimum: 1) +maxRemoteNoteLength: 100000 +# Amount of characters that can be used when writing media descriptions (alt text). Longer descriptions will be rejected. (minimum: 1) +maxAltTextLength: 20000 +# Amount of characters that will be saved for remote media descriptions (alt text). Longer descriptions will be truncated to this length. (minimum: 1) +maxRemoteAltTextLength: 100000 # Proxy for HTTP/HTTPS #proxy: http://127.0.0.1:3128 diff --git a/.config/example.yml b/.config/example.yml index b9086479ea..6b80dab747 100644 --- a/.config/example.yml +++ b/.config/example.yml @@ -261,8 +261,14 @@ id: 'aidx' # IP address family used for outgoing request (ipv4, ipv6 or dual) #outgoingAddressFamily: ipv4 -# Amount of characters that can be used when writing notes (maximum: 100000, minimum: 1) +# Amount of characters that can be used when writing notes. Longer notes will be rejected. (maximum: 100000, minimum: 1) maxNoteLength: 3000 +# Amount of characters that will be saved for remote notes. Longer notes will be truncated to this length. (maximum: 100000, minimum: 1) +maxRemoteNoteLength: 100000 +# Amount of characters that can be used when writing media descriptions (alt text). Longer descriptions will be rejected. (minimum: 1) +maxAltTextLength: 20000 +# Amount of characters that will be saved for remote media descriptions (alt text). Longer descriptions will be truncated to this length. (minimum: 1) +maxRemoteAltTextLength: 100000 # Proxy for HTTP/HTTPS #proxy: http://127.0.0.1:3128 diff --git a/packages/backend/migration/1728348353115-soft-limit-drive-comment.js b/packages/backend/migration/1728348353115-soft-limit-drive-comment.js new file mode 100644 index 0000000000..4eb04432c2 --- /dev/null +++ b/packages/backend/migration/1728348353115-soft-limit-drive-comment.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class SoftLimitDriveComment1728348353115 { + name = 'SoftLimitDriveComment1728348353115' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "drive_file" ALTER COLUMN "comment" TYPE text`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "drive_file" ALTER COLUMN "comment" TYPE varchar(100000)`); + } +} diff --git a/packages/backend/src/config.ts b/packages/backend/src/config.ts index c9411326a9..19f1d6c066 100644 --- a/packages/backend/src/config.ts +++ b/packages/backend/src/config.ts @@ -73,6 +73,9 @@ type Source = { maxFileSize?: number; maxNoteLength?: number; + maxRemoteNoteLength?: number; + maxAltTextLength?: number; + maxRemoteAltTextLength?: number; clusterLimit?: number; @@ -149,6 +152,9 @@ export type Config = { allowedPrivateNetworks: string[] | undefined; maxFileSize: number; maxNoteLength: number; + maxRemoteNoteLength: number; + maxAltTextLength: number; + maxRemoteAltTextLength: number; clusterLimit: number | undefined; id: string; outgoingAddress: string | undefined; @@ -301,6 +307,9 @@ export function loadConfig(): Config { allowedPrivateNetworks: config.allowedPrivateNetworks, maxFileSize: config.maxFileSize ?? 262144000, maxNoteLength: config.maxNoteLength ?? 3000, + maxRemoteNoteLength: config.maxRemoteNoteLength ?? 100000, + maxAltTextLength: config.maxAltTextLength ?? 20000, + maxRemoteAltTextLength: config.maxRemoteAltTextLength ?? 100000, clusterLimit: config.clusterLimit, outgoingAddress: config.outgoingAddress, outgoingAddressFamily: config.outgoingAddressFamily, @@ -475,7 +484,7 @@ function applyEnvOverrides(config: Source) { _apply_top(['sentryForBackend', 'enableNodeProfiling']); _apply_top([['clusterLimit', 'deliverJobConcurrency', 'inboxJobConcurrency', 'relashionshipJobConcurrency', 'deliverJobPerSec', 'inboxJobPerSec', 'relashionshipJobPerSec', 'deliverJobMaxAttempts', 'inboxJobMaxAttempts']]); _apply_top([['outgoingAddress', 'outgoingAddressFamily', 'proxy', 'proxySmtp', 'mediaProxy', 'proxyRemoteFiles', 'videoThumbnailGenerator']]); - _apply_top([['maxFileSize', 'maxNoteLength', 'pidFile']]); + _apply_top([['maxFileSize', 'maxNoteLength', 'maxRemoteNoteLength', 'maxAltTextLength', 'maxRemoteAltTextLength', 'pidFile']]); _apply_top(['import', ['downloadTimeout', 'maxFileSize']]); _apply_top([['signToActivityPubGet', 'checkActivityPubGetSignature']]); } diff --git a/packages/backend/src/const.ts b/packages/backend/src/const.ts index 7cc22a5421..adb0a63ad7 100644 --- a/packages/backend/src/const.ts +++ b/packages/backend/src/const.ts @@ -3,30 +3,11 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -export const MAX_NOTE_TEXT_LENGTH = 3000; - export const USER_ONLINE_THRESHOLD = 1000 * 60 * 10; // 10min export const USER_ACTIVE_THRESHOLD = 1000 * 60 * 60 * 24 * 3; // 3days export const PER_NOTE_REACTION_USER_PAIR_CACHE_MAX = 16; -//#region hard limits -// If you change DB_* values, you must also change the DB schema. - -/** - * Maximum note text length that can be stored in DB. - * Content Warnings are included in this limit. - * Surrogate pairs count as one - */ -export const DB_MAX_NOTE_TEXT_LENGTH = 100000; - -/** - * Maximum image description length that can be stored in DB. - * Surrogate pairs count as one - */ -export const DB_MAX_IMAGE_COMMENT_LENGTH = 100000; -//#endregion - // ブラウザで直接表示することを許可するファイルの種類のリスト // ここに含まれないものは application/octet-stream としてレスポンスされる // SVGはXSSを生むので許可しない diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 2a38ed80b7..25286992d6 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -45,7 +45,6 @@ import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerServ import { NoteReadService } from '@/core/NoteReadService.js'; import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js'; import { bindThis } from '@/decorators.js'; -import { DB_MAX_NOTE_TEXT_LENGTH } from '@/const.js'; import { RoleService } from '@/core/RoleService.js'; import { SearchService } from '@/core/SearchService.js'; import { FeaturedService } from '@/core/FeaturedService.js'; @@ -335,9 +334,13 @@ export class NoteCreateService implements OnApplicationShutdown { data.localOnly = true; } + const maxTextLength = user.host == null + ? this.config.maxNoteLength + : this.config.maxRemoteNoteLength; + if (data.text) { - if (data.text.length > DB_MAX_NOTE_TEXT_LENGTH) { - data.text = data.text.slice(0, DB_MAX_NOTE_TEXT_LENGTH); + if (data.text.length > maxTextLength) { + data.text = data.text.slice(0, maxTextLength); } data.text = data.text.trim(); if (data.text === '') { @@ -348,8 +351,8 @@ export class NoteCreateService implements OnApplicationShutdown { } if (data.cw) { - if (data.cw.length > DB_MAX_NOTE_TEXT_LENGTH) { - data.cw = data.cw.slice(0, DB_MAX_NOTE_TEXT_LENGTH); + if (data.cw.length > maxTextLength) { + data.cw = data.cw.slice(0, maxTextLength); } data.cw = data.cw.trim(); if (data.cw === '') { diff --git a/packages/backend/src/core/NoteEditService.ts b/packages/backend/src/core/NoteEditService.ts index df45595da9..b1dd32aef8 100644 --- a/packages/backend/src/core/NoteEditService.ts +++ b/packages/backend/src/core/NoteEditService.ts @@ -39,7 +39,6 @@ import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerServ import { NoteReadService } from '@/core/NoteReadService.js'; import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js'; import { bindThis } from '@/decorators.js'; -import { DB_MAX_NOTE_TEXT_LENGTH } from '@/const.js'; import { RoleService } from '@/core/RoleService.js'; import { SearchService } from '@/core/SearchService.js'; import { FanoutTimelineService } from '@/core/FanoutTimelineService.js'; @@ -365,9 +364,13 @@ export class NoteEditService implements OnApplicationShutdown { data.localOnly = true; } + const maxTextLength = user.host == null + ? this.config.maxNoteLength + : this.config.maxRemoteNoteLength; + if (data.text) { - if (data.text.length > DB_MAX_NOTE_TEXT_LENGTH) { - data.text = data.text.slice(0, DB_MAX_NOTE_TEXT_LENGTH); + if (data.text.length > maxTextLength) { + data.text = data.text.slice(0, maxTextLength); } data.text = data.text.trim(); if (data.text === '') { @@ -378,8 +381,8 @@ export class NoteEditService implements OnApplicationShutdown { } if (data.cw) { - if (data.cw.length > DB_MAX_NOTE_TEXT_LENGTH) { - data.cw = data.cw.slice(0, DB_MAX_NOTE_TEXT_LENGTH); + if (data.cw.length > maxTextLength) { + data.cw = data.cw.slice(0, maxTextLength); } data.cw = data.cw.trim(); if (data.cw === '') { diff --git a/packages/backend/src/core/activitypub/models/ApImageService.ts b/packages/backend/src/core/activitypub/models/ApImageService.ts index ba9f41ca24..259889d945 100644 --- a/packages/backend/src/core/activitypub/models/ApImageService.ts +++ b/packages/backend/src/core/activitypub/models/ApImageService.ts @@ -9,12 +9,12 @@ import type { DriveFilesRepository, MiMeta } from '@/models/_.js'; import type { MiRemoteUser } from '@/models/User.js'; import type { MiDriveFile } from '@/models/DriveFile.js'; import { truncate } from '@/misc/truncate.js'; -import { DB_MAX_IMAGE_COMMENT_LENGTH } from '@/const.js'; import { DriveService } from '@/core/DriveService.js'; import type Logger from '@/logger.js'; import { bindThis } from '@/decorators.js'; import { checkHttps } from '@/misc/check-https.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; +import type { Config } from '@/config.js'; import { ApResolverService } from '../ApResolverService.js'; import { ApLoggerService } from '../ApLoggerService.js'; import { isDocument, type IObject } from '../type.js'; @@ -29,6 +29,8 @@ export class ApImageService { @Inject(DI.driveFilesRepository) private driveFilesRepository: DriveFilesRepository, + @Inject(DI.config) + private config: Config, private apResolverService: ApResolverService, private driveService: DriveService, @@ -83,7 +85,7 @@ export class ApImageService { uri: image.url, sensitive: !!(image.sensitive), isLink: !shouldBeCached, - comment: truncate(image.name ?? undefined, DB_MAX_IMAGE_COMMENT_LENGTH), + comment: truncate(image.name ?? undefined, this.config.maxRemoteAltTextLength), }); if (!file.isLink || file.url === image.url) return file; diff --git a/packages/backend/src/core/entities/MetaEntityService.ts b/packages/backend/src/core/entities/MetaEntityService.ts index 61655c9652..a94b3ae290 100644 --- a/packages/backend/src/core/entities/MetaEntityService.ts +++ b/packages/backend/src/core/entities/MetaEntityService.ts @@ -110,6 +110,9 @@ export class MetaEntityService { backgroundImageUrl: instance.backgroundImageUrl, logoImageUrl: instance.logoImageUrl, maxNoteTextLength: this.config.maxNoteLength, + maxRemoteNoteTextLength: this.config.maxRemoteNoteLength, + maxAltTextLength: this.config.maxAltTextLength, + maxRemoteAltTextLength: this.config.maxRemoteAltTextLength, defaultLightTheme, defaultDarkTheme, defaultLike: instance.defaultLike, diff --git a/packages/backend/src/models/DriveFile.ts b/packages/backend/src/models/DriveFile.ts index 7de4be4f96..12d7b31e00 100644 --- a/packages/backend/src/models/DriveFile.ts +++ b/packages/backend/src/models/DriveFile.ts @@ -4,7 +4,6 @@ */ import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; -import { DB_MAX_IMAGE_COMMENT_LENGTH } from '@/const.js'; import { id } from './util/id.js'; import { MiUser } from './User.js'; import { MiDriveFolder } from './DriveFolder.js'; @@ -61,8 +60,7 @@ export class MiDriveFile { }) public size: number; - @Column('varchar', { - length: DB_MAX_IMAGE_COMMENT_LENGTH, + @Column('text', { nullable: true, comment: 'The comment of the DriveFile.', }) diff --git a/packages/backend/src/models/json-schema/meta.ts b/packages/backend/src/models/json-schema/meta.ts index 15e87648ff..dbc28a7bfd 100644 --- a/packages/backend/src/models/json-schema/meta.ts +++ b/packages/backend/src/models/json-schema/meta.ts @@ -168,6 +168,18 @@ export const packedMetaLiteSchema = { type: 'number', optional: false, nullable: false, }, + maxRemoteNoteTextLength: { + type: 'number', + optional: false, nullable: false, + }, + maxAltTextLength: { + type: 'number', + optional: false, nullable: false, + }, + maxRemoteAltTextLength: { + type: 'number', + optional: false, nullable: false, + }, ads: { type: 'array', optional: false, nullable: false, diff --git a/packages/backend/src/server/NodeinfoServerService.ts b/packages/backend/src/server/NodeinfoServerService.ts index bc8d3c0411..65a2a59a74 100644 --- a/packages/backend/src/server/NodeinfoServerService.ts +++ b/packages/backend/src/server/NodeinfoServerService.ts @@ -122,6 +122,9 @@ export class NodeinfoServerService { enableMcaptcha: meta.enableMcaptcha, enableTurnstile: meta.enableTurnstile, maxNoteTextLength: this.config.maxNoteLength, + maxRemoteNoteTextLength: this.config.maxRemoteNoteLength, + maxAltTextLength: this.config.maxAltTextLength, + maxRemoteAltTextLength: this.config.maxRemoteAltTextLength, enableEmail: meta.enableEmail, enableServiceWorker: meta.enableServiceWorker, proxyAccountName: proxyAccount ? proxyAccount.username : null, diff --git a/packages/backend/src/server/api/endpoints/drive/files/create.ts b/packages/backend/src/server/api/endpoints/drive/files/create.ts index 74eb4dded7..b8763af96a 100644 --- a/packages/backend/src/server/api/endpoints/drive/files/create.ts +++ b/packages/backend/src/server/api/endpoints/drive/files/create.ts @@ -5,11 +5,11 @@ import ms from 'ms'; import { Inject, Injectable } from '@nestjs/common'; -import { DB_MAX_IMAGE_COMMENT_LENGTH } from '@/const.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; import { DriveService } from '@/core/DriveService.js'; +import type { Config } from '@/config.js'; import { ApiError } from '../../../error.js'; import { MiMeta } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; @@ -56,6 +56,12 @@ export const meta = { code: 'NO_FREE_SPACE', id: 'd08dbc37-a6a9-463a-8c47-96c32ab5f064', }, + + commentTooLong: { + message: 'Cannot upload the file because the comment exceeds the instance limit.', + code: 'COMMENT_TOO_LONG', + id: 'sj3hsm2l-s83j-4sk3-sk3j-sn3k2k4nsm3l', + }, }, } as const; @@ -64,7 +70,7 @@ export const paramDef = { properties: { folderId: { type: 'string', format: 'misskey:id', nullable: true, default: null }, name: { type: 'string', nullable: true, default: null }, - comment: { type: 'string', nullable: true, maxLength: DB_MAX_IMAGE_COMMENT_LENGTH, default: null }, + comment: { type: 'string', nullable: true, default: null }, isSensitive: { type: 'boolean', default: false }, force: { type: 'boolean', default: false }, }, @@ -77,6 +83,9 @@ export default class extends Endpoint { // eslint- @Inject(DI.meta) private serverSettings: MiMeta, + @Inject(DI.config) + private config: Config, + private driveFileEntityService: DriveFileEntityService, private driveService: DriveService, ) { @@ -94,6 +103,10 @@ export default class extends Endpoint { // eslint- } } + if (ps.comment && ps.comment.length > this.config.maxAltTextLength) { + throw new ApiError(meta.errors.commentTooLong); + } + try { // Create file const driveFile = await this.driveService.addFile({ diff --git a/packages/backend/src/server/api/endpoints/drive/files/update.ts b/packages/backend/src/server/api/endpoints/drive/files/update.ts index 5541018126..afad4ba0a6 100644 --- a/packages/backend/src/server/api/endpoints/drive/files/update.ts +++ b/packages/backend/src/server/api/endpoints/drive/files/update.ts @@ -9,8 +9,8 @@ import { Endpoint } from '@/server/api/endpoint-base.js'; import { DI } from '@/di-symbols.js'; import { RoleService } from '@/core/RoleService.js'; import { DriveService } from '@/core/DriveService.js'; +import type { Config } from '@/config.js'; import { ApiError } from '../../../error.js'; -import { DB_MAX_IMAGE_COMMENT_LENGTH } from '@/const.js'; export const meta = { tags: ['drive'], @@ -51,6 +51,12 @@ export const meta = { code: 'RESTRICTED_BY_ROLE', id: '7f59dccb-f465-75ab-5cf4-3ce44e3282f7', }, + + commentTooLong: { + message: 'Cannot upload the file because the comment exceeds the instance limit.', + code: 'COMMENT_TOO_LONG', + id: 'sj3hsm2l-s83j-4sk3-sk3j-sn3k2k4nsm3l', + }, }, res: { type: 'object', @@ -66,7 +72,7 @@ export const paramDef = { folderId: { type: 'string', format: 'misskey:id', nullable: true }, name: { type: 'string' }, isSensitive: { type: 'boolean' }, - comment: { type: 'string', nullable: true, maxLength: DB_MAX_IMAGE_COMMENT_LENGTH }, + comment: { type: 'string', nullable: true }, }, required: ['fileId'], } as const; @@ -76,6 +82,8 @@ export default class extends Endpoint { // eslint- constructor( @Inject(DI.driveFilesRepository) private driveFilesRepository: DriveFilesRepository, + @Inject(DI.config) + private config: Config, private driveService: DriveService, private roleService: RoleService, @@ -90,6 +98,10 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.accessDenied); } + if (ps.comment && ps.comment.length > this.config.maxAltTextLength) { + throw new ApiError(meta.errors.commentTooLong); + } + let packedFile; try { diff --git a/packages/backend/src/server/api/endpoints/drive/files/upload-from-url.ts b/packages/backend/src/server/api/endpoints/drive/files/upload-from-url.ts index 49d2e78d08..52a1c51b2c 100644 --- a/packages/backend/src/server/api/endpoints/drive/files/upload-from-url.ts +++ b/packages/backend/src/server/api/endpoints/drive/files/upload-from-url.ts @@ -4,12 +4,14 @@ */ import ms from 'ms'; -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; import { DriveService } from '@/core/DriveService.js'; -import { DB_MAX_IMAGE_COMMENT_LENGTH } from '@/const.js'; +import { ApiError } from '@/server/api/error.js'; +import { DI } from '@/di-symbols.js'; +import type { Config } from '@/config.js'; export const meta = { tags: ['drive'], @@ -26,6 +28,14 @@ export const meta = { prohibitMoved: true, kind: 'write:drive', + + errors: { + commentTooLong: { + message: 'Cannot upload the file because the comment exceeds the instance limit.', + code: 'COMMENT_TOO_LONG', + id: 'sj3hsm2l-s83j-4sk3-sk3j-sn3k2k4nsm3l', + }, + }, } as const; export const paramDef = { @@ -34,7 +44,7 @@ export const paramDef = { url: { type: 'string' }, folderId: { type: 'string', format: 'misskey:id', nullable: true, default: null }, isSensitive: { type: 'boolean', default: false }, - comment: { type: 'string', nullable: true, maxLength: DB_MAX_IMAGE_COMMENT_LENGTH, default: null }, + comment: { type: 'string', nullable: true, default: null }, marker: { type: 'string', nullable: true, default: null }, force: { type: 'boolean', default: false }, }, @@ -44,11 +54,18 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( + @Inject(DI.config) + private config: Config, + private driveFileEntityService: DriveFileEntityService, private driveService: DriveService, private globalEventService: GlobalEventService, ) { super(meta, paramDef, async (ps, user, _1, _2, _3, ip, headers) => { + if (ps.comment && ps.comment.length > this.config.maxAltTextLength) { + throw new ApiError(meta.errors.commentTooLong); + } + this.driveService.uploadFromUrl({ url: ps.url, user, folderId: ps.folderId, sensitive: ps.isSensitive, force: ps.force, comment: ps.comment, requestIp: ip, requestHeaders: headers }).then(file => { this.driveFileEntityService.pack(file, { self: true }).then(packedFile => { this.globalEventService.publishMainStream(user.id, 'urlUploadFinished', { diff --git a/packages/backend/src/server/api/endpoints/notes/create.ts b/packages/backend/src/server/api/endpoints/notes/create.ts index 412491afaa..a66395f25c 100644 --- a/packages/backend/src/server/api/endpoints/notes/create.ts +++ b/packages/backend/src/server/api/endpoints/notes/create.ts @@ -147,7 +147,7 @@ export const paramDef = { visibleUserIds: { type: 'array', uniqueItems: true, items: { type: 'string', format: 'misskey:id', } }, - cw: { type: 'string', nullable: true, minLength: 1, maxLength: 500 }, + cw: { type: 'string', nullable: true, minLength: 1 }, localOnly: { type: 'boolean', default: false }, reactionAcceptance: { type: 'string', nullable: true, enum: [null, 'likeOnly', 'likeOnlyForRemote', 'nonSensitiveOnly', 'nonSensitiveOnlyForLocalLikeOnlyForRemote'], default: null }, noExtractMentions: { type: 'boolean', default: false }, diff --git a/packages/backend/test/e2e/note.ts b/packages/backend/test/e2e/note.ts index 5937eb9b49..2a8ec8e7de 100644 --- a/packages/backend/test/e2e/note.ts +++ b/packages/backend/test/e2e/note.ts @@ -9,10 +9,12 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; import { MiNote } from '@/models/Note.js'; -import { MAX_NOTE_TEXT_LENGTH } from '@/const.js'; import { api, castAsError, initTestDb, post, role, signup, uploadFile, uploadUrl } from '../utils.js'; import type * as misskey from 'misskey-js'; +// TODO: these tests are probably wrong for depending on this, but that's a problem for later. +const MAX_NOTE_TEXT_LENGTH = 3000; + describe('Note', () => { let Notes: Repository; diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index d41e7ab1c9..8167e22b7d 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -5153,6 +5153,9 @@ export type components = { iconUrl: string | null; sidebarLogoUrl: string | null; maxNoteTextLength: number; + maxRemoteNoteTextLength: number; + maxAltTextLength: number; + maxRemoteAltTextLength: number; ads: { /** * Format: id -- cgit v1.2.3-freya From 01e98c75abc548bcd674526494cfc8ec0c7912ed Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Sat, 26 Oct 2024 10:04:23 -0400 Subject: add separate limits for CW length --- .config/ci.yml | 4 +++ .config/cypress-devcontainer.yml | 4 +++ .config/docker_example.yml | 4 +++ .config/example.yml | 4 +++ locales/index.d.ts | 36 ++++++++++++++++++++++ packages/backend/src/config.ts | 6 ++++ packages/backend/src/core/NoteCreateService.ts | 8 +++-- packages/backend/src/core/NoteEditService.ts | 8 +++-- .../src/server/api/endpoints/notes/create.ts | 12 ++++++-- .../backend/src/server/api/endpoints/notes/edit.ts | 14 +++++++-- 10 files changed, 91 insertions(+), 9 deletions(-) (limited to 'packages/backend/src/core/NoteCreateService.ts') diff --git a/.config/ci.yml b/.config/ci.yml index f29ac392d9..d20ede8d35 100644 --- a/.config/ci.yml +++ b/.config/ci.yml @@ -171,6 +171,10 @@ id: 'aidx' #maxNoteLength: 3000 # Amount of characters that will be saved for remote notes. Longer notes will be truncated to this length. (minimum: 1) #maxRemoteNoteLength: 100000 +# Amount of characters that can be used when writing content warnings. Longer warnings will be rejected. (minimum: 1) +#maxCwLength: 500 +# Amount of characters that will be saved for remote content warnings. Longer warnings will be truncated to this length. (minimum: 1) +#maxRemoteCwLength: 5000 # Amount of characters that can be used when writing media descriptions (alt text). Longer descriptions will be rejected. (minimum: 1) #maxAltTextLength: 20000 # Amount of characters that will be saved for remote media descriptions (alt text). Longer descriptions will be truncated to this length. (minimum: 1) diff --git a/.config/cypress-devcontainer.yml b/.config/cypress-devcontainer.yml index 66b5dceac8..d8013a1c95 100644 --- a/.config/cypress-devcontainer.yml +++ b/.config/cypress-devcontainer.yml @@ -183,6 +183,10 @@ id: 'aidx' #maxNoteLength: 3000 # Amount of characters that will be saved for remote notes. Longer notes will be truncated to this length. (minimum: 1) #maxRemoteNoteLength: 100000 +# Amount of characters that can be used when writing content warnings. Longer warnings will be rejected. (minimum: 1) +#maxCwLength: 500 +# Amount of characters that will be saved for remote content warnings. Longer warnings will be truncated to this length. (minimum: 1) +#maxRemoteCwLength: 5000 # Amount of characters that can be used when writing media descriptions (alt text). Longer descriptions will be rejected. (minimum: 1) #maxAltTextLength: 20000 # Amount of characters that will be saved for remote media descriptions (alt text). Longer descriptions will be truncated to this length. (minimum: 1) diff --git a/.config/docker_example.yml b/.config/docker_example.yml index dd8ea1727a..5fac3dc41e 100644 --- a/.config/docker_example.yml +++ b/.config/docker_example.yml @@ -254,6 +254,10 @@ id: 'aidx' #maxNoteLength: 3000 # Amount of characters that will be saved for remote notes. Longer notes will be truncated to this length. (minimum: 1) #maxRemoteNoteLength: 100000 +# Amount of characters that can be used when writing content warnings. Longer warnings will be rejected. (minimum: 1) +#maxCwLength: 500 +# Amount of characters that will be saved for remote content warnings. Longer warnings will be truncated to this length. (minimum: 1) +#maxRemoteCwLength: 5000 # Amount of characters that can be used when writing media descriptions (alt text). Longer descriptions will be rejected. (minimum: 1) #maxAltTextLength: 20000 # Amount of characters that will be saved for remote media descriptions (alt text). Longer descriptions will be truncated to this length. (minimum: 1) diff --git a/.config/example.yml b/.config/example.yml index 8794a25ffb..0062b6670c 100644 --- a/.config/example.yml +++ b/.config/example.yml @@ -265,6 +265,10 @@ id: 'aidx' #maxNoteLength: 3000 # Amount of characters that will be saved for remote notes. Longer notes will be truncated to this length. (minimum: 1) #maxRemoteNoteLength: 100000 +# Amount of characters that can be used when writing content warnings. Longer warnings will be rejected. (minimum: 1) +#maxCwLength: 500 +# Amount of characters that will be saved for remote content warnings. Longer warnings will be truncated to this length. (minimum: 1) +#maxRemoteCwLength: 5000 # Amount of characters that can be used when writing media descriptions (alt text). Longer descriptions will be rejected. (minimum: 1) #maxAltTextLength: 20000 # Amount of characters that will be saved for remote media descriptions (alt text). Longer descriptions will be truncated to this length. (minimum: 1) diff --git a/locales/index.d.ts b/locales/index.d.ts index 535e88f7c7..d1cb1f97ea 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -5353,6 +5353,10 @@ export interface Locale extends ILocale { * オンにすると、このお知らせは通知されず、既読にする必要もなくなります。 */ "silenceDescription": string; + /** + * New + */ + "new": string; }; "_initialAccountSetting": { /** @@ -8442,6 +8446,10 @@ export interface Locale extends ILocale { * アプリケーションにアクセス許可を与えるには、ログインが必要です。 */ "pleaseLogin": string; + /** + * Allowed + */ + "allowed": string; }; "_antennaSources": { /** @@ -10603,6 +10611,30 @@ export interface Locale extends ILocale { * Mutuals */ "mutuals": string; + /** + * Private account + */ + "isLocked": string; + /** + * Administrator + */ + "isAdmin": string; + /** + * Bot user + */ + "isBot": string; + /** + * Open + */ + "open": string; + /** + * Destination address + */ + "emailDestination": string; + /** + * Date + */ + "date": string; /** * Quoted. */ @@ -10964,6 +10996,10 @@ export interface Locale extends ILocale { * Blocking you */ "blockingYou": string; + /** + * Show warning when opening external URLs + */ + "warnExternalUrl": string; "_mfm": { /** * This is not a widespread feature, it may not display properly on most other fedi software, including other Misskey forks diff --git a/packages/backend/src/config.ts b/packages/backend/src/config.ts index 19f1d6c066..3dc49c7eb6 100644 --- a/packages/backend/src/config.ts +++ b/packages/backend/src/config.ts @@ -73,6 +73,8 @@ type Source = { maxFileSize?: number; maxNoteLength?: number; + maxCwLength?: number; + maxRemoteCwLength?: number; maxRemoteNoteLength?: number; maxAltTextLength?: number; maxRemoteAltTextLength?: number; @@ -153,6 +155,8 @@ export type Config = { maxFileSize: number; maxNoteLength: number; maxRemoteNoteLength: number; + maxCwLength: number; + maxRemoteCwLength: number; maxAltTextLength: number; maxRemoteAltTextLength: number; clusterLimit: number | undefined; @@ -308,6 +312,8 @@ export function loadConfig(): Config { maxFileSize: config.maxFileSize ?? 262144000, maxNoteLength: config.maxNoteLength ?? 3000, maxRemoteNoteLength: config.maxRemoteNoteLength ?? 100000, + maxCwLength: config.maxCwLength ?? 500, + maxRemoteCwLength: config.maxRemoteCwLength ?? 5000, maxAltTextLength: config.maxAltTextLength ?? 20000, maxRemoteAltTextLength: config.maxRemoteAltTextLength ?? 100000, clusterLimit: config.clusterLimit, diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 25286992d6..1bc4599a60 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -350,9 +350,13 @@ export class NoteCreateService implements OnApplicationShutdown { data.text = null; } + const maxCwLength = user.host == null + ? this.config.maxCwLength + : this.config.maxRemoteCwLength; + if (data.cw) { - if (data.cw.length > maxTextLength) { - data.cw = data.cw.slice(0, maxTextLength); + if (data.cw.length > maxCwLength) { + data.cw = data.cw.slice(0, maxCwLength); } data.cw = data.cw.trim(); if (data.cw === '') { diff --git a/packages/backend/src/core/NoteEditService.ts b/packages/backend/src/core/NoteEditService.ts index b1dd32aef8..d31958e5d4 100644 --- a/packages/backend/src/core/NoteEditService.ts +++ b/packages/backend/src/core/NoteEditService.ts @@ -380,9 +380,13 @@ export class NoteEditService implements OnApplicationShutdown { data.text = null; } + const maxCwLength = user.host == null + ? this.config.maxCwLength + : this.config.maxRemoteCwLength; + if (data.cw) { - if (data.cw.length > maxTextLength) { - data.cw = data.cw.slice(0, maxTextLength); + if (data.cw.length > maxCwLength) { + data.cw = data.cw.slice(0, maxCwLength); } data.cw = data.cw.trim(); if (data.cw === '') { diff --git a/packages/backend/src/server/api/endpoints/notes/create.ts b/packages/backend/src/server/api/endpoints/notes/create.ts index a66395f25c..d1cf0123dc 100644 --- a/packages/backend/src/server/api/endpoints/notes/create.ts +++ b/packages/backend/src/server/api/endpoints/notes/create.ts @@ -90,6 +90,12 @@ export const meta = { id: '3ac74a84-8fd5-4bb0-870f-01804f82ce16', }, + maxCwLength: { + message: 'You tried posting a content warning which is too long.', + code: 'MAX_CW_LENGTH', + id: '7004c478-bda3-4b4f-acb2-4316398c9d52', + }, + cannotReplyToSpecifiedVisibilityNoteWithExtendedVisibility: { message: 'You cannot reply to a specified visibility note with extended visibility.', code: 'CANNOT_REPLY_TO_SPECIFIED_VISIBILITY_NOTE_WITH_EXTENDED_VISIBILITY', @@ -250,10 +256,12 @@ export default class extends Endpoint { // eslint- private noteCreateService: NoteCreateService, ) { super(meta, paramDef, async (ps, me) => { - const contentLength = (ps.text?.length ?? 0) + (ps.cw?.length ?? 0); - if (contentLength > this.config.maxNoteLength) { + if (ps.text && ps.text.length > this.config.maxNoteLength) { throw new ApiError(meta.errors.maxLength); } + if (ps.cw && ps.cw.length > this.config.maxCwLength) { + throw new ApiError(meta.errors.maxCwLength); + } let visibleUsers: MiUser[] = []; if (ps.visibleUserIds) { diff --git a/packages/backend/src/server/api/endpoints/notes/edit.ts b/packages/backend/src/server/api/endpoints/notes/edit.ts index b9be145caf..dc94c78e75 100644 --- a/packages/backend/src/server/api/endpoints/notes/edit.ts +++ b/packages/backend/src/server/api/endpoints/notes/edit.ts @@ -86,6 +86,12 @@ export const meta = { id: '3ac74a84-8fd5-4bb0-870f-01804f82ce16', }, + maxCwLength: { + message: 'You tried posting a content warning which is too long.', + code: 'MAX_CW_LENGTH', + id: '7004c478-bda3-4b4f-acb2-4316398c9d52', + }, + cannotReplyToSpecifiedVisibilityNoteWithExtendedVisibility: { message: 'You cannot reply to a specified visibility note with extended visibility.', code: 'CANNOT_REPLY_TO_SPECIFIED_VISIBILITY_NOTE_WITH_EXTENDED_VISIBILITY', @@ -197,7 +203,7 @@ export const paramDef = { format: 'misskey:id', }, }, - cw: { type: 'string', nullable: true, minLength: 1, maxLength: 500 }, + cw: { type: 'string', nullable: true, minLength: 1 }, localOnly: { type: 'boolean', default: false }, reactionAcceptance: { type: 'string', nullable: true, enum: [null, 'likeOnly', 'likeOnlyForRemote', 'nonSensitiveOnly', 'nonSensitiveOnlyForLocalLikeOnlyForRemote'], default: null }, noExtractMentions: { type: 'boolean', default: false }, @@ -297,10 +303,12 @@ export default class extends Endpoint { // eslint- private noteEditService: NoteEditService, ) { super(meta, paramDef, async (ps, me) => { - const contentLength = (ps.text?.length ?? 0) + (ps.cw?.length ?? 0); - if (contentLength > this.config.maxNoteLength) { + if (ps.text && ps.text.length > this.config.maxNoteLength) { throw new ApiError(meta.errors.maxLength); } + if (ps.cw && ps.cw.length > this.config.maxCwLength) { + throw new ApiError(meta.errors.maxCwLength); + } let visibleUsers: MiUser[] = []; if (ps.visibleUserIds) { -- cgit v1.2.3-freya