merge: Add "reject quotes" settings (!901)

View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/901

Approved-by: dakkar <dakkar@thenautilus.net>
Approved-by: Marie <github@yuugi.dev>
This commit is contained in:
Hazelnoot 2025-03-01 03:33:06 +00:00
commit 14a81b4f85
49 changed files with 693 additions and 100 deletions

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

@ -100,6 +100,7 @@ function generateDummyUser(override?: Partial<MiUser>): MiUser {
noindex: false,
enableRss: true,
mandatoryCW: null,
rejectQuotes: false,
...override,
};
}
@ -143,6 +144,7 @@ function generateDummyNote(override?: Partial<MiNote>): MiNote {
renoteUserId: null,
renoteUserHost: null,
updatedAt: null,
processErrors: [],
...override,
};
}

View file

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

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

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