merge: Add check to prevent creating too many active dialog announcements (!1175)

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

Closes #769

Approved-by: Hazelnoot <acomputerdog@gmail.com>
Approved-by: dakkar <dakkar@thenautilus.net>
This commit is contained in:
Hazelnoot 2025-09-14 06:54:53 -04:00
commit e72d153cc8
7 changed files with 98 additions and 32 deletions

View file

@ -250,6 +250,8 @@ id: 'aidx'
#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
# Amount of dialog-style announcements that can be active at a time. (minimum: 1)
#maxDialogAnnouncements: 5
# Proxy for HTTP/HTTPS
#proxy: http://127.0.0.1:3128

View file

@ -345,6 +345,8 @@ id: 'aidx'
#maxBioLength: 1500
# Amount of characters that will be saved for remote user bios. Longer descriptions will be truncated to this length. (minimum: 1)
#maxRemoteBioLength: 15000
# Amount of dialog-style announcements that can be active at a time. (minimum: 1)
#maxDialogAnnouncements: 5
# Proxy for HTTP/HTTPS
#proxy: http://127.0.0.1:3128

View file

@ -348,6 +348,8 @@ id: 'aidx'
#maxBioLength: 1500
# Amount of characters that will be saved for remote user bios. Longer descriptions will be truncated to this length. (minimum: 1)
#maxRemoteBioLength: 15000
# Amount of dialog-style announcements that can be active at a time. (minimum: 1)
#maxDialogAnnouncements: 5
# Proxy for HTTP/HTTPS
#proxy: http://127.0.0.1:3128

View file

