diff --git a/CONTRIBUTING.Sharkey.md b/CONTRIBUTING.Sharkey.md index b20a3fdf9b..cbde43cc80 100644 --- a/CONTRIBUTING.Sharkey.md +++ b/CONTRIBUTING.Sharkey.md @@ -501,3 +501,30 @@ following apply: - It's necessary to use `null` as a data value. `QuantumKVCache` does not allow null values, and thus another option should be chosen. + +### Inter-Process Communication + +Sharkey can utilize multiple processes for a single server. +When running in this mode, a mechanism to synchronization changes between each process is necessary. +This is accomplished through the use of **Redis IPC**. + +#### IPC Options + +There are three methods to access this IPC system, all of which are available through Dependency Injection: +* Using the `pub` and `sub` Redis instances to directly establish channels. + This should only be done when necessary, like when implementing very low-level utilities. +* The `publishInternalEvent` method of `GlobalEventService`. + This method will asynchronously publish an event to redis, which will forward it to all connected processes - **including the sending process**. + Due to this and other issues, `publishInternalEvent` should be considered obsolete and avoided in new code. + Instead, consider one of the other options. +* `InternalEventService`, which is the newest and recommended way to handle IPC. + The `emit` method accepts arguments identical to `publishInternalEvent`, which eases migration, while also accepting a configuration object to control event propagation. + Additionally, `InternalEventService` ensures that local event listeners are called *before* notifying other processes, avoiding potential data races and other weirdness. + +#### When to use IPC + +IPC should be used whenever cacheable data is modified. +By cacheable, we mean any data that could be stored in any of the supported memory caches. +Changes to `MiUser`, `MiUserProfile`, or `MiInstance` entities should **always** be considered cacheable, but these are not the only options. +A major exception is when the local data is cached in a Quantum cache (`QuantumKVCache`). +Quantum caches automatically call `InternalEventService.emit` to synchronize changes, so you only need to `await set()` and the changes will be reflected in other processes' caches too. diff --git a/packages/backend/src/core/FanoutTimelineEndpointService.ts b/packages/backend/src/core/FanoutTimelineEndpointService.ts index 6657a04dc9..46f25eabb4 100644 --- a/packages/backend/src/core/FanoutTimelineEndpointService.ts +++ b/packages/backend/src/core/FanoutTimelineEndpointService.ts @@ -116,7 +116,10 @@ export class FanoutTimelineEndpointService { const parentFilter = filter; filter = (note, populated) => { - const { accessible, silence } = this.noteVisibilityService.checkNoteVisibility(populated, me, { data, filters: { includeSilencedAuthor: ps.ignoreAuthorFromUserSilence } }); + const { accessible, silence } = this.noteVisibilityService.checkNoteVisibility(populated, me, { data, filters: { + includeSilencedAuthor: ps.ignoreAuthorFromUserSilence, + includeReplies: true, // Include replies because we check them elsewhere + } }); if (!accessible || silence) return false; return parentFilter(note, populated); diff --git a/packages/backend/src/misc/is-reply.ts b/packages/backend/src/misc/is-reply.ts index 980eae11c9..1405f38d01 100644 --- a/packages/backend/src/misc/is-reply.ts +++ b/packages/backend/src/misc/is-reply.ts @@ -3,8 +3,10 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { MiUser } from '@/models/User.js'; +import type { MiNote } from '@/models/Note.js'; +import type { MiUser } from '@/models/User.js'; -export function isReply(note: any, viewerId?: MiUser['id'] | undefined | null): boolean { - return note.replyId && note.replyUserId !== note.userId && note.replyUserId !== viewerId; +// Should really be named "isReplyToOther" +export function isReply(note: MiNote, viewerId?: MiUser['id'] | undefined | null): boolean { + return note.replyId != null && note.replyUserId !== note.userId && note.replyUserId !== viewerId; } diff --git a/packages/backend/src/server/api/endpoints/admin/gen-vapid-keys.ts b/packages/backend/src/server/api/endpoints/admin/gen-vapid-keys.ts index 9e4b87f674..6a29ad3481 100644 --- a/packages/backend/src/server/api/endpoints/admin/gen-vapid-keys.ts +++ b/packages/backend/src/server/api/endpoints/admin/gen-vapid-keys.ts @@ -3,9 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import webpush from 'web-push'; -const { generateVAPIDKeys } = webpush; import { Endpoint } from '@/server/api/endpoint-base.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; @@ -15,6 +14,21 @@ export const meta = { requireCredential: true, requireModerator: true, kind: 'write:admin:meta', + + res: { + type: 'object', + optional: false, nullable: false, + properties: { + public: { + type: 'string', + optional: false, nullable: false, + }, + private: { + type: 'string', + optional: false, nullable: false, + }, + }, + }, } as const; export const paramDef = { @@ -28,8 +42,8 @@ export default class extends Endpoint { // eslint- constructor( private moderationLogService: ModerationLogService, ) { - super(meta, paramDef, async (ps, me) => { - const keys = await generateVAPIDKeys(); + super(meta, paramDef, async () => { + const keys = webpush.generateVAPIDKeys(); // TODO add moderation log diff --git a/packages/backend/src/server/api/endpoints/admin/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts index 22eab974a8..ed7e1aeb2d 100644 --- a/packages/backend/src/server/api/endpoints/admin/meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/meta.ts @@ -489,6 +489,10 @@ export const meta = { type: 'string', optional: false, nullable: true, }, + about: { + type: 'string', + optional: false, nullable: true, + }, disableRegistration: { type: 'boolean', optional: false, nullable: false, diff --git a/packages/backend/src/server/api/endpoints/admin/silence-user.ts b/packages/backend/src/server/api/endpoints/admin/silence-user.ts index e69c46e55a..8c841c5bd9 100644 --- a/packages/backend/src/server/api/endpoints/admin/silence-user.ts +++ b/packages/backend/src/server/api/endpoints/admin/silence-user.ts @@ -10,7 +10,7 @@ import { DI } from '@/di-symbols.js'; import { RoleService } from '@/core/RoleService.js'; import { CacheService } from '@/core/CacheService.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { InternalEventService } from '@/global/InternalEventService.js'; export const meta = { tags: ['admin'], @@ -36,7 +36,7 @@ export default class extends Endpoint { // eslint- private readonly cacheService: CacheService, private readonly moderationLogService: ModerationLogService, private readonly roleService: RoleService, - private readonly globalEventService: GlobalEventService, + private readonly internalEventService: InternalEventService, ) { super(meta, paramDef, async (ps, me) => { const user = await this.cacheService.findUserById(ps.userId); @@ -47,11 +47,10 @@ export default class extends Endpoint { // eslint- if (user.isSilenced) return; - await this.usersRepository.update(user.id, { + await this.usersRepository.update({ id: user.id }, { isSilenced: true, }); - - this.globalEventService.publishInternalEvent(user.host == null ? 'localUserUpdated' : 'remoteUserUpdated', { + await this.internalEventService.emit(user.host == null ? 'localUserUpdated' : 'remoteUserUpdated', { id: user.id, }); diff --git a/packages/backend/src/server/api/endpoints/admin/unsilence-user.ts b/packages/backend/src/server/api/endpoints/admin/unsilence-user.ts index f511859c2c..08c0b8cc55 100644 --- a/packages/backend/src/server/api/endpoints/admin/unsilence-user.ts +++ b/packages/backend/src/server/api/endpoints/admin/unsilence-user.ts @@ -9,7 +9,7 @@ import type { UsersRepository } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; import { CacheService } from '@/core/CacheService.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { InternalEventService } from '@/global/InternalEventService.js'; export const meta = { tags: ['admin'], @@ -34,18 +34,17 @@ export default class extends Endpoint { // eslint- private readonly usersRepository: UsersRepository, private readonly cacheService: CacheService, private readonly moderationLogService: ModerationLogService, - private readonly globalEventService: GlobalEventService, + private readonly internalEventService: InternalEventService, ) { super(meta, paramDef, async (ps, me) => { const user = await this.cacheService.findUserById(ps.userId); if (!user.isSilenced) return; - await this.usersRepository.update(user.id, { + await this.usersRepository.update({ id: user.id }, { isSilenced: false, }); - - this.globalEventService.publishInternalEvent(user.host == null ? 'localUserUpdated' : 'remoteUserUpdated', { + await this.internalEventService.emit(user.host == null ? 'localUserUpdated' : 'remoteUserUpdated', { id: user.id, }); diff --git a/packages/backend/src/server/api/endpoints/admin/update-user-note.ts b/packages/backend/src/server/api/endpoints/admin/update-user-note.ts index e9930422c0..2d65940578 100644 --- a/packages/backend/src/server/api/endpoints/admin/update-user-note.ts +++ b/packages/backend/src/server/api/endpoints/admin/update-user-note.ts @@ -8,6 +8,8 @@ import type { UserProfilesRepository, UsersRepository } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { DI } from '@/di-symbols.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { InternalEventService } from '@/global/InternalEventService.js'; +import { CacheService } from '@/core/CacheService.js'; export const meta = { tags: ['admin'], @@ -34,23 +36,23 @@ export default class extends Endpoint { // eslint- @Inject(DI.userProfilesRepository) private userProfilesRepository: UserProfilesRepository, + private readonly internalEventService: InternalEventService, + private readonly cacheService: CacheService, private moderationLogService: ModerationLogService, ) { super(meta, paramDef, async (ps, me) => { - const user = await this.usersRepository.findOneBy({ id: ps.userId }); - - if (user == null) { - throw new Error('user not found'); - } - - const currentProfile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); + const [user, currentProfile] = await Promise.all([ + this.cacheService.findUserById(ps.userId), + this.cacheService.userProfileCache.fetch(ps.userId), + ]); await this.userProfilesRepository.update({ userId: user.id }, { moderationNote: ps.text, }); + await this.internalEventService.emit('updateUserProfile', { userId: user.id }); - this.moderationLogService.log(me, 'updateUserNote', { + await this.moderationLogService.log(me, 'updateUserNote', { userId: user.id, userUsername: user.username, userHost: user.host, diff --git a/packages/backend/src/server/api/endpoints/get-online-users-count.ts b/packages/backend/src/server/api/endpoints/get-online-users-count.ts index 307bdce1cd..08a037f7a3 100644 --- a/packages/backend/src/server/api/endpoints/get-online-users-count.ts +++ b/packages/backend/src/server/api/endpoints/get-online-users-count.ts @@ -28,10 +28,11 @@ export const meta = { }, }, - // 2 calls per second + // 20 calls, then 4 per second limit: { - duration: 1000, - max: 2, + type: 'bucket', + size: 20, + dripRate: 250, }, } as const; diff --git a/packages/backend/src/server/api/endpoints/users/notes.ts b/packages/backend/src/server/api/endpoints/users/notes.ts index 2822b52ec8..e897777697 100644 --- a/packages/backend/src/server/api/endpoints/users/notes.ts +++ b/packages/backend/src/server/api/endpoints/users/notes.ts @@ -51,10 +51,11 @@ export const meta = { }, }, - // 5 calls per second + // Up to 20 calls, then 4/second limit: { - duration: 1000, - max: 5, + type: 'bucket', + size: 20, + dripRate: 250, }, } as const; @@ -63,7 +64,6 @@ 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 }, withBots: { type: 'boolean', default: true }, @@ -122,8 +122,7 @@ export default class extends Endpoint { // eslint- withQuotes: ps.withQuotes, withBots: ps.withBots, withNonPublic: ps.withNonPublic, - withRepliesToOthers: ps.withReplies, - withRepliesToSelf: ps.withRepliesToSelf, + withReplies: ps.withReplies, }, me); return await this.noteEntityService.packMany(timeline, me); @@ -146,15 +145,12 @@ export default class extends Endpoint { // eslint- ignoreAuthorFromInstanceBlock: true, ignoreAuthorFromUserSuspension: true, ignoreAuthorFromUserSilence: true, - excludeReplies: ps.withChannelNotes && !ps.withReplies, // userTimelineWithChannel may include replies - excludeNoFiles: ps.withChannelNotes && ps.withFiles, // userTimelineWithChannel may include notes without files + excludeReplies: !ps.withChannelNotes && !ps.withReplies, // userTimelineWithChannel may include replies + excludeNoFiles: !ps.withChannelNotes && ps.withFiles, // userTimelineWithChannel may include notes without files excludePureRenotes: !ps.withRenotes, excludeBots: !ps.withBots, noteFilter: note => { if (note.channel?.isSensitive && !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; @@ -171,8 +167,7 @@ export default class extends Endpoint { // eslint- withQuotes: ps.withQuotes, withBots: ps.withBots, withNonPublic: ps.withNonPublic, - withRepliesToOthers: ps.withReplies, - withRepliesToSelf: ps.withRepliesToSelf, + withReplies: ps.withReplies, }, me), }); @@ -191,8 +186,7 @@ export default class extends Endpoint { // eslint- withQuotes: boolean, withBots: boolean, withNonPublic: boolean, - withRepliesToOthers: boolean, - withRepliesToSelf: boolean, + withReplies: boolean, }, me: MiLocalUser | null) { const isSelf = me && (me.id === ps.userId); @@ -236,12 +230,8 @@ export default class extends Endpoint { // eslint- this.queryService.andIsNotQuote(query, 'note'); } - if (!ps.withRepliesToOthers && !ps.withRepliesToSelf) { - query.andWhere('reply.id IS NULL'); - } else if (!ps.withRepliesToOthers) { + if (!ps.withReplies) { this.queryService.generateExcludedRepliesQueryForNotes(query, me); - } else if (!ps.withRepliesToSelf) { - query.andWhere('(reply.id IS NULL OR reply."userId" != note."userId")'); } if (!ps.withNonPublic) { diff --git a/packages/frontend/src/pages/admin/settings.vue b/packages/frontend/src/pages/admin/settings.vue index fd51269181..67fd2d7881 100644 --- a/packages/frontend/src/pages/admin/settings.vue +++ b/packages/frontend/src/pages/admin/settings.vue @@ -472,7 +472,7 @@ const proxyAccountForm = useForm({ description: state.description, }); } - if (state.enabled !== proxyAccount.enabled) { + if (state.enabled !== meta.enableProxyAccount) { await os.apiWithDialog('admin/update-meta', { enableProxyAccount: state.enabled, }); diff --git a/packages/frontend/src/pages/chat/XMessage.vue b/packages/frontend/src/pages/chat/XMessage.vue index ae1c0c5a11..572a0ac5c6 100644 --- a/packages/frontend/src/pages/chat/XMessage.vue +++ b/packages/frontend/src/pages/chat/XMessage.vue @@ -262,6 +262,9 @@ function showMenu(ev: MouseEvent, contextmenu = false) { .body { margin: 0 12px; + + // https://stackoverflow.com/questions/36230944/prevent-flex-items-from-overflowing-a-container + min-width: 0; } .header { diff --git a/packages/frontend/src/pages/user-list-timeline.vue b/packages/frontend/src/pages/user-list-timeline.vue index cb4d6f4240..6e0ea18438 100644 --- a/packages/frontend/src/pages/user-list-timeline.vue +++ b/packages/frontend/src/pages/user-list-timeline.vue @@ -33,10 +33,11 @@ import { misskeyApi } from '@/utility/misskey-api.js'; import { definePage } from '@/page.js'; import { i18n } from '@/i18n.js'; import { useRouter } from '@/router.js'; -import { store } from '@/store.js'; import { deepMerge } from '@/utility/merge.js'; -import * as os from '@/os.js'; +import { useMuteOverrides } from '@/utility/check-word-mute.js'; +import { store } from '@/store.js'; import { $i } from '@/i.js'; +import * as os from '@/os.js'; const router = useRouter(); @@ -65,10 +66,22 @@ function saveTlFilter(key: keyof typeof store.s.tl.filter, newValue: boolean) { } } +const muteOverrides = useMuteOverrides(); + watch(() => props.listId, async () => { - list.value = await misskeyApi('users/lists/show', { + const _list = await misskeyApi('users/lists/show', { listId: props.listId, }); + list.value = _list; + + // Disable mandatory CW for all list members + muteOverrides.user = {}; // Reset prior + for (const userId of _list.userIds) { + muteOverrides.user[userId] = { + userMandatoryCW: null, + instanceMandatoryCW: null, + }; + } }, { immediate: true }); function queueUpdated(q) { @@ -76,6 +89,7 @@ function queueUpdated(q) { } function top() { + if (!rootEl.value) return; scrollInContainer(rootEl.value, { top: 0 }); } diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md index eb91c13680..3550677287 100644 --- a/packages/misskey-js/etc/misskey-js.api.md +++ b/packages/misskey-js/etc/misskey-js.api.md @@ -250,6 +250,9 @@ type AdminFederationUpdateInstanceRequest = operations['admin___federation___upd // @public (undocumented) type AdminForwardAbuseUserReportRequest = operations['admin___forward-abuse-user-report']['requestBody']['content']['application/json']; +// @public (undocumented) +type AdminGenVapidKeysResponse = operations['admin___gen-vapid-keys']['responses']['200']['content']['application/json']; + // @public (undocumented) type AdminGetIndexStatsResponse = operations['admin___get-index-stats']['responses']['200']['content']['application/json']; @@ -1598,6 +1601,7 @@ declare namespace entities { AdminFederationRemoveAllFollowingRequest, AdminFederationUpdateInstanceRequest, AdminForwardAbuseUserReportRequest, + AdminGenVapidKeysResponse, AdminGetIndexStatsResponse, AdminGetTableStatsResponse, AdminGetUserIpsRequest, diff --git a/packages/misskey-js/src/autogen/endpoint.ts b/packages/misskey-js/src/autogen/endpoint.ts index 4edef14e18..831a868792 100644 --- a/packages/misskey-js/src/autogen/endpoint.ts +++ b/packages/misskey-js/src/autogen/endpoint.ts @@ -71,6 +71,7 @@ import type { AdminFederationRemoveAllFollowingRequest, AdminFederationUpdateInstanceRequest, AdminForwardAbuseUserReportRequest, + AdminGenVapidKeysResponse, AdminGetIndexStatsResponse, AdminGetTableStatsResponse, AdminGetUserIpsRequest, @@ -726,7 +727,7 @@ export type Endpoints = { 'admin/federation/remove-all-following': { req: AdminFederationRemoveAllFollowingRequest; res: EmptyResponse }; 'admin/federation/update-instance': { req: AdminFederationUpdateInstanceRequest; res: EmptyResponse }; 'admin/forward-abuse-user-report': { req: AdminForwardAbuseUserReportRequest; res: EmptyResponse }; - 'admin/gen-vapid-keys': { req: EmptyRequest; res: EmptyResponse }; + 'admin/gen-vapid-keys': { req: EmptyRequest; res: AdminGenVapidKeysResponse }; 'admin/get-index-stats': { req: EmptyRequest; res: AdminGetIndexStatsResponse }; 'admin/get-table-stats': { req: EmptyRequest; res: AdminGetTableStatsResponse }; 'admin/get-user-ips': { req: AdminGetUserIpsRequest; res: AdminGetUserIpsResponse }; diff --git a/packages/misskey-js/src/autogen/entities.ts b/packages/misskey-js/src/autogen/entities.ts index 2d70299c6d..e1cb079a74 100644 --- a/packages/misskey-js/src/autogen/entities.ts +++ b/packages/misskey-js/src/autogen/entities.ts @@ -74,6 +74,7 @@ export type AdminFederationRefreshRemoteInstanceMetadataRequest = operations['ad export type AdminFederationRemoveAllFollowingRequest = operations['admin___federation___remove-all-following']['requestBody']['content']['application/json']; export type AdminFederationUpdateInstanceRequest = operations['admin___federation___update-instance']['requestBody']['content']['application/json']; export type AdminForwardAbuseUserReportRequest = operations['admin___forward-abuse-user-report']['requestBody']['content']['application/json']; +export type AdminGenVapidKeysResponse = operations['admin___gen-vapid-keys']['responses']['200']['content']['application/json']; export type AdminGetIndexStatsResponse = operations['admin___get-index-stats']['responses']['200']['content']['application/json']; export type AdminGetTableStatsResponse = operations['admin___get-table-stats']['responses']['200']['content']['application/json']; export type AdminGetUserIpsRequest = operations['admin___get-user-ips']['requestBody']['content']['application/json']; diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index fa0c29877d..d64e8ffffd 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -15800,12 +15800,17 @@ export interface operations { }; requestBody?: never; responses: { - /** @description OK (without any results) */ - 204: { + /** @description OK (with results) */ + 200: { headers: { [name: string]: unknown; }; - content?: never; + content: { + 'application/json': { + public: string; + private: string; + }; + }; }; /** @description Client error */ 400: { @@ -16346,6 +16351,7 @@ export interface operations { defaultLightTheme: string | null; defaultLike: string; description: string | null; + about: string | null; disableRegistration: boolean; impressumUrl: string | null; donationUrl: string | null; @@ -47895,8 +47901,6 @@ export interface operations { /** @default false */ withReplies?: boolean; /** @default true */ - withRepliesToSelf?: boolean; - /** @default true */ withQuotes?: boolean; /** @default true */ withRenotes?: boolean;