Add forced CW
Some checks failed
API report (misskey.js) / report (push) Successful in 43s
Lint / pnpm_install (pull_request) Successful in 35s
API report (misskey.js) / report (pull_request) Successful in 1m4s
Test (misskey.js) / test (22.x) (pull_request) Successful in 1m32s
Test (production install and build) / production (22.x) (pull_request) Successful in 1m35s
Test (frontend) / vitest (22.x) (pull_request) Successful in 2m22s
Lint / lint (misskey-js) (pull_request) Successful in 50s
Lint / lint (backend) (pull_request) Failing after 1m53s
Test (backend) / validate-api-json (22.x) (pull_request) Successful in 2m26s
Test (backend) / unit (22.x) (pull_request) Failing after 3m45s
Lint / lint (sw) (pull_request) Successful in 38s
Lint / typecheck (misskey-js) (pull_request) Successful in 40s
Lint / typecheck (backend) (pull_request) Failing after 1m55s
Test (backend) / e2e (22.x) (pull_request) Failing after 8m25s
Lint / lint (frontend) (pull_request) Failing after 8m23s

This commit is contained in:
Leah 2025-03-06 11:58:12 +01:00
parent 54cd470ddd
commit d03dd7fc46
35 changed files with 703 additions and 50 deletions

View file

@ -618,6 +618,8 @@ unsetUserBanner: "Unset banner"
unsetUserBannerConfirm: "Are you sure you want to unset the banner?"
unsetUserMutualLink: "Unset mutual link"
unsetUserMutualLinkConfirm: "Are you sure you want to unset the mutual link?"
mandatoryCW: "Force content warning"
mandatoryCWDescription: "Applies a content warning to all posts created by this user. If the post already has a CW, then this is appended to the end."
deleteAllFiles: "Delete all files"
deleteAllFilesConfirm: "Are you sure that you want to delete all files?"
removeAllFollowing: "Unfollow all followed users"
@ -2661,6 +2663,9 @@ _abuse:
list: "list"
resolver: "resolver"
_moderationLogTypes:
approve: "Approved"
decline: "Declined"
setMandatoryCW: "Set content warning for user"
createRole: "Role created"
deleteRole: "Role deleted"
updateRole: "Role updated"

20
locales/index.d.ts vendored
View file

@ -2480,6 +2480,14 @@ export interface Locale extends ILocale {
*
*/
"unsetUserMutualLinkConfirm": string;
/**
*
*/
"mandatoryCW": string;
/**
* 稿稿CWを持っている場合
*/
"mandatoryCWDescription": string;
/**
*
*/
@ -10319,6 +10327,18 @@ export interface Locale extends ILocale {
"resolver": string;
};
"_moderationLogTypes": {
/**
*
*/
"approve": string;
/**
* 退
*/
"decline": string;
/**
*
*/
"setMandatoryCW": string;
/**
*
*/

View file

@ -616,6 +616,8 @@ unsetUserBanner: "バナーを解除"
unsetUserBannerConfirm: "バナーを解除しますか?"
unsetUserMutualLink: "相互リンクを削除"
unsetUserMutualLinkConfirm: "相互リンクを削除しますか?"
mandatoryCW: "強制内容警告"
mandatoryCWDescription: "このユーザが作成したすべての投稿に内容の警告を適用する。投稿がすでにCWを持っている場合、これは末尾に追加されます。"
deleteAllFiles: "すべてのファイルを削除"
deleteAllFilesConfirm: "すべてのファイルを削除しますか?"
removeAllFollowing: "フォローを全解除"
@ -2725,6 +2727,9 @@ _abuse:
resolver: "リソルバー"
_moderationLogTypes:
approve: "承認済み"
decline: "辞退"
setMandatoryCW: "ユーザーへのコンテンツ警告の設定"
createRole: "ロールを作成"
deleteRole: "ロールを削除"
updateRole: "ロールを更新"

View file

@ -0,0 +1,11 @@
export class AddUserMandatoryCW1738043621143 {
name = 'AddUserCW1738043621143'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "user" ADD "mandatoryCW" text`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "mandatoryCW"`);
}
}

View file

