From 292d3b92295d194856cb73c66ac097180f70deb8 Mon Sep 17 00:00:00 2001
From: Hazelnoot
Date: Sat, 15 Feb 2025 23:08:02 -0500
Subject: [PATCH] add "reject quotes" toggle at user and instance level
+ improve, cleanup, and de-duplicate quote resolution
+ add warning message when quote cannot be loaded
+ add "process error" framework to display warnings when a note cannot be correctly loaded from another instance
---
locales/index.d.ts | 34 +++++
.../1739671352784-add_note_processErrors.js | 11 ++
.../1739671777344-add_user_rejectQuotes.js | 11 ++
...1739671847942-add_instance_rejectQuotes.js | 11 ++
.../backend/src/core/NoteCreateService.ts | 30 ++++
packages/backend/src/core/NoteEditService.ts | 5 +
.../core/activitypub/models/ApNoteService.ts | 143 ++++++++----------
.../core/entities/InstanceEntityService.ts | 1 +
.../src/core/entities/NoteEntityService.ts | 1 +
.../src/core/entities/UserEntityService.ts | 1 +
packages/backend/src/models/Instance.ts | 9 ++
packages/backend/src/models/Note.ts | 11 ++
packages/backend/src/models/User.ts | 9 ++
.../models/json-schema/federation-instance.ts | 5 +
.../backend/src/models/json-schema/note.ts | 8 +
.../backend/src/models/json-schema/user.ts | 4 +
.../admin/federation/update-instance.ts | 10 ++
.../api/endpoints/admin/reject-quotes.ts | 63 ++++++++
.../src/server/api/endpoints/notes/create.ts | 8 +
.../src/server/api/endpoints/notes/edit.ts | 8 +
packages/backend/src/types.ts | 22 +++
packages/frontend/src/components/MkNote.vue | 2 +-
.../src/components/MkNoteDetailed.vue | 2 +-
.../frontend/src/components/MkNoteSub.vue | 2 +-
.../frontend/src/components/MkPostForm.vue | 2 +-
.../frontend/src/components/SkErrorList.vue | 43 ++++++
packages/frontend/src/components/SkNote.vue | 2 +-
.../src/components/SkNoteDetailed.vue | 2 +-
.../frontend/src/components/SkNoteSub.vue | 2 +-
packages/frontend/src/pages/admin-user.vue | 19 +++
.../src/pages/admin/modlog.ModLog.vue | 12 ++
packages/frontend/src/pages/instance-info.vue | 12 ++
packages/frontend/src/pages/note.vue | 8 +-
packages/misskey-js/src/consts.ts | 19 +++
packages/misskey-js/src/entities.ts | 12 ++
sharkey-locales/en-US.yml | 10 ++
36 files changed, 466 insertions(+), 88 deletions(-)
create mode 100644 packages/backend/migration/1739671352784-add_note_processErrors.js
create mode 100644 packages/backend/migration/1739671777344-add_user_rejectQuotes.js
create mode 100644 packages/backend/migration/1739671847942-add_instance_rejectQuotes.js
create mode 100644 packages/backend/src/server/api/endpoints/admin/reject-quotes.ts
create mode 100644 packages/frontend/src/components/SkErrorList.vue
diff --git a/locales/index.d.ts b/locales/index.d.ts
index bf49869bf8..cc7884b8c1 100644
--- a/locales/index.d.ts
+++ b/locales/index.d.ts
@@ -8566,6 +8566,10 @@ export interface Locale extends ILocale {
* Un-silence users
*/
"write:admin:unsilence-user": string;
+ /**
+ * Allow/Reject quote posts from a user
+ */
+ "write:admin:reject-quotes": string;
/**
* View your list of scheduled notes
*/
@@ -10242,6 +10246,14 @@ export interface Locale extends ILocale {
* Accepted reports from remote instance
*/
"acceptRemoteInstanceReports": string;
+ /**
+ * Rejected quotes from user
+ */
+ "rejectQuotesUser": string;
+ /**
+ * Allowed quotes from user
+ */
+ "allowQuotesUser": string;
};
"_fileViewer": {
/**
@@ -11240,6 +11252,22 @@ export interface Locale extends ILocale {
* Reject reports from this instance
*/
"rejectReports": string;
+ /**
+ * Reject quote posts from this instance
+ */
+ "rejectQuotesInstance": string;
+ /**
+ * Reject quote posts from this user
+ */
+ "rejectQuotesUser": string;
+ /**
+ * Are you sure you wish to reject quote posts?
+ */
+ "rejectQuotesConfirm": string;
+ /**
+ * Are you sure you wish to allow quote posts?
+ */
+ "allowQuotesConfirm": string;
/**
* This host is blocked implicitly because a base domain is blocked. To unblock this host, first unblock the base domain(s).
*/
@@ -12109,6 +12137,12 @@ export interface Locale extends ILocale {
* 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.
*/
"mandatoryCWDescription": string;
+ "_processErrors": {
+ /**
+ * Unable to process quote. This post may be missing context.
+ */
+ "quoteUnavailable": string;
+ };
}
declare const locales: {
[lang: string]: Locale;
diff --git a/packages/backend/migration/1739671352784-add_note_processErrors.js b/packages/backend/migration/1739671352784-add_note_processErrors.js
new file mode 100644
index 0000000000..0be10125e1
--- /dev/null
+++ b/packages/backend/migration/1739671352784-add_note_processErrors.js
@@ -0,0 +1,11 @@
+export class AddNoteProcessErrors1739671352784 {
+ name = 'AddNoteProcessErrors1739671352784'
+
+ async up(queryRunner) {
+ await queryRunner.query(`ALTER TABLE "note" ADD "processErrors" text array`);
+ }
+
+ async down(queryRunner) {
+ await queryRunner.query(`ALTER TABLE "note" DROP COLUMN "processErrors"`);
+ }
+}
diff --git a/packages/backend/migration/1739671777344-add_user_rejectQuotes.js b/packages/backend/migration/1739671777344-add_user_rejectQuotes.js
new file mode 100644
index 0000000000..29ed90c8ff
--- /dev/null
+++ b/packages/backend/migration/1739671777344-add_user_rejectQuotes.js
@@ -0,0 +1,11 @@
+export class AddUserRejectQuotes1739671777344 {
+ name = 'AddUserRejectQuotes1739671777344'
+
+ async up(queryRunner) {
+ await queryRunner.query(`ALTER TABLE "user" ADD "rejectQuotes" boolean NOT NULL DEFAULT false`);
+ }
+
+ async down(queryRunner) {
+ await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "rejectQuotes"`);
+ }
+}
diff --git a/packages/backend/migration/1739671847942-add_instance_rejectQuotes.js b/packages/backend/migration/1739671847942-add_instance_rejectQuotes.js
new file mode 100644
index 0000000000..89774eb991
--- /dev/null
+++ b/packages/backend/migration/1739671847942-add_instance_rejectQuotes.js
@@ -0,0 +1,11 @@
+export class AddInstanceRejectQuotes1739671847942 {
+ name = 'AddInstanceRejectQuotes1739671847942'
+
+ async up(queryRunner) {
+ await queryRunner.query(`ALTER TABLE "instance" ADD "rejectQuotes" boolean NOT NULL DEFAULT false`);
+ }
+
+ async down(queryRunner) {
+ await queryRunner.query(`ALTER TABLE "instance" DROP COLUMN "rejectQuotes"`);
+ }
+}
diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts
index 8291db9b42..df31cb4247 100644
--- a/packages/backend/src/core/NoteCreateService.ts
+++ b/packages/backend/src/core/NoteCreateService.ts
@@ -144,6 +144,7 @@ type Option = {
uri?: string | null;
url?: string | null;
app?: MiApp | null;
+ processErrors?: string[] | null;
};
export type PureRenoteOption = Option & { renote: MiNote } & ({ text?: null } | { cw?: null } | { reply?: null } | { poll?: null } | { files?: null | [] });
@@ -309,6 +310,9 @@ export class NoteCreateService implements OnApplicationShutdown {
}
}
+ // Check quote permissions
+ await this.checkQuotePermissions(data, user);
+
// Check blocking
if (this.isRenote(data) && !this.isQuote(data)) {
if (data.renote.userHost === null) {
@@ -482,6 +486,7 @@ export class NoteCreateService implements OnApplicationShutdown {
renoteUserId: data.renote ? data.renote.userId : null,
renoteUserHost: data.renote ? data.renote.userHost : null,
userHost: user.host,
+ processErrors: data.processErrors,
});
// should really not happen, but better safe than sorry
@@ -1147,4 +1152,29 @@ export class NoteCreateService implements OnApplicationShutdown {
public async onApplicationShutdown(signal?: string | undefined): Promise {
await this.dispose();
}
+
+ @bindThis
+ public async checkQuotePermissions(data: Option, user: MiUser): Promise {
+ // Not a quote
+ if (!this.isRenote(data) || !this.isQuote(data)) return;
+
+ // User cannot quote
+ if (user.rejectQuotes) {
+ if (user.host == null) {
+ throw new IdentifiableError('1c0ea108-d1e3-4e8e-aa3f-4d2487626153', 'QUOTE_DISABLED_FOR_USER');
+ } else {
+ (data as Option).renote = null;
+ (data.processErrors ??= []).push('quoteUnavailable');
+ }
+ }
+
+ // Instance cannot quote
+ if (user.host) {
+ const instance = await this.federatedInstanceService.fetch(user.host);
+ if (instance?.rejectQuotes) {
+ (data as Option).renote = null;
+ (data.processErrors ??= []).push('quoteUnavailable');
+ }
+ }
+ }
}
diff --git a/packages/backend/src/core/NoteEditService.ts b/packages/backend/src/core/NoteEditService.ts
index 24a99156d2..7851af86b7 100644
--- a/packages/backend/src/core/NoteEditService.ts
+++ b/packages/backend/src/core/NoteEditService.ts
@@ -140,6 +140,7 @@ type Option = {
app?: MiApp | null;
updatedAt?: Date | null;
editcount?: boolean | null;
+ processErrors?: string[] | null;
};
@Injectable()
@@ -337,6 +338,9 @@ export class NoteEditService implements OnApplicationShutdown {
}
}
+ // Check quote permissions
+ await this.noteCreateService.checkQuotePermissions(data, user);
+
// Check blocking
if (this.isRenote(data) && !this.isQuote(data)) {
if (data.renote.userHost === null) {
@@ -529,6 +533,7 @@ export class NoteEditService implements OnApplicationShutdown {
if (data.uri != null) note.uri = data.uri;
if (data.url != null) note.url = data.url;
+ if (data.processErrors !== undefined) note.processErrors = data.processErrors;
if (mentionedUsers.length > 0) {
note.mentions = mentionedUsers.map(u => u.id);
diff --git a/packages/backend/src/core/activitypub/models/ApNoteService.ts b/packages/backend/src/core/activitypub/models/ApNoteService.ts
index 2995b1e764..8470285e93 100644
--- a/packages/backend/src/core/activitypub/models/ApNoteService.ts
+++ b/packages/backend/src/core/activitypub/models/ApNoteService.ts
@@ -296,44 +296,8 @@ export class ApNoteService {
: null;
// 引用
- let quote: MiNote | undefined | null = null;
-
- if (note._misskey_quote ?? note.quoteUrl ?? note.quoteUri) {
- const tryResolveNote = async (uri: unknown): Promise<
- | { status: 'ok'; res: MiNote }
- | { status: 'permerror' | 'temperror' }
- > => {
- if (typeof uri !== 'string' || !/^https?:/.test(uri)) {
- this.logger.warn(`Failed to resolve quote ${uri} for note ${entryUri}: URI is invalid`);
- return { status: 'permerror' };
- }
- try {
- const res = await this.resolveNote(uri, { resolver });
- if (res == null) {
- this.logger.warn(`Failed to resolve quote ${uri} for note ${entryUri}: resolution error`);
- return { status: 'permerror' };
- }
- return { status: 'ok', res };
- } catch (e) {
- const error = e instanceof Error ? `${e.name}: ${e.message}` : String(e);
- this.logger.warn(`Failed to resolve quote ${uri} for note ${entryUri}: ${error}`);
-
- return {
- status: (e instanceof StatusError && !e.isRetryable) ? 'permerror' : 'temperror',
- };
- }
- };
-
- const uris = unique([note._misskey_quote, note.quoteUrl, note.quoteUri].filter(x => x != null));
- const results = await Promise.all(uris.map(tryResolveNote));
-
- quote = results.filter((x): x is { status: 'ok', res: MiNote } => x.status === 'ok').map(x => x.res).at(0);
- if (!quote) {
- if (results.some(x => x.status === 'temperror')) {
- throw new Error(`temporary error resolving quote for ${entryUri}`);
- }
- }
- }
+ const quote = await this.getQuote(note, entryUri, resolver);
+ const processErrors = quote === null ? ['quoteUnavailable'] : null;
// vote
if (reply && reply.hasPoll) {
@@ -369,7 +333,8 @@ export class ApNoteService {
createdAt: note.published ? new Date(note.published) : null,
files,
reply,
- renote: quote,
+ renote: quote ?? null,
+ processErrors,
name: note.name,
cw,
text,
@@ -538,44 +503,8 @@ export class ApNoteService {
: null;
// 引用
- let quote: MiNote | undefined | null = null;
-
- if (note._misskey_quote ?? note.quoteUrl ?? note.quoteUri) {
- const tryResolveNote = async (uri: unknown): Promise<
- | { status: 'ok'; res: MiNote }
- | { status: 'permerror' | 'temperror' }
- > => {
- if (typeof uri !== 'string' || !/^https?:/.test(uri)) {
- this.logger.warn(`Failed to resolve quote ${uri} for note ${entryUri}: URI is invalid`);
- return { status: 'permerror' };
- }
- try {
- const res = await this.resolveNote(uri, { resolver });
- if (res == null) {
- this.logger.warn(`Failed to resolve quote ${uri} for note ${entryUri}: resolution error`);
- return { status: 'permerror' };
- }
- return { status: 'ok', res };
- } catch (e) {
- const error = e instanceof Error ? `${e.name}: ${e.message}` : String(e);
- this.logger.warn(`Failed to resolve quote ${uri} for note ${entryUri}: ${error}`);
-
- return {
- status: (e instanceof StatusError && !e.isRetryable) ? 'permerror' : 'temperror',
- };
- }
- };
-
- const uris = unique([note._misskey_quote, note.quoteUrl, note.quoteUri].filter(x => x != null));
- const results = await Promise.all(uris.map(tryResolveNote));
-
- quote = results.filter((x): x is { status: 'ok', res: MiNote } => x.status === 'ok').map(x => x.res).at(0);
- if (!quote) {
- if (results.some(x => x.status === 'temperror')) {
- throw new Error(`temporary error resolving quote for ${entryUri}`);
- }
- }
- }
+ const quote = await this.getQuote(note, entryUri, resolver);
+ const processErrors = quote === null ? ['quoteUnavailable'] : null;
// vote
if (reply && reply.hasPoll) {
@@ -611,7 +540,8 @@ export class ApNoteService {
createdAt: note.published ? new Date(note.published) : null,
files,
reply,
- renote: quote,
+ renote: quote ?? null,
+ processErrors,
name: note.name,
cw,
text,
@@ -734,6 +664,63 @@ export class ApNoteService {
});
}));
}
+
+ /**
+ * Fetches the note's quoted post.
+ * On success - returns the note.
+ * On skip (no quote) - returns undefined.
+ * On permanent error - returns null.
+ * On temporary error - throws an exception.
+ */
+ private async getQuote(note: IPost, entryUri: string, resolver: Resolver): Promise {
+ const quoteUris = new Set();
+ if (note._misskey_quote) quoteUris.add(note._misskey_quote);
+ if (note.quoteUrl) quoteUris.add(note.quoteUrl);
+ if (note.quoteUri) quoteUris.add(note.quoteUri);
+
+ // No quote, return undefined
+ if (quoteUris.size < 1) return undefined;
+
+ /**
+ * Attempts to resolve a quote by URI.
+ * Returns the note if successful, true if there's a retryable error, and false if there's a permanent error.
+ */
+ const resolveQuote = async (uri: unknown): Promise => {
+ if (typeof(uri) !== 'string' || !/^https?:/.test(uri)) {
+ this.logger.warn(`Failed to resolve quote "${uri}" for note "${entryUri}": URI is invalid`);
+ return false;
+ }
+
+ try {
+ const quote = await this.resolveNote(uri, { resolver });
+
+ if (quote == null) {
+ this.logger.warn(`Failed to resolve quote "${uri}" for note "${entryUri}": request error`);
+ return false;
+ }
+
+ return quote;
+ } catch (e) {
+ const error = e instanceof Error ? `${e.name}: ${e.message}` : String(e);
+ this.logger.warn(`Failed to resolve quote "${uri}" for note "${entryUri}": ${error}`);
+
+ return (e instanceof StatusError && e.isRetryable);
+ }
+ };
+
+ const results = await Promise.all(Array.from(quoteUris).map(u => resolveQuote(u)));
+
+ // Success - return the quote
+ const quote = results.find(r => typeof(r) === 'object');
+ if (quote) return quote;
+
+ // Temporary / retryable error - throw error
+ const tempError = results.find(r => r === true);
+ if (tempError) throw new Error(`temporary error resolving quote for "${entryUri}"`);
+
+ // Permanent error - return null
+ return null;
+ }
}
function getBestIcon(note: IObject): IObject | null {
diff --git a/packages/backend/src/core/entities/InstanceEntityService.ts b/packages/backend/src/core/entities/InstanceEntityService.ts
index 63e5923255..fcc9bed3bd 100644
--- a/packages/backend/src/core/entities/InstanceEntityService.ts
+++ b/packages/backend/src/core/entities/InstanceEntityService.ts
@@ -60,6 +60,7 @@ export class InstanceEntityService {
latestRequestReceivedAt: instance.latestRequestReceivedAt ? instance.latestRequestReceivedAt.toISOString() : null,
isNSFW: instance.isNSFW,
rejectReports: instance.rejectReports,
+ rejectQuotes: instance.rejectQuotes,
moderationNote: iAmModerator ? instance.moderationNote : null,
};
}
diff --git a/packages/backend/src/core/entities/NoteEntityService.ts b/packages/backend/src/core/entities/NoteEntityService.ts
index dca73567cc..1c51aba09b 100644
--- a/packages/backend/src/core/entities/NoteEntityService.ts
+++ b/packages/backend/src/core/entities/NoteEntityService.ts
@@ -490,6 +490,7 @@ export class NoteEntityService implements OnModuleInit {
...(opts.detail ? {
clippedCount: note.clippedCount,
+ processErrors: note.processErrors,
reply: note.replyId ? this.pack(note.reply ?? note.replyId, me, {
detail: false,
diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts
index 4fbbbdd379..5d539ea264 100644
--- a/packages/backend/src/core/entities/UserEntityService.ts
+++ b/packages/backend/src/core/entities/UserEntityService.ts
@@ -674,6 +674,7 @@ export class UserEntityService implements OnModuleInit {
securityKeys: profile!.twoFactorEnabled
? this.userSecurityKeysRepository.countBy({ userId: user.id }).then(result => result >= 1)
: false,
+ rejectQuotes: user.rejectQuotes,
} : {}),
...(isDetailed && isMe ? {
diff --git a/packages/backend/src/models/Instance.ts b/packages/backend/src/models/Instance.ts
index ba93190c57..c64ebb1b3b 100644
--- a/packages/backend/src/models/Instance.ts
+++ b/packages/backend/src/models/Instance.ts
@@ -164,6 +164,15 @@ export class MiInstance {
})
public rejectReports: boolean;
+ /**
+ * If true, quote posts from this instance will be downgraded to normal posts.
+ * The quote will be stripped and a process error will be generated.
+ */
+ @Column('boolean', {
+ default: false,
+ })
+ public rejectQuotes: boolean;
+
@Column('varchar', {
length: 16384, default: '',
})
diff --git a/packages/backend/src/models/Note.ts b/packages/backend/src/models/Note.ts
index 8b5265e8fe..2dabb75d83 100644
--- a/packages/backend/src/models/Note.ts
+++ b/packages/backend/src/models/Note.ts
@@ -203,6 +203,17 @@ export class MiNote {
@JoinColumn()
public channel: MiChannel | null;
+ /**
+ * List of non-fatal errors encountered while processing (creating or updating) this note.
+ * Entries can be a translation key (which will be queried from the "_processErrors" section) or a raw string.
+ * Errors will be displayed to the user when viewing the note.
+ */
+ @Column('text', {
+ array: true,
+ nullable: true,
+ })
+ public processErrors: string[] | null;
+
//#region Denormalized fields
@Index()
@Column('varchar', {
diff --git a/packages/backend/src/models/User.ts b/packages/backend/src/models/User.ts
index 8a3ad1003d..5d87c7fa12 100644
--- a/packages/backend/src/models/User.ts
+++ b/packages/backend/src/models/User.ts
@@ -348,6 +348,15 @@ export class MiUser {
})
public mandatoryCW: string | null;
+ /**
+ * If true, quote posts from this user will be downgraded to normal posts.
+ * The quote will be stripped and a process error will be generated.
+ */
+ @Column('boolean', {
+ default: false,
+ })
+ public rejectQuotes: boolean;
+
constructor(data: Partial) {
if (data == null) return;
diff --git a/packages/backend/src/models/json-schema/federation-instance.ts b/packages/backend/src/models/json-schema/federation-instance.ts
index 7960e748e9..57d4466ffa 100644
--- a/packages/backend/src/models/json-schema/federation-instance.ts
+++ b/packages/backend/src/models/json-schema/federation-instance.ts
@@ -126,6 +126,11 @@ export const packedFederationInstanceSchema = {
optional: false,
nullable: false,
},
+ rejectQuotes: {
+ type: 'boolean',
+ optional: false,
+ nullable: false,
+ },
moderationNote: {
type: 'string',
optional: true, nullable: true,
diff --git a/packages/backend/src/models/json-schema/note.ts b/packages/backend/src/models/json-schema/note.ts
index 432c096e48..51d23fe5e7 100644
--- a/packages/backend/src/models/json-schema/note.ts
+++ b/packages/backend/src/models/json-schema/note.ts
@@ -256,6 +256,14 @@ export const packedNoteSchema = {
type: 'number',
optional: true, nullable: false,
},
+ processErrors: {
+ type: 'array',
+ optional: true, nullable: true,
+ items: {
+ type: 'string',
+ optional: false, nullable: false,
+ },
+ },
myReaction: {
type: 'string',
diff --git a/packages/backend/src/models/json-schema/user.ts b/packages/backend/src/models/json-schema/user.ts
index 1c2ba538c1..3d0bf44c2e 100644
--- a/packages/backend/src/models/json-schema/user.ts
+++ b/packages/backend/src/models/json-schema/user.ts
@@ -445,6 +445,10 @@ export const packedUserDetailedNotMeOnlySchema = {
type: 'boolean',
nullable: false, optional: true,
},
+ rejectQuotes: {
+ type: 'boolean',
+ nullable: false, optional: true,
+ },
//#region relations
isFollowing: {
type: 'boolean',
diff --git a/packages/backend/src/server/api/endpoints/admin/federation/update-instance.ts b/packages/backend/src/server/api/endpoints/admin/federation/update-instance.ts
index daf19c4435..24d0b8527c 100644
--- a/packages/backend/src/server/api/endpoints/admin/federation/update-instance.ts
+++ b/packages/backend/src/server/api/endpoints/admin/federation/update-instance.ts
@@ -27,6 +27,7 @@ export const paramDef = {
isNSFW: { type: 'boolean' },
rejectReports: { type: 'boolean' },
moderationNote: { type: 'string' },
+ rejectQuotes: { type: 'boolean' },
},
required: ['host'],
} as const;
@@ -59,6 +60,7 @@ export default class extends Endpoint { // eslint-
suspensionState,
isNSFW: ps.isNSFW,
rejectReports: ps.rejectReports,
+ rejectQuotes: ps.rejectQuotes,
moderationNote: ps.moderationNote,
});
@@ -92,6 +94,14 @@ export default class extends Endpoint { // eslint-
});
}
+ if (ps.rejectQuotes != null && instance.rejectQuotes !== ps.rejectQuotes) {
+ const message = ps.rejectReports ? 'rejectQuotesInstance' : 'acceptQuotesInstance';
+ this.moderationLogService.log(me, message, {
+ id: instance.id,
+ host: instance.host,
+ });
+ }
+
if (ps.moderationNote != null && instance.moderationNote !== ps.moderationNote) {
this.moderationLogService.log(me, 'updateRemoteInstanceNote', {
id: instance.id,
diff --git a/packages/backend/src/server/api/endpoints/admin/reject-quotes.ts b/packages/backend/src/server/api/endpoints/admin/reject-quotes.ts
new file mode 100644
index 0000000000..78f94ceeff
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/admin/reject-quotes.ts
@@ -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 { UsersRepository } from '@/models/_.js';
+import { DI } from '@/di-symbols.js';
+import { GlobalEventService } from '@/core/GlobalEventService.js';
+import { CacheService } from '@/core/CacheService.js';
+import { ModerationLogService } from '@/core/ModerationLogService.js';
+
+export const meta = {
+ tags: ['admin'],
+
+ requireCredential: true,
+ requireModerator: true,
+ kind: 'write:admin:reject-quotes',
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ userId: { type: 'string', format: 'misskey:id' },
+ rejectQuotes: { type: 'boolean', nullable: false },
+ },
+ required: ['userId', 'rejectQuotes'],
+} as const;
+
+@Injectable()
+export default class extends Endpoint { // 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.rejectQuotes === ps.rejectQuotes) return;
+
+ // Log event first.
+ // This ensures that we don't "lose" the log if an error occurs
+ await this.moderationLogService.log(me, ps.rejectQuotes ? 'rejectQuotesUser' : 'acceptQuotesUser', {
+ userId: user.id,
+ userUsername: user.username,
+ userHost: user.host,
+ });
+
+ await this.usersRepository.update(ps.userId, {
+ rejectQuotes: ps.rejectQuotes,
+ });
+
+ // Synchronize caches and other processes
+ this.globalEventService.publishInternalEvent('localUserUpdated', { id: ps.userId });
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/notes/create.ts b/packages/backend/src/server/api/endpoints/notes/create.ts
index d1cf0123dc..b0f32bfda8 100644
--- a/packages/backend/src/server/api/endpoints/notes/create.ts
+++ b/packages/backend/src/server/api/endpoints/notes/create.ts
@@ -143,6 +143,12 @@ export const meta = {
code: 'CONTAINS_TOO_MANY_MENTIONS',
id: '4de0363a-3046-481b-9b0f-feff3e211025',
},
+
+ quoteDisabledForUser: {
+ message: 'You do not have permission to create quote posts.',
+ code: 'QUOTE_DISABLED_FOR_USER',
+ id: '1c0ea108-d1e3-4e8e-aa3f-4d2487626153',
+ },
},
} as const;
@@ -415,6 +421,8 @@ export default class extends Endpoint { // eslint-
throw new ApiError(meta.errors.containsProhibitedWords);
} else if (e.id === '9f466dab-c856-48cd-9e65-ff90ff750580') {
throw new ApiError(meta.errors.containsTooManyMentions);
+ } else if (e.id === '1c0ea108-d1e3-4e8e-aa3f-4d2487626153') {
+ throw new ApiError(meta.errors.quoteDisabledForUser);
}
}
throw e;
diff --git a/packages/backend/src/server/api/endpoints/notes/edit.ts b/packages/backend/src/server/api/endpoints/notes/edit.ts
index dc94c78e75..cc2293c5d6 100644
--- a/packages/backend/src/server/api/endpoints/notes/edit.ts
+++ b/packages/backend/src/server/api/endpoints/notes/edit.ts
@@ -176,6 +176,12 @@ export const meta = {
id: '33510210-8452-094c-6227-4a6c05d99f02',
},
+ quoteDisabledForUser: {
+ message: 'You do not have permission to create quote posts.',
+ code: 'QUOTE_DISABLED_FOR_USER',
+ id: '1c0ea108-d1e3-4e8e-aa3f-4d2487626153',
+ },
+
containsProhibitedWords: {
message: 'Cannot post because it contains prohibited words.',
code: 'CONTAINS_PROHIBITED_WORDS',
@@ -469,6 +475,8 @@ export default class extends Endpoint { // eslint-
throw new ApiError(meta.errors.containsProhibitedWords);
} else if (e.id === '9f466dab-c856-48cd-9e65-ff90ff750580') {
throw new ApiError(meta.errors.containsTooManyMentions);
+ } else if (e.id === '1c0ea108-d1e3-4e8e-aa3f-4d2487626153') {
+ throw new ApiError(meta.errors.quoteDisabledForUser);
}
}
throw e;
diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts
index b359fa5a39..b5d982e3a5 100644
--- a/packages/backend/src/types.ts
+++ b/packages/backend/src/types.ts
@@ -132,6 +132,10 @@ export const moderationLogTypes = [
'deletePage',
'deleteFlash',
'deleteGalleryPost',
+ 'acceptQuotesUser',
+ 'rejectQuotesUser',
+ 'acceptQuotesInstance',
+ 'rejectQuotesInstance',
] as const;
export type ModerationLogPayloads = {
@@ -417,6 +421,24 @@ export type ModerationLogPayloads = {
postUserUsername: string;
post: any;
};
+ acceptQuotesUser: {
+ userId: string,
+ userUsername: string,
+ userHost: string | null,
+ };
+ rejectQuotesUser: {
+ userId: string,
+ userUsername: string,
+ userHost: string | null,
+ };
+ acceptQuotesInstance: {
+ id: string;
+ host: string;
+ };
+ rejectQuotesInstance: {
+ id: string;
+ host: string;
+ };
};
export type Serialized = {
diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue
index 0bac6a67b9..4b174d7336 100644
--- a/packages/frontend/src/components/MkNote.vue
+++ b/packages/frontend/src/components/MkNote.vue
@@ -142,7 +142,7 @@ SPDX-License-Identifier: AGPL-3.0-only