@ -98,6 +98,7 @@ type Source = {
maxRemoteAltTextLength?: number;
maxBioLength?: number;
maxRemoteBioLength?: number;
maxDialogAnnouncements?: number;
clusterLimit?: number;
@ -265,6 +266,7 @@ export type Config = {
maxRemoteAltTextLength: number;
maxBioLength: number;
maxRemoteBioLength: number;
maxDialogAnnouncements: number;
clusterLimit: number | undefined;
id: string;
outgoingAddress: string | undefined;
@ -467,6 +469,7 @@ export function loadConfig(): Config {
maxRemoteAltTextLength: config.maxRemoteAltTextLength ?? 100000,
maxBioLength: config.maxBioLength ?? 1500,
maxRemoteBioLength: config.maxRemoteBioLength ?? 15000,
maxDialogAnnouncements: config.maxDialogAnnouncements ?? 5,
clusterLimit: config.clusterLimit,
outgoingAddress: config.outgoingAddress,
outgoingAddressFamily: config.outgoingAddressFamily,
@ -664,7 +667,7 @@ function applyEnvOverrides(config: Source) {
_apply_top(['sentryForFrontend', 'browserTracingIntegration', 'routeLabel']);
_apply_top([['clusterLimit', 'deliverJobConcurrency', 'inboxJobConcurrency', 'relashionshipJobConcurrency', 'deliverJobPerSec', 'inboxJobPerSec', 'relashionshipJobPerSec', 'deliverJobMaxAttempts', 'inboxJobMaxAttempts']]);
_apply_top([['outgoingAddress', 'outgoingAddressFamily', 'proxy', 'proxySmtp', 'mediaDirectory', 'mediaProxy', 'proxyRemoteFiles', 'videoThumbnailGenerator']]);
_apply_top([['maxFileSize', 'maxNoteLength', 'maxRemoteNoteLength', 'maxAltTextLength', 'maxRemoteAltTextLength', 'maxBioLength', 'maxRemoteBioLength', 'pidFile', 'filePermissionBits']]);
_apply_top([['maxFileSize', 'maxNoteLength', 'maxRemoteNoteLength', 'maxAltTextLength', 'maxRemoteAltTextLength', 'maxBioLength', 'maxRemoteBioLength', 'maxDialogAnnouncements', 'pidFile', 'filePermissionBits']]);
_apply_top(['import', ['downloadTimeout', 'maxFileSize']]);
_apply_top([['signToActivityPubGet', 'checkActivityPubGetSignature', 'setupPassword', 'disallowExternalApRedirect']]);
_apply_top(['logging', 'sql', ['disableQueryTruncation', 'enableQueryParamLogging']]);

View file

@ -14,11 +14,16 @@ import { IdService } from '@/core/IdService.js';
import { AnnouncementEntityService } from '@/core/entities/AnnouncementEntityService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import type { Config } from '@/config.js';
import { RoleService } from '@/core/RoleService.js';
@Injectable()
export class AnnouncementService {
constructor(
@Inject(DI.config)
private config: Config,
@Inject(DI.announcementsRepository)
private announcementsRepository: AnnouncementsRepository,
@ -74,6 +79,10 @@ export class AnnouncementService {
@bindThis
public async create(values: Partial<MiAnnouncement>, moderator?: MiUser): Promise<{ raw: MiAnnouncement; packed: Packed<'Announcement'> }> {
if (values.display === 'dialog') {
await this.assertDialogAnnouncementsCountLimit();
}
const announcement = await this.announcementsRepository.insertOne({
id: this.idService.gen(),
updatedAt: null,
@ -128,6 +137,11 @@ export class AnnouncementService {
@bindThis
public async update(announcement: MiAnnouncement, values: Partial<MiAnnouncement>, moderator?: MiUser): Promise<void> {
// Check if this operation would produce an active dialog announcement
if ((values.display ?? announcement.display) === 'dialog' && (values.isActive ?? announcement.isActive)) {
await this.assertDialogAnnouncementsCountLimit();
}
await this.announcementsRepository.update(announcement.id, {
updatedAt: new Date(),
title: values.title,
@ -231,4 +245,17 @@ export class AnnouncementService {
this.globalEventService.publishMainStream(user.id, 'readAllAnnouncements');
}
}
private async assertDialogAnnouncementsCountLimit(): Promise<void> {
// Check how many active dialog queries already exist, to enforce a limit
const dialogCount = await this.announcementsRepository.createQueryBuilder('announcement')
.where({
isActive: true,
display: 'dialog',
})
.getCount();
if (dialogCount >= this.config.maxDialogAnnouncements) {
throw new IdentifiableError('c0d15f15-f18e-4a40-bcb1-f310d58204ee', 'Too many dialog announcements.');
}
}
}

View file

@ -4,8 +4,10 @@
*/
import { Injectable } from '@nestjs/common';
import { ApiError } from '@/server/api/error.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { AnnouncementService } from '@/core/AnnouncementService.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
export const meta = {
tags: ['admin'],
@ -48,6 +50,14 @@ export const meta = {
},
},
},
errors: {
dialogLimitExceeded: {
message: 'Cannot create the announcement because there are too many active dialog-style announcements.',
code: 'DIALOG_LIMIT_EXCEEDED',
id: '7c1bc084-9c14-4bcf-a910-978cd8e99b5a',
},
},
} as const;
export const paramDef = {
@ -74,23 +84,30 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private announcementService: AnnouncementService,
) {
super(meta, paramDef, async (ps, me) => {
const { raw, packed } = await this.announcementService.create({
updatedAt: null,
title: ps.title,
text: ps.text,
/* eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- 空の文字列の場合、nullを渡すようにするため */
imageUrl: ps.imageUrl || null,
icon: ps.icon,
display: ps.display,
forExistingUsers: ps.forExistingUsers,
forRoles: ps.forRoles,
silence: ps.silence,
needConfirmationToRead: ps.needConfirmationToRead,
confetti: ps.confetti,
userId: ps.userId,
}, me);
try {
const { raw, packed } = await this.announcementService.create({
updatedAt: null,
title: ps.title,
text: ps.text,
/* eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- 空の文字列の場合、nullを渡すようにするため */
imageUrl: ps.imageUrl || null,
icon: ps.icon,
display: ps.display,
forExistingUsers: ps.forExistingUsers,
forRoles: ps.forRoles,
silence: ps.silence,
needConfirmationToRead: ps.needConfirmationToRead,
confetti: ps.confetti,
userId: ps.userId,
}, me);
return packed;
return packed;
} catch (e) {
if (e instanceof IdentifiableError) {
if (e.id === 'c0d15f15-f18e-4a40-bcb1-f310d58204ee') throw new ApiError(meta.errors.dialogLimitExceeded);
}
throw e;
}
});
}
}

View file

@ -8,6 +8,7 @@ import { Endpoint } from '@/server/api/endpoint-base.js';
import type { AnnouncementsRepository } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
import { AnnouncementService } from '@/core/AnnouncementService.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import { ApiError } from '../../../error.js';
export const meta = {
@ -23,6 +24,11 @@ export const meta = {
code: 'NO_SUCH_ANNOUNCEMENT',
id: 'd3aae5a7-6372-4cb4-b61c-f511ffc2d7cc',
},
dialogLimitExceeded: {
message: 'Cannot create the announcement because there are too many active dialog-style announcements.',
code: 'DIALOG_LIMIT_EXCEEDED',
id: '1a5db7ca-6d3f-44bc-ac51-05cae93b643c',
},
},
} as const;
@ -58,21 +64,28 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (announcement == null) throw new ApiError(meta.errors.noSuchAnnouncement);
await this.announcementService.update(announcement, {
updatedAt: new Date(),
title: ps.title,
text: ps.text,
/* eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- 空の文字列の場合、nullを渡すようにするため */
imageUrl: ps.imageUrl || null,
display: ps.display,
icon: ps.icon,
forExistingUsers: ps.forExistingUsers,
forRoles: ps.forRoles,
silence: ps.silence,
needConfirmationToRead: ps.needConfirmationToRead,
confetti: ps.confetti,
isActive: ps.isActive,
}, me);
try {
await this.announcementService.update(announcement, {
updatedAt: new Date(),
title: ps.title,
text: ps.text,
/* eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- 空の文字列の場合、nullを渡すようにするため */
imageUrl: ps.imageUrl || null,
display: ps.display,
icon: ps.icon,
forExistingUsers: ps.forExistingUsers,
forRoles: ps.forRoles,
silence: ps.silence,
needConfirmationToRead: ps.needConfirmationToRead,
confetti: ps.confetti,
isActive: ps.isActive,
}, me);
} catch (e) {
if (e instanceof IdentifiableError) {
if (e.id === 'c0d15f15-f18e-4a40-bcb1-f310d58204ee') throw new ApiError(meta.errors.dialogLimitExceeded);
}
throw e;
}
});
}
}