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:
commit
14a81b4f85
49 changed files with 693 additions and 100 deletions
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue