implement mandatory CW for notes (resolves #910)

This commit is contained in:
Hazelnoot 2025-06-27 22:32:26 -04:00
parent 6f8d831e09
commit 92750240eb
29 changed files with 305 additions and 11 deletions

View file

@ -127,6 +127,7 @@ function generateDummyNote(override?: Partial<MiNote>): MiNote {
renoteUserInstance: null,
updatedAt: null,
processErrors: [],
mandatoryCW: null,
...override,
};
}

View file

@ -497,6 +497,9 @@ export class ApRendererService {
let summary = note.cw === '' ? String.fromCharCode(0x200B) : note.cw;
// Apply mandatory CW, if applicable
if (note.mandatoryCW) {
summary = appendContentWarning(summary, note.mandatoryCW);
}
if (author.mandatoryCW) {
summary = appendContentWarning(summary, author.mandatoryCW);
}

View file

@ -651,6 +651,7 @@ export class NoteEntityService implements OnModuleInit {
user: packedUsers?.get(note.userId) ?? this.userEntityService.pack(note.user ?? note.userId, me),
text: text,
cw: note.cw,
mandatoryCW: note.mandatoryCW,
visibility: note.visibility,
localOnly: note.localOnly,
reactionAcceptance: note.reactionAcceptance,

View file

@ -23,6 +23,9 @@ export const getNoteSummary = (note: Packed<'Note'>): string => {
// Append mandatory CW, if applicable
let cw = note.cw;
if (note.mandatoryCW) {
cw = appendContentWarning(cw, note.mandatoryCW);
}
if (note.user.mandatoryCW) {
cw = appendContentWarning(cw, note.user.mandatoryCW);
}

View file

@ -228,6 +228,15 @@ export class MiNote {
})
public processErrors: string[] | null;
/**
* Specifies a Content Warning that should be forcibly attached to this note.
* Does not replace the user's own CW.
*/
@Column('text', {
nullable: true,
})
public mandatoryCW: string | null;
//#region Denormalized fields
@Column('varchar', {
length: 128, nullable: true,

View file

@ -41,6 +41,10 @@ export const packedNoteSchema = {
type: 'string',
optional: true, nullable: true,
},
mandatoryCW: {
type: 'string',
optional: true, nullable: true,
},
userId: {
type: 'string',
optional: false, nullable: false,

View file

@ -34,6 +34,7 @@ export * as 'admin/avatar-decorations/list' from './endpoints/admin/avatar-decor
export * as 'admin/avatar-decorations/update' from './endpoints/admin/avatar-decorations/update.js';
export * as 'admin/captcha/current' from './endpoints/admin/captcha/current.js';
export * as 'admin/captcha/save' from './endpoints/admin/captcha/save.js';
export * as 'admin/cw-note' from './endpoints/admin/cw-note.js';
export * as 'admin/cw-user' from './endpoints/admin/cw-user.js';
export * as 'admin/decline-user' from './endpoints/admin/decline-user.js';
export * as 'admin/delete-account' from './endpoints/admin/delete-account.js';

View file

@ -0,0 +1,63 @@
/*
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { MiNote, MiUser, NotesRepository } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
export const meta = {
tags: ['admin'],
requireCredential: true,
requireModerator: true,
kind: 'write:admin:cw-note',
} as const;
export const paramDef = {
type: 'object',
properties: {
noteId: { type: 'string', format: 'misskey:id' },
cw: { type: 'string', nullable: true },
},
required: ['noteId', 'cw'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.notesRepository)
private readonly notesRepository: NotesRepository,
private readonly moderationLogService: ModerationLogService,
) {
super(meta, paramDef, async (ps, me) => {
const note = await this.notesRepository.findOneOrFail({
where: { id: ps.noteId },
relations: { user: true },
}) as MiNote & { user: MiUser };
// Skip if there's nothing to do
if (note.mandatoryCW === ps.cw) return;
// Log event first.
// This ensures that we don't "lose" the log if an error occurs
await this.moderationLogService.log(me, 'setMandatoryCWForNote', {
newCW: ps.cw,
oldCW: note.mandatoryCW,
noteId: note.id,
noteUserId: note.user.id,
noteUserUsername: note.user.username,
noteUserHost: note.user.host,
});
await this.notesRepository.update(ps.noteId, {
// Collapse empty strings to null
mandatoryCW: ps.cw || null,
});
});
}
}

View file

@ -46,7 +46,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
await this.usersRepository.update(ps.userId, {
// Collapse empty strings to null
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
mandatoryCW: ps.cw || null,
});

View file

@ -224,7 +224,13 @@ export class MastodonConverters {
// TODO avoid re-packing files for each edit
const files = await this.driveFileEntityService.packManyByIds(edit.fileIds);
const cw = appendContentWarning(edit.cw, noteUser.mandatoryCW) ?? '';
let cw = edit.cw ?? '';
if (note.mandatoryCW) {
cw = appendContentWarning(cw, note.mandatoryCW);
}
if (noteUser.mandatoryCW) {
cw = appendContentWarning(cw, noteUser.mandatoryCW);
}
const isQuote = renote && (edit.cw || edit.newText || edit.fileIds.length > 0 || note.replyId);
const quoteUri = isQuote
@ -299,7 +305,13 @@ export class MastodonConverters {
? quoteUri.then(quote => this.mfmService.toMastoApiHtml(mfm.parse(text), mentionedRemoteUsers, false, quote) ?? escapeMFM(text))
: '';
const cw = appendContentWarning(note.cw, noteUser.mandatoryCW) ?? '';
let cw = note.cw ?? '';
if (note.mandatoryCW) {
cw = appendContentWarning(cw, note.mandatoryCW);
}
if (noteUser.mandatoryCW) {
cw = appendContentWarning(cw, noteUser.mandatoryCW);
}
const reblogged = await this.mastodonDataService.hasReblog(note.id, me);

View file

@ -106,6 +106,7 @@ export const moderationLogTypes = [
'deleteUserAnnouncement',
'resetPassword',
'setMandatoryCW',
'setMandatoryCWForNote',
'setRemoteInstanceNSFW',
'unsetRemoteInstanceNSFW',
'suspendRemoteInstance',
@ -294,6 +295,14 @@ export type ModerationLogPayloads = {
userUsername: string;
userHost: string | null;
};
setMandatoryCWForNote: {
newCW: string | null;
oldCW: string | null;
noteId: string;
noteUserId: string;
noteUserUsername: string;
noteUserHost: string | null;
};
setRemoteInstanceNSFW: {
id: string;
host: string;