diff --git a/locales/index.d.ts b/locales/index.d.ts
index bf49869bf8..6a2790c9af 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/Prohibit 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;
+ /**
+ * Blocked/Stripped quote posts from user
+ */
+ "rejectQuotesUser": string;
+ /**
+ * Allowed quote posts from user
+ */
+ "allowQuotesUser": string;
};
"_fileViewer": {
/**
@@ -11240,6 +11252,26 @@ export interface Locale extends ILocale {
* Reject reports from this instance
*/
"rejectReports": string;
+ /**
+ * Strip quote posts from this instance
+ */
+ "rejectQuotesInstance": string;
+ /**
+ * Strip quote posts from this user
+ */
+ "rejectQuotesRemoteUser": string;
+ /**
+ * Block quote posts from this user
+ */
+ "rejectQuotesLocalUser": string;
+ /**
+ * Are you sure you wish to strip 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 +12141,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/WebhookTestService.ts b/packages/backend/src/core/WebhookTestService.ts
index 93693216cb..2e50f4472f 100644
--- a/packages/backend/src/core/WebhookTestService.ts
+++ b/packages/backend/src/core/WebhookTestService.ts
@@ -100,6 +100,7 @@ function generateDummyUser(override?: Partial): MiUser {
noindex: false,
enableRss: true,
mandatoryCW: null,
+ rejectQuotes: false,
...override,
};
}
@@ -143,6 +144,7 @@ function generateDummyNote(override?: Partial): MiNote {
renoteUserId: null,
renoteUserHost: null,
updatedAt: null,
+ processErrors: [],
...override,
};
}
diff --git a/packages/backend/src/core/activitypub/models/ApNoteService.ts b/packages/backend/src/core/activitypub/models/ApNoteService.ts
index 2995b1e764..606ab4c26e 100644
--- a/packages/backend/src/core/activitypub/models/ApNoteService.ts
+++ b/packages/backend/src/core/activitypub/models/ApNoteService.ts
@@ -25,6 +25,7 @@ import { UtilityService } from '@/core/UtilityService.js';
import { bindThis } from '@/decorators.js';
import { checkHttps } from '@/misc/check-https.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
+import { isRetryableError } from '@/misc/is-retryable-error.js';
import { getOneApId, getApId, getOneApHrefNullable, validPost, isEmoji, getApType, isApObject, isDocument, IApDocument } from '../type.js';
import { ApLoggerService } from '../ApLoggerService.js';
import { ApMfmService } from '../ApMfmService.js';
@@ -296,44 +297,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 +334,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 +504,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 +541,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 +665,66 @@ 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) {
+ if (e instanceof Error) {
+ this.logger.warn(`Failed to resolve quote "${uri}" for note "${entryUri}":`, e);
+ } else {
+ this.logger.warn(`Failed to resolve quote "${uri}" for note "${entryUri}": ${e}`);
+ }
+
+ return isRetryableError(e);
+ }
+ };
+
+ 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 a37284316b..537677ed34 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..96fef863a0 100644
--- a/packages/backend/src/core/entities/UserEntityService.ts
+++ b/packages/backend/src/core/entities/UserEntityService.ts
@@ -593,6 +593,7 @@ export class UserEntityService implements OnModuleInit {
noindex: user.noindex,
enableRss: user.enableRss,
mandatoryCW: user.mandatoryCW,
+ rejectQuotes: user.rejectQuotes,
isSilenced: user.isSilenced || this.roleService.getUserPolicies(user.id).then(r => !r.canPublicNote),
speakAsCat: user.speakAsCat ?? false,
approved: user.approved,
diff --git a/packages/backend/src/misc/is-retryable-error.ts b/packages/backend/src/misc/is-retryable-error.ts
new file mode 100644
index 0000000000..9bb8700c7a
--- /dev/null
+++ b/packages/backend/src/misc/is-retryable-error.ts
@@ -0,0 +1,22 @@
+/*
+ * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { AbortError } from 'node-fetch';
+import { UnrecoverableError } from 'bullmq';
+import { StatusError } from '@/misc/status-error.js';
+import { IdentifiableError } from '@/misc/identifiable-error.js';
+
+/**
+ * Returns false if the provided value represents a "permanent" error that cannot be retried.
+ * Returns true if the error is retryable, unknown (as all errors are retryable by default), or not an error object.
+ */
+export function isRetryableError(e: unknown): boolean {
+ if (e instanceof StatusError) return e.isRetryable;
+ if (e instanceof IdentifiableError) return e.isRetryable;
+ if (e instanceof UnrecoverableError) return false;
+ if (e instanceof AbortError) return true;
+ if (e instanceof Error) return e.name === 'AbortError';
+ return true;
+}
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..16e240ab11 100644
--- a/packages/backend/src/models/json-schema/note.ts
+++ b/packages/backend/src/models/json-schema/note.ts
@@ -17,6 +17,11 @@ export const packedNoteSchema = {
optional: false, nullable: false,
format: 'date-time',
},
+ updatedAt: {
+ type: 'string',
+ optional: true, nullable: false,
+ format: 'date-time',
+ },
deletedAt: {
type: 'string',
optional: true, nullable: true,
@@ -256,6 +261,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..0f1601f138 100644
--- a/packages/backend/src/models/json-schema/user.ts
+++ b/packages/backend/src/models/json-schema/user.ts
@@ -138,6 +138,10 @@ export const packedUserLiteSchema = {
type: 'string',
nullable: true, optional: false,
},
+ rejectQuotes: {
+ type: 'boolean',
+ nullable: false, optional: true,
+ },
isBot: {
type: 'boolean',
nullable: false, optional: true,
diff --git a/packages/backend/src/server/api/endpoint-list.ts b/packages/backend/src/server/api/endpoint-list.ts
index 551d7b17c2..a641a14448 100644
--- a/packages/backend/src/server/api/endpoint-list.ts
+++ b/packages/backend/src/server/api/endpoint-list.ts
@@ -74,6 +74,7 @@ export * as 'admin/queue/deliver-delayed' from './endpoints/admin/queue/deliver-
export * as 'admin/queue/inbox-delayed' from './endpoints/admin/queue/inbox-delayed.js';
export * as 'admin/queue/promote' from './endpoints/admin/queue/promote.js';
export * as 'admin/queue/stats' from './endpoints/admin/queue/stats.js';
+export * as 'admin/reject-quotes' from './endpoints/admin/reject-quotes.js';
export * as 'admin/relays/add' from './endpoints/admin/relays/add.js';
export * as 'admin/relays/list' from './endpoints/admin/relays/list.js';
export * as 'admin/relays/remove' from './endpoints/admin/relays/remove.js';
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/backend/test/unit/NoteCreateService.ts b/packages/backend/test/unit/NoteCreateService.ts
index ccf9ed1e6d..f4ecfef34d 100644
--- a/packages/backend/test/unit/NoteCreateService.ts
+++ b/packages/backend/test/unit/NoteCreateService.ts
@@ -61,6 +61,7 @@ describe('NoteCreateService', () => {
replyUserHost: null,
renoteUserId: null,
renoteUserHost: null,
+ processErrors: [],
};
const poll: IPoll = {
diff --git a/packages/backend/test/unit/misc/is-renote.ts b/packages/backend/test/unit/misc/is-renote.ts
index 1baa995f59..24cd2236bb 100644
--- a/packages/backend/test/unit/misc/is-renote.ts
+++ b/packages/backend/test/unit/misc/is-renote.ts
@@ -44,6 +44,7 @@ const base: MiNote = {
replyUserHost: null,
renoteUserId: null,
renoteUserHost: null,
+ processErrors: [],
};
describe('misc:is-renote', () => {
diff --git a/packages/backend/test/unit/misc/is-retryable-error.ts b/packages/backend/test/unit/misc/is-retryable-error.ts
new file mode 100644
index 0000000000..096bf64d4f
--- /dev/null
+++ b/packages/backend/test/unit/misc/is-retryable-error.ts
@@ -0,0 +1,73 @@
+/*
+ * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { UnrecoverableError } from 'bullmq';
+import { AbortError } from 'node-fetch';
+import { isRetryableError } from '@/misc/is-retryable-error.js';
+import { StatusError } from '@/misc/status-error.js';
+import { IdentifiableError } from '@/misc/identifiable-error.js';
+
+describe(isRetryableError, () => {
+ it('should return true for retryable StatusError', () => {
+ const error = new StatusError('test error', 500);
+ const result = isRetryableError(error);
+ expect(result).toBeTruthy();
+ });
+
+ it('should return false for permanent StatusError', () => {
+ const error = new StatusError('test error', 400);
+ const result = isRetryableError(error);
+ expect(result).toBeFalsy();
+ });
+
+ it('should return true for retryable IdentifiableError', () => {
+ const error = new IdentifiableError('id', 'message', true);
+ const result = isRetryableError(error);
+ expect(result).toBeTruthy();
+ });
+
+ it('should return false for permanent StatusError', () => {
+ const error = new IdentifiableError('id', 'message', false);
+ const result = isRetryableError(error);
+ expect(result).toBeFalsy();
+ });
+
+ it('should return false for UnrecoverableError', () => {
+ const error = new UnrecoverableError();
+ const result = isRetryableError(error);
+ expect(result).toBeFalsy();
+ });
+
+ it('should return true for typed AbortError', () => {
+ const error = new AbortError();
+ const result = isRetryableError(error);
+ expect(result).toBeTruthy();
+ });
+
+ it('should return true for named AbortError', () => {
+ const error = new Error();
+ error.name = 'AbortError';
+
+ const result = isRetryableError(error);
+
+ expect(result).toBeTruthy();
+ });
+
+ const nonErrorInputs = [
+ [null, 'null'],
+ [undefined, 'undefined'],
+ [0, 'number'],
+ ['string', 'string'],
+ [true, 'boolean'],
+ [[], 'array'],
+ [{}, 'object'],
+ ];
+ for (const [input, label] of nonErrorInputs) {
+ it(`should return true for ${label} input`, () => {
+ const result = isRetryableError(input);
+ expect(result).toBeTruthy();
+ });
+ }
+});
diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue
index 0bac6a67b9..3f52244bdc 100644
--- a/packages/frontend/src/components/MkNote.vue
+++ b/packages/frontend/src/components/MkNote.vue
@@ -102,7 +102,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-
+