@ -209,7 +209,7 @@ export class NoteCreateService implements OnApplicationShutdown {
}
@bindThis
public async create(user: {
public async create(user: MiUser & {
id: MiUser['id'];
username: MiUser['username'];
host: MiUser['host'];
@ -569,7 +569,7 @@ export class NoteCreateService implements OnApplicationShutdown {
}
@bindThis
private async postNoteCreated(note: MiNote, user: {
private async postNoteCreated(note: MiNote, user: MiUser & {
id: MiUser['id'];
username: MiUser['username'];
host: MiUser['host'];
@ -739,7 +739,7 @@ export class NoteCreateService implements OnApplicationShutdown {
//#region AP deliver
if (this.userEntityService.isLocalUser(user)) {
(async () => {
const noteActivity = await this.renderNoteOrRenoteActivity(data, note);
const noteActivity = await this.renderNoteOrRenoteActivity(data, note, user);
const dm = this.apDeliverManagerService.createDeliverManager(user, noteActivity);
// メンションされたリモートユーザーに配送
@ -907,12 +907,12 @@ export class NoteCreateService implements OnApplicationShutdown {
}
@bindThis
private async renderNoteOrRenoteActivity(data: NoteCreateOption, note: MiNote) {
private async renderNoteOrRenoteActivity(data: NoteCreateOption, note: MiNote, user: MiUser) {
if (data.localOnly) return null;
const content = this.isRenote(data) && !this.isQuote(data)
? this.apRendererService.renderAnnounce(data.renote.uri ? data.renote.uri : `${this.config.url}/notes/${data.renote.id}`, note)
: this.apRendererService.renderCreate(await this.apRendererService.renderNote(note, false), note);
: this.apRendererService.renderCreate(await this.apRendererService.renderNote(note, user, false), note);
return this.apRendererService.addContext(content);
}

View file

@ -100,7 +100,7 @@ export class PollService {
if (user == null) throw new Error('user not found');
if (this.userEntityService.isLocalUser(user)) {
const content = this.apRendererService.addContext(this.apRendererService.renderUpdate(await this.apRendererService.renderNote(note, false), user));
const content = this.apRendererService.addContext(this.apRendererService.renderUpdate(await this.apRendererService.renderNote(note, user, false), user));
this.apDeliverManagerService.deliverToFollowers(user, content);
this.relayService.deliverToRelays(user, content);
}

View file

@ -28,6 +28,7 @@ import { bindThis } from '@/decorators.js';
import { CustomEmojiService } from '@/core/CustomEmojiService.js';
import { isNotNull } from '@/misc/is-not-null.js';
import { IdService } from '@/core/IdService.js';
import { appendContentWarning } from '@/misc/append-content-warning.js';
import { JsonLdService } from './JsonLdService.js';
import { ApMfmService } from './ApMfmService.js';
import { CONTEXTS } from './misc/contexts.js';
@ -319,7 +320,7 @@ export class ApRendererService {
}
@bindThis
public async renderNote(note: MiNote, dive = true): Promise<IPost> {
public async renderNote(note: MiNote, author: MiUser, dive = true): Promise<IPost> {
const getPromisedFiles = async (ids: string[]): Promise<MiDriveFile[]> => {
if (ids.length === 0) return [];
const items = await this.driveFilesRepository.findBy({ id: In(ids) });
@ -333,14 +334,14 @@ export class ApRendererService {
inReplyToNote = await this.notesRepository.findOneBy({ id: note.replyId });
if (inReplyToNote != null) {
const inReplyToUserExist = await this.usersRepository.exists({ where: { id: inReplyToNote.userId } });
const inReplyToUser = await this.usersRepository.findOneBy({ id: inReplyToNote.userId });
if (inReplyToUserExist) {
if (inReplyToUser) {
if (inReplyToNote.uri) {
inReplyTo = inReplyToNote.uri;
} else {
if (dive) {
inReplyTo = await this.renderNote(inReplyToNote, false);
inReplyTo = await this.renderNote(inReplyToNote, inReplyToUser, false);
} else {
inReplyTo = `${this.config.url}/notes/${inReplyToNote.id}`;
}
@ -403,7 +404,13 @@ export class ApRendererService {
apAppend += `\n\nRE: ${quote}`;
}
const summary = note.cw === '' ? String.fromCharCode(0x200B) : note.cw;
let summary = note.cw === '' ? String.fromCharCode(0x200B) : note.cw;
// Apply mandatory CW, if applicable
if (author.mandatoryCW) {
summary = appendContentWarning(summary, author.mandatoryCW);
}
const { content, noMisskeyContent } = this.apMfmService.getNoteHtml(note, apAppend);

View file

@ -132,11 +132,12 @@ export class Resolver {
case 'notes':
return this.notesRepository.findOneByOrFail({ id: parsed.id })
.then(async note => {
const author = await this.usersRepository.findOneByOrFail({ id: note.userId });
if (parsed.rest === 'activity') {
// this refers to the create activity and not the note itself
return this.apRendererService.addContext(this.apRendererService.renderCreate(await this.apRendererService.renderNote(note), note));
return this.apRendererService.addContext(this.apRendererService.renderCreate(await this.apRendererService.renderNote(note, author), note));
} else {
return this.apRendererService.renderNote(note);
return this.apRendererService.renderNote(note, author);
}
});
case 'users':

View file

@ -492,6 +492,7 @@ export class UserEntityService implements OnModuleInit {
isBot: user.isBot,
isCat: user.isCat,
speakAsCat: user.speakAsCat,
mandatoryCW: user.mandatoryCW,
instance: user.host ? this.federatedInstanceService.federatedInstanceCache.fetch(user.host).then(instance => instance ? {
name: instance.name,
softwareName: instance.softwareName,

View file

@ -0,0 +1,62 @@
/*
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
/*
* Important Note: this file must be kept in sync with packages/frontend/src/scripts/append-content-warning.ts
*/
/**
* Appends an additional content warning onto an existing one.
* The additional value will not be added if it already exists within the original input.
* @param original Existing content warning
* @param additional Content warning to append
* @param reverse If true, then the additional CW will be prepended instead of appended.
*/
export function appendContentWarning(original: string | null | undefined, additional: string, reverse = false): string {
// Easy case - if original is empty, then additional replaces it.
if (!original) {
return additional;
}
// Easy case - if the additional CW is empty, then don't append it.
if (!additional) {
return original;
}
// If the additional CW already exists in the input, then we *don't* append another copy!
if (includesWholeWord(original, additional)) {
return original;
}
return reverse
? `${additional}, ${original}`
: `${original}, ${additional}`;
}
/**
* Emulates a regular expression like /\b(pattern)\b/, but with a raw non-regex pattern.
* We're checking to see whether the default CW appears inside the existing CW, but *only* if there's word boundaries on either side.
* @param input Input string to search
* @param target Target word / phrase to search for
*/
function includesWholeWord(input: string, target: string): boolean {
const parts = input.split(target);
// The additional string could appear multiple times within the original input.
// We need to check each occurrence, since any of them could potentially match.
for (let i = 0; i + 1 < parts.length; i++) {
const before = parts[i];
const after = parts[i + 1];
// If either the preceding or following tokens are a "word", then this "match" is actually just part of a longer word.
// Likewise, if *neither* token is a word, then this is a real match and the CW already exists in the input.
if (!/\w$/.test(before) && !/^\w/.test(after)) {
return true;
}
}
// If we don't match, then there is no existing CW.
return false;
}

View file

@ -4,6 +4,7 @@
*/
import type { Packed } from './json-schema.js';
import {appendContentWarning} from "@/misc/append-content-warning.js";
/**
* 稿
@ -20,9 +21,15 @@ export const getNoteSummary = (note: Packed<'Note'>): string => {
let summary = '';
// Append mandatory CW, if applicable
let cw = note.cw;
if (note.user.mandatoryCW) {
cw = appendContentWarning(cw, note.user.mandatoryCW);
}
// 本文
if (note.cw != null) {
summary += note.cw;
if (cw != null) {
summary += `CW: ${cw}`;
} else {
summary += note.text ? note.text : '';
}

View file

@ -277,6 +277,15 @@ export class MiUser {
})
public signupReason: string | null;
/**
* Specifies a Content Warning that should be forcibly applied to all notes by this user.
* If null (default), then no Content Warning is applied.
*/
@Column('text', {
nullable: true,
})
public mandatoryCW: string | null;
constructor(data: Partial<MiUser>) {
if (data == null) return;

View file

@ -107,6 +107,10 @@ export const packedUserLiteSchema = {
},
},
},
mandatoryCW: {
type: 'string',
nullable: true, optional: false,
},
isBot: {
type: 'boolean',
nullable: false, optional: true,

View file

@ -88,15 +88,16 @@ export class ActivityPubServerService {
/**
* Pack Create<Note> or Announce Activity
* @param note Note
* @param author Author of the note
*/
@bindThis
private async packActivity(note: MiNote): Promise<any> {
private async packActivity(note: MiNote, author: MiUser): Promise<any> {
if (isRenote(note) && !isQuote(note)) {
const renote = await this.notesRepository.findOneByOrFail({ id: note.renoteId });
return this.apRendererService.renderAnnounce(renote.uri ? renote.uri : `${this.config.url}/notes/${renote.id}`, note);
}
return this.apRendererService.renderCreate(await this.apRendererService.renderNote(note, false), note);
return this.apRendererService.renderCreate(await this.apRendererService.renderNote(note, author, false), note);
}
@bindThis
@ -390,7 +391,7 @@ export class ActivityPubServerService {
this.notesRepository.findOneByOrFail({ id: pining.noteId }))))
.filter(note => !note.localOnly && ['public', 'home'].includes(note.visibility));
const renderedNotes = await Promise.all(pinnedNotes.map(note => this.apRendererService.renderNote(note)));
const renderedNotes = await Promise.all(pinnedNotes.map(note => this.apRendererService.renderNote(note, user)));
const rendered = this.apRendererService.renderOrderedCollection(
`${this.config.url}/users/${userId}/collections/featured`,
@ -461,7 +462,7 @@ export class ActivityPubServerService {
if (sinceId) notes.reverse();
const activities = await Promise.all(notes.map(note => this.packActivity(note)));
const activities = await Promise.all(notes.map(note => this.packActivity(note, user)));
const rendered = this.apRendererService.renderOrderedCollectionPage(
`${partOf}?${url.query({
page: 'true',
@ -592,7 +593,9 @@ export class ActivityPubServerService {
reply.header('Cache-Control', 'public, max-age=180');
this.setResponseType(request, reply);
return this.apRendererService.addContext(await this.apRendererService.renderNote(note, false));
const author = await this.usersRepository.findOneByOrFail({ id: note.userId });
return this.apRendererService.addContext(await this.apRendererService.renderNote(note, author, false));
});
// note activity
@ -613,7 +616,9 @@ export class ActivityPubServerService {
reply.header('Cache-Control', 'public, max-age=180');
this.setResponseType(request, reply);
return (this.apRendererService.addContext(await this.packActivity(note)));
const author = await this.usersRepository.findOneByOrFail({ id: note.userId });
return (this.apRendererService.addContext(await this.packActivity(note, author)));
});
// outbox

View file

@ -29,6 +29,7 @@ import * as ep___admin_avatarDecorations_create from './endpoints/admin/avatar-d
import * as ep___admin_avatarDecorations_delete from './endpoints/admin/avatar-decorations/delete.js';
import * as ep___admin_avatarDecorations_list from './endpoints/admin/avatar-decorations/list.js';
import * as ep___admin_avatarDecorations_update from './endpoints/admin/avatar-decorations/update.js';
import * as ep___admin_cw_user from './endpoints/admin/cw-user.js';
import * as ep___admin_unsetUserAvatar from './endpoints/admin/unset-user-avatar.js';
import * as ep___admin_unsetUserBanner from './endpoints/admin/unset-user-banner.js';
import * as ep___admin_unsetUserMutualLink from './endpoints/admin/unset-user-mutual-link.js';
@ -429,6 +430,7 @@ const $admin_avatarDecorations_create: Provider = { provide: 'ep:admin/avatar-de
const $admin_avatarDecorations_delete: Provider = { provide: 'ep:admin/avatar-decorations/delete', useClass: ep___admin_avatarDecorations_delete.default };
const $admin_avatarDecorations_list: Provider = { provide: 'ep:admin/avatar-decorations/list', useClass: ep___admin_avatarDecorations_list.default };
const $admin_avatarDecorations_update: Provider = { provide: 'ep:admin/avatar-decorations/update', useClass: ep___admin_avatarDecorations_update.default };
const $admin_cwUser: Provider = { provide: 'ep:admin/cw-user', useClass: ep___admin_cw_user.default };
const $admin_unsetUserAvatar: Provider = { provide: 'ep:admin/unset-user-avatar', useClass: ep___admin_unsetUserAvatar.default };
const $admin_unsetUserBanner: Provider = { provide: 'ep:admin/unset-user-banner', useClass: ep___admin_unsetUserBanner.default };
const $admin_unsetUserMutualLink: Provider = { provide: 'ep:admin/unset-user-mutual-link', useClass: ep___admin_unsetUserMutualLink.default };
@ -833,6 +835,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
$admin_avatarDecorations_delete,
$admin_avatarDecorations_list,
$admin_avatarDecorations_update,
$admin_cwUser,
$admin_unsetUserAvatar,
$admin_unsetUserBanner,
$admin_unsetUserMutualLink,

View file

@ -29,6 +29,7 @@ import * as ep___admin_avatarDecorations_create from './endpoints/admin/avatar-d
import * as ep___admin_avatarDecorations_delete from './endpoints/admin/avatar-decorations/delete.js';
import * as ep___admin_avatarDecorations_list from './endpoints/admin/avatar-decorations/list.js';
import * as ep___admin_avatarDecorations_update from './endpoints/admin/avatar-decorations/update.js';
import * as ep___cw_user from './endpoints/admin/cw-user.js';
import * as ep___admin_unsetUserAvatar from './endpoints/admin/unset-user-avatar.js';
import * as ep___admin_unsetUserBanner from './endpoints/admin/unset-user-banner.js';
import * as ep___admin_unsetUserMutualLink from './endpoints/admin/unset-user-mutual-link.js';
@ -427,6 +428,7 @@ const eps = [
['admin/avatar-decorations/delete', ep___admin_avatarDecorations_delete],
['admin/avatar-decorations/list', ep___admin_avatarDecorations_list],
['admin/avatar-decorations/update', ep___admin_avatarDecorations_update],
['admin/cw-user', ep___cw_user],
['admin/unset-user-avatar', ep___admin_unsetUserAvatar],
['admin/unset-user-banner', ep___admin_unsetUserBanner],
['admin/unset-user-mutual-link', ep___admin_unsetUserMutualLink],

View file

@ -0,0 +1,67 @@
/*
* 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 { UsersRepository } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
import { CacheService } from '@/core/CacheService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
export const meta = {
tags: ['admin'],
requireCredential: true,
requireModerator: true,
kind: 'write:admin:cw-user',
} as const;
export const paramDef = {
type: 'object',
properties: {
userId: { type: 'string', format: 'misskey:id' },
cw: { type: 'string', nullable: true },
},
required: ['userId', 'cw'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.usersRepository)
private readonly usersRepository: UsersRepository,
private readonly globalEventService: GlobalEventService,
private readonly cacheService: CacheService,
private readonly moderationLogService: ModerationLogService,
) {
super(meta, paramDef, async (ps, me) => {
const user = await this.cacheService.findUserById(ps.userId);
// Skip if there's nothing to do
if (user.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, 'setMandatoryCW', {
newCW: ps.cw,
oldCW: user.mandatoryCW,
userId: user.id,
userUsername: user.username,
userHost: user.host,
});
await this.usersRepository.update(ps.userId, {
// Collapse empty strings to null
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
mandatoryCW: ps.cw || null,
});
// Synchronize caches and other processes
this.globalEventService.publishInternalEvent('localUserUpdated', { id: ps.userId });
});
}
}

View file

@ -91,6 +91,7 @@ export const moderationLogTypes = [
'deleteGlobalAnnouncement',
'deleteUserAnnouncement',
'resetPassword',
'setMandatoryCW',
'regenerateUserToken',
'suspendRemoteInstance',
'unsuspendRemoteInstance',
@ -360,7 +361,14 @@ export type ModerationLogPayloads = {
userId: string;
userUsername: string;
userMutualLinkSections: { name: string | null; mutualLinks: { id: string; url: string; fileId: string; description: string | null; imgSrc: string; }[]; }[] | []
}
};
setMandatoryCW: {
newCW: string | null;
oldCW: string | null;
userId: string;
userUsername: string;
userHost: string | null;
};
};
export type MinimumUser = {

View file

@ -2,6 +2,7 @@
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { IdService } from '@/core/IdService.js';
process.env.NODE_ENV = 'test';
@ -20,7 +21,7 @@ import { CoreModule } from '@/core/CoreModule.js';
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
import { LoggerService } from '@/core/LoggerService.js';
import type { IActor, IApDocument, ICollection, IObject, IPost } from '@/core/activitypub/type.js';
import { MiMeta, MiNote, UserProfilesRepository } from '@/models/_.js';
import { MiMeta, MiNote, MiUser, UserProfilesRepository } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
import { secureRndstr } from '@/misc/secure-rndstr.js';
import { DownloadService } from '@/core/DownloadService.js';
@ -94,6 +95,7 @@ describe('ActivityPub', () => {
let rendererService: ApRendererService;
let jsonLdService: JsonLdService;
let resolver: MockResolver;
let idService: IdService;
const metaInitial = {
cacheRemoteFiles: true,
@ -137,6 +139,7 @@ describe('ActivityPub', () => {
imageService = app.get<ApImageService>(ApImageService);
jsonLdService = app.get<JsonLdService>(JsonLdService);
resolver = new MockResolver(await app.resolve<LoggerService>(LoggerService));
idService = app.get<IdService>(IdService);
// Prevent ApPersonService from fetching instance, as it causes Jest import-after-test error
const federatedInstanceService = app.get<FederatedInstanceService>(FederatedInstanceService);
@ -474,4 +477,147 @@ describe('ActivityPub', () => {
});
});
});
describe(ApRendererService, () => {
let note: MiNote;
let author: MiUser;
beforeEach(() => {
author = new MiUser({
id: idService.gen(),
});
note = new MiNote({
id: idService.gen(),
userId: author.id,
visibility: 'public',
localOnly: false,
text: 'Note text',
cw: null,
renoteCount: 0,
repliesCount: 0,
clippedCount: 0,
reactions: {},
fileIds: [],
attachedFileTypes: [],
visibleUserIds: [],
mentions: [],
// This is fucked tbh - it's JSON stored in a TEXT column that gets parsed/serialized all over the place
mentionedRemoteUsers: '[]',
reactionAndUserPairCache: [],
emojis: [],
tags: [],
hasPoll: false,
});
});
describe('renderNote', () => {
describe('summary', () => {
// I actually don't know why it does this, but the logic was already there so I've preserved it.
it('should be zero-width space when CW is empty string', async () => {
note.cw = '';
const result = await rendererService.renderNote(note, author, false);
expect(result.summary).toBe(String.fromCharCode(0x200B));
});
it('should be undefined when CW is null', async () => {
const result = await rendererService.renderNote(note, author, false);
expect(result.summary).toBeUndefined();
});
it('should be CW when present without mandatoryCW', async () => {
note.cw = 'original';
const result = await rendererService.renderNote(note, author, false);
expect(result.summary).toBe('original');
});
it('should be mandatoryCW when present without CW', async () => {
author.mandatoryCW = 'mandatory';
const result = await rendererService.renderNote(note, author, false);
expect(result.summary).toBe('mandatory');
});
it('should be merged when CW and mandatoryCW are both present', async () => {
note.cw = 'original';
author.mandatoryCW = 'mandatory';
const result = await rendererService.renderNote(note, author, false);
expect(result.summary).toBe('original, mandatory');
});
it('should be CW when CW includes mandatoryCW', async () => {
note.cw = 'original and mandatory';
author.mandatoryCW = 'mandatory';
const result = await rendererService.renderNote(note, author, false);
expect(result.summary).toBe('original and mandatory');
});
});
});
describe('renderUpnote', () => {
describe('summary', () => {
// I actually don't know why it does this, but the logic was already there so I've preserved it.
it('should be zero-width space when CW is empty string', async () => {
note.cw = '';
const result = await rendererService.renderUpNote(note, author, false);
expect(result.summary).toBe(String.fromCharCode(0x200B));
});
it('should be undefined when CW is null', async () => {
const result = await rendererService.renderUpNote(note, author, false);
expect(result.summary).toBeUndefined();
});
it('should be CW when present without mandatoryCW', async () => {
note.cw = 'original';
const result = await rendererService.renderUpNote(note, author, false);
expect(result.summary).toBe('original');
});
it('should be mandatoryCW when present without CW', async () => {
author.mandatoryCW = 'mandatory';
const result = await rendererService.renderUpNote(note, author, false);
expect(result.summary).toBe('mandatory');
});
it('should be merged when CW and mandatoryCW are both present', async () => {
note.cw = 'original';
author.mandatoryCW = 'mandatory';
const result = await rendererService.renderUpNote(note, author, false);
expect(result.summary).toBe('original, mandatory');
});
it('should be CW when CW includes mandatoryCW', async () => {
note.cw = 'original and mandatory';
author.mandatoryCW = 'mandatory';
const result = await rendererService.renderUpNote(note, author, false);
expect(result.summary).toBe('original and mandatory');
});
});
});
});
});

View file

@ -0,0 +1,92 @@
/*
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { appendContentWarning } from '@/misc/append-content-warning.js';
describe(appendContentWarning, () => {
it('should return additional when original is null', () => {
const result = appendContentWarning(null, 'additional');
expect(result).toBe('additional');
});
it('should return additional when original is undefined', () => {
const result = appendContentWarning(undefined, 'additional');
expect(result).toBe('additional');
});
it('should return additional when original is empty', () => {
const result = appendContentWarning('', 'additional');
expect(result).toBe('additional');
});
it('should return original when additional is empty', () => {
const result = appendContentWarning('original', '');
expect(result).toBe('original');
});
it('should append additional when it does not exist in original', () => {
const result = appendContentWarning('original', 'additional');
expect(result).toBe('original, additional');
});
it('should append additional when it exists in original but has preceeding word', () => {
const result = appendContentWarning('notadditional', 'additional');
expect(result).toBe('notadditional, additional');
});
it('should append additional when it exists in original but has following word', () => {
const result = appendContentWarning('additionalnot', 'additional');
expect(result).toBe('additionalnot, additional');
});
it('should append additional when it exists in original multiple times but has preceeding or following word', () => {
const result = appendContentWarning('notadditional additionalnot', 'additional');
expect(result).toBe('notadditional additionalnot, additional');
});
it('should not append additional when it exists in original', () => {
const result = appendContentWarning('an additional word', 'additional');
expect(result).toBe('an additional word');
});
it('should not append additional when original starts with it', () => {
const result = appendContentWarning('additional word', 'additional');
expect(result).toBe('additional word');
});
it('should not append additional when original ends with it', () => {
const result = appendContentWarning('an additional', 'additional');
expect(result).toBe('an additional');
});
it('should not append additional when it appears multiple times', () => {
const result = appendContentWarning('an additional additional word', 'additional');
expect(result).toBe('an additional additional word');
});
it('should not append additional when it appears multiple times but some have preceeding or following', () => {
const result = appendContentWarning('a notadditional additional additionalnot word', 'additional');
expect(result).toBe('a notadditional additional additionalnot word');
});
it('should prepend additional when reverse is true', () => {
const result = appendContentWarning('original', 'additional', true);
expect(result).toBe('additional, original');
});
});

View file

@ -52,11 +52,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkNoteHeader :note="appearNote" :mini="true"/>
<MkInstanceTicker v-if="showTicker" :instance="appearNote.user.instance"/>
<div style="container-type: inline-size;">
<p v-if="appearNote.cw != null" :class="$style.cw">
<Mfm v-if="appearNote.cw != ''" style="margin-right: 8px;" :text="appearNote.cw" :author="appearNote.user" :nyaize="'respect'"/>
<p v-if="mergedCW != null" :class="$style.cw">
<Mfm v-if="mergedCW != ''" style="margin-right: 8px;" :text="mergedCW" :author="appearNote.user" :nyaize="'respect'"/>
<MkCwButton v-model="showContent" :text="appearNote.text" :renote="appearNote.renote" :files="appearNote.files" :poll="appearNote.poll" style="margin: 4px 0;"/>
</p>
<div v-show="appearNote.cw == null || showContent" :class="[{ [$style.contentCollapsed]: collapsed }]">
<div v-show="mergedCW == null || showContent" :class="[{ [$style.contentCollapsed]: collapsed }]">
<div :class="$style.text">
<span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span>
<MkA v-if="appearNote.replyId" :class="$style.replyIcon" :to="`/notes/${appearNote.replyId}`"><i class="ti ti-arrow-back-up"></i></MkA>
@ -172,6 +172,7 @@ import MkInstanceTicker from '@/components/MkInstanceTicker.vue';
import { pleaseLogin } from '@/scripts/please-login.js';
import { focusPrev, focusNext } from '@/scripts/focus.js';
import { checkWordMute } from '@/scripts/check-word-mute.js';
import { computeMergedCw } from "@/scripts/compute-merged-cw";
import { userPage } from '@/filters/user.js';
import number from '@/filters/number.js';
import * as os from '@/os.js';
@ -215,6 +216,8 @@ const currentClip = inject<Ref<Misskey.entities.Clip> | null>('currentClip', nul
const note = ref(deepClone(props.note));
const mergedCW = computed(() => computeMergedCw(appearNote.value));
// plugin
if (noteViewInterruptors.length > 0) {
onMounted(async () => {

View file

@ -66,11 +66,11 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</header>
<div :class="$style.noteContent">
<p v-if="appearNote.cw != null" :class="$style.cw">
<Mfm v-if="appearNote.cw != ''" style="margin-right: 8px;" :text="appearNote.cw" :author="appearNote.user" :nyaize="'respect'"/>
<p v-if="mergedCW != null" :class="$style.cw">
<Mfm v-if="mergedCW != ''" style="margin-right: 8px;" :text="mergedCW" :author="appearNote.user" :nyaize="'respect'"/>
<MkCwButton v-model="showContent" :text="appearNote.text" :renote="appearNote.renote" :files="appearNote.files" :poll="appearNote.poll"/>
</p>
<div v-show="appearNote.cw == null || showContent">
<div v-show="mergedCW == null || showContent">
<span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span>
<MkA v-if="appearNote.replyId" :class="$style.noteReplyTarget" :to="`/notes/${appearNote.replyId}`"><i class="ti ti-arrow-back-up"></i></MkA>
<Mfm
@ -214,6 +214,7 @@ import MkUrlPreview from '@/components/MkUrlPreview.vue';
import MkInstanceTicker from '@/components/MkInstanceTicker.vue';
import { pleaseLogin } from '@/scripts/please-login.js';
import { checkWordMute } from '@/scripts/check-word-mute.js';
import { computeMergedCw } from '@/scripts/compute-merged-cw';
import { userPage } from '@/filters/user.js';
import { notePage } from '@/filters/note.js';
import number from '@/filters/number.js';
@ -249,6 +250,8 @@ const inChannel = inject('inChannel', null);
const note = ref(deepClone(props.note));
const mergedCW = computed(() => computeMergedCw(appearNote.value));
// plugin
if (noteViewInterruptors.length > 0) {
onMounted(async () => {

View file

@ -9,11 +9,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<div :class="$style.main">
<MkNoteHeader :class="$style.header" :note="note" :mini="true"/>
<div>
<p v-if="note.cw != null" :class="$style.cw">
<Mfm v-if="note.cw != ''" style="margin-right: 8px;" :text="note.cw" :author="note.user" :nyaize="'respect'" :emojiUrls="note.emojis"/>
<p v-if="mergedCW != null" :class="$style.cw">
<Mfm v-if="mergedCW != ''" style="margin-right: 8px;" :text="mergedCW" :author="note.user" :nyaize="'respect'" :emojiUrls="note.emojis"/>
<MkCwButton v-model="showContent" :text="note.text" :files="note.files" :poll="note.poll"/>
</p>
<div v-show="note.cw == null || showContent">
<div v-show="mergedCW == null || showContent">
<MkSubNoteContent :class="$style.text" :note="note"/>
</div>
</div>
@ -22,16 +22,19 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import {computed, ref} from 'vue';
import * as Misskey from 'misskey-js';
import MkNoteHeader from '@/components/MkNoteHeader.vue';
import MkSubNoteContent from '@/components/MkSubNoteContent.vue';
import MkCwButton from '@/components/MkCwButton.vue';
import { computeMergedCw } from '@/scripts/compute-merged-cw';
const props = defineProps<{
note: Misskey.entities.Note;
}>();
const mergedCW = computed(() => computeMergedCw(appearNote.value));
const showContent = ref(false);
</script>

View file

@ -11,11 +11,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<div :class="$style.body">
<MkNoteHeader :class="$style.header" :note="note" :mini="true"/>
<div>
<p v-if="note.cw != null" :class="$style.cw">
<Mfm v-if="note.cw != ''" style="margin-right: 8px;" :text="note.cw" :author="note.user" :nyaize="'respect'"/>
<p v-if="mergedCW != null" :class="$style.cw">
<Mfm v-if="mergedCW != ''" style="margin-right: 8px;" :text="mergedCW" :author="note.user" :nyaize="'respect'"/>
<MkCwButton v-model="showContent" :text="note.text" :files="note.files" :poll="note.poll"/>
</p>
<div v-show="note.cw == null || showContent">
<div v-show="mergedCW == null || showContent">
<MkSubNoteContent :class="$style.text" :note="note"/>
</div>
</div>
@ -45,6 +45,7 @@ import * as Misskey from 'misskey-js';
import MkNoteHeader from '@/components/MkNoteHeader.vue';
import MkSubNoteContent from '@/components/MkSubNoteContent.vue';
import MkCwButton from '@/components/MkCwButton.vue';
import { computeMergedCw } from "@/scripts/compute-merged-cw";
import { notePage } from '@/filters/note.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { i18n } from '@/i18n.js';
@ -67,6 +68,8 @@ const muted = ref($i ? checkWordMute(props.note, $i, $i.mutedWords) : false);
const showContent = ref(false);
const replies = ref<Misskey.entities.Note[]>([]);
const mergedCW = computed(() => computeMergedCw(appearNote.value));
if (props.detail) {
misskeyApi('notes/children', {
noteId: props.note.id,

View file

@ -54,7 +54,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkKeyValue>
</div>
<MkTextarea v-model="moderationNote" manualSave>
<MkTextarea v-model="moderationNote" manualSave @update:modelValue="onModerationNoteChanged">
<template #label>{{ i18n.ts.moderationNote }}</template>
</MkTextarea>
@ -85,6 +85,10 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</div>
</MkFolder>
<MkInput v-model="mandatoryCW" type="text" manualSave @update:modelValue="onMandatoryCWChanged">
<template #label>{{ i18n.ts.mandatoryCW }}</template>
<template #caption>{{ i18n.ts.mandatoryCWDescription }}</template>
</MkInput>
</div>
</MkFolder>
@ -265,6 +269,7 @@ import { i18n } from '@/i18n.js';
import { iAmAdmin, iAmModerator, $i } from '@/account.js';
import MkRolePreview from '@/components/MkRolePreview.vue';
import MkPagination from '@/components/MkPagination.vue';
import MkInput from "@/components/MkInput.vue";
const props = withDefaults(defineProps<{
userId: string;
@ -288,6 +293,7 @@ const limited = ref(false);
const suspended = ref(false);
const deleted = ref(false);
const moderationNote = ref('');
const mandatoryCW = ref<string | null>(null);
const signupReason = ref('');
const filesPagination = {
@ -328,12 +334,7 @@ function createFetcher() {
deleted.value = info.value.isDeleted;
moderationNote.value = info.value.moderationNote;
signupReason.value = info.value.signupReason;
watch(moderationNote, async () => {
await misskeyApi('admin/update-user-note', {
userId: user.value.id, text: moderationNote.value,
}).then(refreshUser);
});
mandatoryCW.value = user.value.mandatoryCW;
});
}
@ -341,6 +342,16 @@ function refreshUser() {
init.value = createFetcher();
}
async function onMandatoryCWChanged(value: string) {
await os.apiWithDialog('admin/cw-user', { userId: props.userId, cw: value });
refreshUser();
}
async function onModerationNoteChanged(value: string) {
await misskeyApi('admin/update-user-note', { userId: props.userId, text: value });
refreshUser();
}
async function updateRemoteUser() {
await os.apiWithDialog('federation/update-remote-user', {
userId: user.value.id,

View file

@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<b
:class="{
[$style.logGreen]: ['createRole', 'addCustomEmoji', 'createGlobalAnnouncement', 'createUserAnnouncement', 'createAd', 'createInvitation', 'createAvatarDecoration'].includes(log.type),
[$style.logYellow]: ['markSensitiveDriveFile', 'resetPassword', 'regenerateUserToken', 'updateUserName', 'unsetUserAvatar', 'unsetUserBanner', 'unsetUserMutualLink'].includes(log.type),
[$style.logYellow]: ['markSensitiveDriveFile', 'resetPassword', 'setMandatoryCW', 'regenerateUserToken', 'updateUserName', 'unsetUserAvatar', 'unsetUserBanner', 'unsetUserMutualLink'].includes(log.type),
[$style.logRed]: ['suspend', 'approve', 'deleteRole', 'suspendRemoteInstance', 'deleteGlobalAnnouncement', 'deleteUserAnnouncement', 'deleteCustomEmoji', 'deleteNote', 'deleteDriveFile', 'deleteAd', 'deleteAvatarDecoration'].includes(log.type)
}"
>{{ i18n.ts._moderationLogTypes[log.type] }}</b>
@ -22,6 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<span v-else-if="log.type === 'approve'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</span>
<span v-else-if="log.type === 'unsuspend'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</span>
<span v-else-if="log.type === 'resetPassword'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</span>
<span v-else-if="log.type === 'setMandatoryCW'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</span>
<span v-else-if="log.type === 'regenerateUserToken'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</span>
<span v-else-if="log.type === 'assignRole'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }} <i class="ti ti-arrow-right"></i> {{ log.info.roleName }}</span>
<span v-else-if="log.type === 'unassignRole'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }} <i class="ti ti-equal-not"></i> {{ log.info.roleName }}</span>
@ -47,7 +48,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<span v-else-if="log.type === 'updateAvatarDecoration'">: {{ log.info.before.name }}</span>
<span v-else-if="log.type === 'deleteAvatarDecoration'">: {{ log.info.avatarDecoration.name }}</span>
</template>
<template #icon>
<template v-if="log.user" #icon>
<MkAvatar :user="log.user" :class="$style.avatar"/>
</template>
<template #suffix>
@ -83,6 +84,12 @@ SPDX-License-Identifier: AGPL-3.0-only
<template v-else-if="log.type === 'approve'">
<div>{{ i18n.ts.user }}: <MkA :to="`/admin/user/${log.info.userId}`" class="_link">@{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</MkA></div>
</template>
<template v-else-if="log.type === 'setMandatoryCW'">
<div>{{ i18n.ts.user }}: <MkA :to="`/admin/user/${log.info.userId}`" class="_link">@{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</MkA></div>
<div :class="$style.diff">
<CodeDiff :context="0" :hideHeader="true" :oldString="log.info.oldCW ?? ''" :newString="log.info.newCW ?? ''" maxHeight="150px"/>
</div>
</template>
<template v-else-if="log.type === 'unsuspend'">
<div>{{ i18n.ts.user }}: <MkA :to="`/admin/user/${log.info.userId}`" class="_link">@{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</MkA></div>
</template>

View file

@ -0,0 +1,62 @@
/*
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
/*
* Important Note: this file must be kept in sync with packages/backend/src/misc/append-content-warning.ts
*/
/**
* Appends an additional content warning onto an existing one.
* The additional value will not be added if it already exists within the original input.
* @param original Existing content warning
* @param additional Content warning to append
* @param reverse If true, then the additional CW will be prepended instead of appended.
*/
export function appendContentWarning(original: string | null | undefined, additional: string, reverse = false): string {
// Easy case - if original is empty, then additional replaces it.
if (!original) {
return additional;
}
// Easy case - if the additional CW is empty, then don't append it.
if (!additional) {
return original;
}
// If the additional CW already exists in the input, then we *don't* append another copy!
if (includesWholeWord(original, additional)) {
return original;
}
return reverse
? `${additional}, ${original}`
: `${original}, ${additional}`;
}
/**
* Emulates a regular expression like /\b(pattern)\b/, but with a raw non-regex pattern.
* We're checking to see whether the default CW appears inside the existing CW, but *only* if there's word boundaries on either side.
* @param input Input string to search
* @param target Target word / phrase to search for
*/
function includesWholeWord(input: string, target: string): boolean {
const parts = input.split(target);
// The additional string could appear multiple times within the original input.
// We need to check each occurrence, since any of them could potentially match.
for (let i = 0; i + 1 < parts.length; i++) {
const before = parts[i];
const after = parts[i + 1];
// If either the preceding or following tokens are a "word", then this "match" is actually just part of a longer word.
// Likewise, if *neither* token is a word, then this is a real match and the CW already exists in the input.
if (!/\w$/.test(before) && !/^\w/.test(after)) {
return true;
}
}
// If we don't match, then there is no existing CW.
return false;
}

View file

@ -0,0 +1,17 @@
/*
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as Misskey from 'misskey-js';
import { appendContentWarning } from '@/scripts/append-content-warning';
export function computeMergedCw(note: Misskey.entities.Note): string | null {
let cw = note.cw;
if (note.user.mandatoryCW) {
cw = appendContentWarning(cw, note.user.mandatoryCW);
}
return cw ?? null;
}

View file

@ -4,6 +4,7 @@
*/
import * as Misskey from 'misskey-js';
import { appendContentWarning } from './append-content-warning';
import { i18n } from '@/i18n.js';
/**
@ -25,9 +26,15 @@ export const getNoteSummary = (note?: Misskey.entities.Note | null): string => {
let summary = '';
// Append mandatory CW, if applicable
let cw = note.cw;
if (note.user.mandatoryCW) {
cw = appendContentWarning(cw, note.user.mandatoryCW);
}
// 本文
if (note.cw != null) {
summary += note.cw;
if (cw != null) {
summary += `CW: ${cw}`;
} else {
summary += note.text ? note.text : '';
}

View file

@ -137,6 +137,9 @@ type AdminAvatarDecorationsListResponse = operations['admin___avatar-decorations
// @public (undocumented)
type AdminAvatarDecorationsUpdateRequest = operations['admin___avatar-decorations___update']['requestBody']['content']['application/json'];
// @public (undocumented)
type AdminCwUserRequest = operations['admin___cw-user']['requestBody']['content']['application/json'];
// @public (undocumented)
type AdminDeclineUserRequest = operations['admin___decline-user']['requestBody']['content']['application/json'];
@ -1264,6 +1267,7 @@ declare namespace entities {
AdminAvatarDecorationsListRequest,
AdminAvatarDecorationsListResponse,
AdminAvatarDecorationsUpdateRequest,
AdminCwUserRequest,
AdminUnsetUserAvatarRequest,
AdminUnsetUserBannerRequest,
AdminUnsetUserMutualLinkRequest,
@ -2824,7 +2828,7 @@ type PagesUpdateRequest = operations['pages___update']['requestBody']['content']
function parse(acct: string): Acct;
// @public (undocumented)
export const permissions: readonly ["read:account", "write:account", "read:blocks", "write:blocks", "read:drive", "write:drive", "read:favorites", "write:favorites", "read:following", "write:following", "read:messaging", "write:messaging", "read:mutes", "write:mutes", "write:notes", "read:notifications", "write:notifications", "read:reactions", "write:reactions", "write:votes", "read:pages", "write:pages", "write:page-likes", "read:page-likes", "read:user-groups", "write:user-groups", "read:channels", "write:channels", "read:gallery", "write:gallery", "read:gallery-likes", "write:gallery-likes", "read:flash", "write:flash", "read:flash-likes", "write:flash-likes", "read:admin:abuse-user-reports", "read:admin:abuse-report-resolvers", "write:admin:abuse-report-resolvers", "read:admin:index-stats", "read:admin:table-stats", "read:admin:user-ips", "read:admin:meta", "write:admin:reset-password", "write:admin:regenerate-user-token", "write:admin:resolve-abuse-user-report", "write:admin:send-email", "read:admin:server-info", "read:admin:show-moderation-log", "read:admin:show-account-move-log", "read:admin:show-user", "read:admin:show-users", "write:admin:suspend-user", "write:admin:approve-user", "write:admin:decline-user", "write:admin:unsuspend-user", "write:admin:meta", "write:admin:user-name", "write:admin:user-note", "write:admin:user-avatar", "write:admin:user-banner", "write:admin:user-mutual-link", "write:admin:roles", "read:admin:roles", "write:admin:relays", "read:admin:relays", "write:admin:invite-codes", "read:admin:invite-codes", "write:admin:announcements", "read:admin:announcements", "write:admin:avatar-decorations", "read:admin:avatar-decorations", "write:admin:federation", "write:admin:indie-auth", "read:admin:indie-auth", "write:admin:account", "read:admin:account", "write:admin:emoji", "read:admin:emoji", "write:admin:queue", "read:admin:queue", "write:admin:promo", "write:admin:drive", "read:admin:drive", "write:admin:sso", "read:admin:sso", "write:admin:ad", "read:admin:ad", "write:invite-codes", "read:invite-codes", "write:clip-favorite", "read:clip-favorite", "read:federation", "write:report-abuse", "write:push-notification"];
export const permissions: readonly ["read:account", "write:account", "read:blocks", "write:blocks", "read:drive", "write:drive", "read:favorites", "write:favorites", "read:following", "write:following", "read:messaging", "write:messaging", "read:mutes", "write:mutes", "write:notes", "read:notifications", "write:notifications", "read:reactions", "write:reactions", "write:votes", "read:pages", "write:pages", "write:page-likes", "read:page-likes", "read:user-groups", "write:user-groups", "read:channels", "write:channels", "read:gallery", "write:gallery", "read:gallery-likes", "write:gallery-likes", "read:flash", "write:flash", "read:flash-likes", "write:flash-likes", "read:admin:abuse-user-reports", "read:admin:abuse-report-resolvers", "write:admin:abuse-report-resolvers", "write:admin:cw-user", "read:admin:index-stats", "read:admin:table-stats", "read:admin:user-ips", "read:admin:meta", "write:admin:reset-password", "write:admin:regenerate-user-token", "write:admin:resolve-abuse-user-report", "write:admin:send-email", "read:admin:server-info", "read:admin:show-moderation-log", "read:admin:show-account-move-log", "read:admin:show-user", "read:admin:show-users", "write:admin:suspend-user", "write:admin:approve-user", "write:admin:decline-user", "write:admin:unsuspend-user", "write:admin:meta", "write:admin:user-name", "write:admin:user-note", "write:admin:user-avatar", "write:admin:user-banner", "write:admin:user-mutual-link", "write:admin:roles", "read:admin:roles", "write:admin:relays", "read:admin:relays", "write:admin:invite-codes", "read:admin:invite-codes", "write:admin:announcements", "read:admin:announcements", "write:admin:avatar-decorations", "read:admin:avatar-decorations", "write:admin:federation", "write:admin:indie-auth", "read:admin:indie-auth", "write:admin:account", "read:admin:account", "write:admin:emoji", "read:admin:emoji", "write:admin:queue", "read:admin:queue", "write:admin:promo", "write:admin:drive", "read:admin:drive", "write:admin:sso", "read:admin:sso", "write:admin:ad", "read:admin:ad", "write:invite-codes", "read:invite-codes", "write:clip-favorite", "read:clip-favorite", "read:federation", "write:report-abuse", "write:push-notification"];
// @public (undocumented)
type PingResponse = operations['ping']['responses']['200']['content']['application/json'];

View file

@ -256,6 +256,17 @@ declare module '../api.js' {
credential?: string | null,
): Promise<SwitchCaseResponseType<E, P>>;
/**
* No description provided.
*
* **Credential required**: *Yes* / **Permission**: *write:admin:cw-user*
*/
request<E extends 'admin/cw-user', P extends Endpoints[E]['req']>(
endpoint: E,
params: P,
credential?: string | null,
): Promise<SwitchCaseResponseType<E, P>>;
/**
* No description provided.
*

View file

@ -36,6 +36,7 @@ import type {
AdminAvatarDecorationsListRequest,
AdminAvatarDecorationsListResponse,
AdminAvatarDecorationsUpdateRequest,
AdminCwUserRequest,
AdminUnsetUserAvatarRequest,
AdminUnsetUserBannerRequest,
AdminUnsetUserMutualLinkRequest,
@ -620,6 +621,7 @@ export type Endpoints = {
'admin/avatar-decorations/delete': { req: AdminAvatarDecorationsDeleteRequest; res: EmptyResponse };
'admin/avatar-decorations/list': { req: AdminAvatarDecorationsListRequest; res: AdminAvatarDecorationsListResponse };
'admin/avatar-decorations/update': { req: AdminAvatarDecorationsUpdateRequest; res: EmptyResponse };
'admin/cw-user': { req: AdminCwUserRequest; res: EmptyResponse };
'admin/unset-user-avatar': { req: AdminUnsetUserAvatarRequest; res: EmptyResponse };
'admin/unset-user-banner': { req: AdminUnsetUserBannerRequest; res: EmptyResponse };
'admin/unset-user-mutual-link': { req: AdminUnsetUserMutualLinkRequest; res: EmptyResponse };

View file

@ -39,6 +39,7 @@ export type AdminAvatarDecorationsDeleteRequest = operations['admin___avatar-dec
export type AdminAvatarDecorationsListRequest = operations['admin___avatar-decorations___list']['requestBody']['content']['application/json'];
export type AdminAvatarDecorationsListResponse = operations['admin___avatar-decorations___list']['responses']['200']['content']['application/json'];
export type AdminAvatarDecorationsUpdateRequest = operations['admin___avatar-decorations___update']['requestBody']['content']['application/json'];
export type AdminCwUserRequest = operations['admin___cw-user']['requestBody']['content']['application/json'];
export type AdminUnsetUserAvatarRequest = operations['admin___unset-user-avatar']['requestBody']['content']['application/json'];
export type AdminUnsetUserBannerRequest = operations['admin___unset-user-banner']['requestBody']['content']['application/json'];
export type AdminUnsetUserMutualLinkRequest = operations['admin___unset-user-mutual-link']['requestBody']['content']['application/json'];

View file

@ -219,6 +219,15 @@ export type paths = {
*/
post: operations['admin___avatar-decorations___update'];
};
'/admin/cw-user': {
/**
* admin/cw-user
* @description No description provided.
*
* **Credential required**: *Yes* / **Permission**: *write:admin:cw-user*
*/
post: operations['admin___cw-user'];
};
'/admin/unset-user-avatar': {
/**
* admin/unset-user-avatar
@ -3860,6 +3869,7 @@ export type components = {
offsetX?: number;
offsetY?: number;
}[];
mandatoryCW: string | null;
isBot?: boolean;
isCat?: boolean;
speakAsCat?: boolean;
@ -5233,9 +5243,9 @@ export type components = {
description: string | null;
langs: string[];
tosUrl: string | null;
/** @default https://github.com/misskey-dev/misskey */
/** @default https://git.woem.men/woem.men/forkey */
repositoryUrl: string | null;
/** @default https://github.com/misskey-dev/misskey/issues/new */
/** @default https://git.woem.men/woem.men/forkey/issues/new/choose */
feedbackUrl: string | null;
defaultDarkTheme: string | null;
defaultLightTheme: string | null;
@ -6969,6 +6979,59 @@ export type operations = {
};
};
};
/**
* admin/cw-user
* @description No description provided.
*
* **Credential required**: *Yes* / **Permission**: *write:admin:cw-user*
*/
'admin___cw-user': {
requestBody: {
content: {
'application/json': {
/** Format: misskey:id */
userId: string;
cw: string | null;
};
};
};
responses: {
/** @description OK (without any results) */
204: {
content: never;
};
/** @description Client error */
400: {
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Authentication error */
401: {
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Forbidden error */
403: {
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description I'm Ai */
418: {
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Internal server error */
500: {
content: {
'application/json': components['schemas']['Error'];
};
};
};
};
/**
* admin/unset-user-avatar
* @description No description provided.

View file

@ -48,6 +48,7 @@ export const permissions = [
'read:admin:abuse-user-reports',
'read:admin:abuse-report-resolvers',
'write:admin:abuse-report-resolvers',
'write:admin:cw-user',
'read:admin:index-stats',
'read:admin:table-stats',
'read:admin:user-ips',