merge: Fix regressions from recent block/mute fixes (!1239)

View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/1239

Approved-by: dakkar <dakkar@thenautilus.net>
Approved-by: Marie <github@yuugi.dev>
This commit is contained in:
Hazelnoot 2025-11-15 13:38:52 -05:00
commit 911f90f95a
17 changed files with 127 additions and 59 deletions

View file

@ -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.

View file

@ -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);

View file

@ -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;
}

View file

@ -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<typeof meta, typeof paramDef> { // 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

View file

@ -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,

View file

@ -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<typeof meta, typeof paramDef> { // 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<typeof meta, typeof paramDef> { // 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,
});

View file

@ -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<typeof meta, typeof paramDef> { // 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,
});

View file

@ -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<typeof meta, typeof paramDef> { // 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,

View file

@ -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;

View file

@ -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<typeof meta, typeof paramDef> { // 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<typeof meta, typeof paramDef> { // 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<typeof meta, typeof paramDef> { // 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<typeof meta, typeof paramDef> { // 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<typeof meta, typeof paramDef> { // 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) {

View file

@ -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,
});

View file

@ -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 {

View file

@ -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 });
}

View file

@ -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,

View file

@ -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 };

View file

@ -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'];

View file

@ -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;