1
0
mirror of https://git.boykissers.com/pawkey/pawkey-sk.git synced 2025-12-20 12:14:18 +00:00

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
This commit is contained in:
Hazelnoot
2025-02-15 23:08:02 -05:00
parent 93ffd4611c
commit 292d3b9229
36 changed files with 466 additions and 88 deletions

View File

@@ -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"`);
}
}

View File

@@ -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"`);
}
}

View File

@@ -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"`);
}
}

View File

@@ -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<void> {
await this.dispose();
}
@bindThis
public async checkQuotePermissions(data: Option, user: MiUser): Promise<void> {
// 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');
}
}
}
}

View File

@@ -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);

View File

@@ -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<MiNote | null | undefined> {
const quoteUris = new Set<string>();
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<MiNote | boolean> => {
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 {

View File

@@ -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,
};
}

View File

@@ -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,

View File

@@ -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 ? {

View File

@@ -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: '',
})

View File

@@ -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', {

View File

@@ -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<MiUser>) {
if (data == null) return;

View File

@@ -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,

View File

@@ -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',

View File

@@ -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',

View File

@@ -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<typeof meta, typeof paramDef> { // eslint-
suspensionState,
isNSFW: ps.isNSFW,
rejectReports: ps.rejectReports,
rejectQuotes: ps.rejectQuotes,
moderationNote: ps.moderationNote,
});
@@ -92,6 +94,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // 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,

View File

@@ -0,0 +1,63 @@
/*
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { 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<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.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 });
});
}
}

View File

@@ -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<typeof meta, typeof paramDef> { // 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;

View File

@@ -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<typeof meta, typeof paramDef> { // 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;

View File

@@ -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<T> = {