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:
@@ -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"`);
|
||||
}
|
||||
}
|
||||
@@ -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"`);
|
||||
}
|
||||
}
|
||||
@@ -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"`);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ? {
|
||||
|
||||
@@ -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: '',
|
||||
})
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -142,7 +142,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<i class="ti ti-ban"></i>
|
||||
</button>
|
||||
<button
|
||||
v-if="canRenote && !props.mock"
|
||||
v-if="canRenote && !props.mock && !$i?.rejectQuotes"
|
||||
ref="quoteButton"
|
||||
:class="$style.footerButton"
|
||||
class="_button"
|
||||
|
||||
@@ -153,7 +153,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<i class="ti ti-ban"></i>
|
||||
</button>
|
||||
<button
|
||||
v-if="canRenote"
|
||||
v-if="canRenote && !$i?.rejectQuotes"
|
||||
ref="quoteButton"
|
||||
class="_button"
|
||||
:class="$style.noteFooterButton"
|
||||
|
||||
@@ -38,7 +38,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<p v-if="note.renoteCount > 0" :class="$style.noteFooterButtonCount">{{ note.renoteCount }}</p>
|
||||
</button>
|
||||
<button
|
||||
v-if="canRenote"
|
||||
v-if="canRenote && !$i?.rejectQuotes"
|
||||
ref="quoteButton"
|
||||
class="_button"
|
||||
:class="$style.noteFooterButton"
|
||||
|
||||
@@ -640,7 +640,7 @@ async function onPaste(ev: ClipboardEvent) {
|
||||
|
||||
const paste = ev.clipboardData.getData('text');
|
||||
|
||||
if (!renoteTargetNote.value && !quoteId.value && paste.startsWith(url + '/notes/')) {
|
||||
if (!renoteTargetNote.value && !quoteId.value && paste.startsWith(url + '/notes/') && !$i.rejectQuotes) {
|
||||
ev.preventDefault();
|
||||
|
||||
os.confirm({
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<!--
|
||||
SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<!-- Match appearance of MkRemoteCaution.vue -->
|
||||
<div v-for="error of displayErrors" :key="error" :class="$style.root">
|
||||
<i :class="$style.icon" class="ti ti-alert-triangle"></i>{{ i18n.ts._processErrors[error] ?? error }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
||||
const props = defineProps<{
|
||||
errors?: string[] | null;
|
||||
}>();
|
||||
|
||||
const displayErrors = computed<Iterable<string>>(() => {
|
||||
if (!props.errors?.length) return [];
|
||||
|
||||
// Set constructor preserve order, so we can sort first to avoid a copy operation.
|
||||
return new Set(props.errors.toSorted());
|
||||
});
|
||||
</script>
|
||||
|
||||
<style module lang="scss">
|
||||
.root {
|
||||
font-size: 0.8em;
|
||||
padding: 16px;
|
||||
background: color-mix(in srgb, var(--MI_THEME-infoWarnBg) 65%, transparent);
|
||||
color: var(--MI_THEME-infoWarnFg);
|
||||
border-radius: var(--MI-radius);
|
||||
overflow: clip;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-right: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -143,7 +143,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<i class="ti ti-ban"></i>
|
||||
</button>
|
||||
<button
|
||||
v-if="canRenote && !props.mock"
|
||||
v-if="canRenote && !props.mock && !$i?.rejectQuotes"
|
||||
ref="quoteButton"
|
||||
:class="$style.footerButton"
|
||||
class="_button"
|
||||
|
||||
@@ -158,7 +158,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<i class="ti ti-ban"></i>
|
||||
</button>
|
||||
<button
|
||||
v-if="canRenote"
|
||||
v-if="canRenote && !$i?.rejectQuotes"
|
||||
ref="quoteButton"
|
||||
class="_button"
|
||||
:class="$style.noteFooterButton"
|
||||
|
||||
@@ -46,7 +46,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<p v-if="note.renoteCount > 0" :class="$style.noteFooterButtonCount">{{ note.renoteCount }}</p>
|
||||
</button>
|
||||
<button
|
||||
v-if="canRenote"
|
||||
v-if="canRenote && !$i?.rejectQuotes"
|
||||
ref="quoteButton"
|
||||
class="_button"
|
||||
:class="$style.noteFooterButton"
|
||||
|
||||
@@ -81,6 +81,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<div class="_gaps">
|
||||
<MkSwitch v-model="silenced" @update:modelValue="toggleSilence">{{ i18n.ts.silence }}</MkSwitch>
|
||||
<MkSwitch v-if="!isSystem" v-model="suspended" @update:modelValue="toggleSuspend">{{ i18n.ts.suspend }}</MkSwitch>
|
||||
<MkSwitch v-model="rejectQuotes" @update:modelValue="toggleRejectQuotes">{{ i18n.ts.rejectQuotesUser }}</MkSwitch>
|
||||
<MkSwitch v-model="markedAsNSFW" @update:modelValue="toggleNSFW">{{ i18n.ts.markAsNSFW }}</MkSwitch>
|
||||
|
||||
<MkInput v-model="mandatoryCW" type="text" manualSave @update:modelValue="onMandatoryCWChanged">
|
||||
@@ -247,6 +248,7 @@ const moderator = ref(false);
|
||||
const silenced = ref(false);
|
||||
const approved = ref(false);
|
||||
const suspended = ref(false);
|
||||
const rejectQuotes = ref(false);
|
||||
const markedAsNSFW = ref(false);
|
||||
const moderationNote = ref('');
|
||||
const mandatoryCW = ref<string | null>(null);
|
||||
@@ -287,6 +289,7 @@ function createFetcher() {
|
||||
approved.value = info.value.approved;
|
||||
markedAsNSFW.value = info.value.alwaysMarkNsfw;
|
||||
suspended.value = info.value.isSuspended;
|
||||
rejectQuotes.value = user.value.rejectQuotes ?? false;
|
||||
moderationNote.value = info.value.moderationNote;
|
||||
mandatoryCW.value = user.value.mandatoryCW;
|
||||
});
|
||||
@@ -368,6 +371,22 @@ async function toggleSuspend(v) {
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleRejectQuotes(v: boolean): Promise<void> {
|
||||
const confirm = await os.confirm({
|
||||
type: 'warning',
|
||||
text: v ? i18n.ts.rejectQuotesConfirm : i18n.ts.allowQuotesConfirm,
|
||||
});
|
||||
if (confirm.canceled) {
|
||||
rejectQuotes.value = !v;
|
||||
} else {
|
||||
await misskeyApi('admin/reject-quotes', {
|
||||
userId: props.userId,
|
||||
rejectQuotes: v,
|
||||
});
|
||||
await refreshUser();
|
||||
}
|
||||
}
|
||||
|
||||
async function unsetUserAvatar() {
|
||||
const confirm = await os.confirm({
|
||||
type: 'warning',
|
||||
|
||||
@@ -28,6 +28,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
'unsetRemoteInstanceNSFW',
|
||||
'rejectRemoteInstanceReports',
|
||||
'acceptRemoteInstanceReports',
|
||||
'rejectQuotesUser',
|
||||
'acceptQuotesUser',
|
||||
].includes(log.type),
|
||||
[$style.logRed]: [
|
||||
'suspend',
|
||||
@@ -53,6 +55,10 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<span v-if="log.type === 'updateUserNote'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</span>
|
||||
<span v-else-if="log.type === 'suspend'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</span>
|
||||
<span v-else-if="log.type === 'approve'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</span>
|
||||
<span v-else-if="log.type === 'rejectQuotesUser'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</span>
|
||||
<span v-else-if="log.type === 'acceptQuotesUser'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</span>
|
||||
<span v-else-if="log.type === 'rejectQuotesInstance'">: {{ log.info.host }}</span>
|
||||
<span v-else-if="log.type === 'acceptQuotesInstance'">: {{ log.info.host }}</span>
|
||||
<span v-else-if="log.type === 'decline'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</span>
|
||||
<span v-else-if="log.type === 'unsuspend'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</span>
|
||||
<span v-else-if="log.type === 'resetPassword'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</span>
|
||||
@@ -134,6 +140,12 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<template v-else-if="log.type === 'unsuspend'">
|
||||
<div>{{ i18n.ts.user }}: <MkA :to="`/admin/user/${log.info.userId}`" class="_link">@{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</MkA></div>
|
||||
</template>
|
||||
<template v-else-if="log.type === 'rejectQuotesUser'">
|
||||
<div>{{ i18n.ts.user }}: <MkA :to="`/admin/user/${log.info.userId}`" class="_link">@{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</MkA></div>
|
||||
</template>
|
||||
<template v-else-if="log.type === 'acceptQuotesUser'">
|
||||
<div>{{ i18n.ts.user }}: <MkA :to="`/admin/user/${log.info.userId}`" class="_link">@{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</MkA></div>
|
||||
</template>
|
||||
<template v-else-if="log.type === 'updateRole'">
|
||||
<div :class="$style.diff">
|
||||
<CodeDiff :context="5" :hideHeader="true" :oldString="JSON5.stringify(log.info.before, null, '\t')" :newString="JSON5.stringify(log.info.after, null, '\t')" language="javascript" maxHeight="300px"/>
|
||||
|
||||
@@ -53,6 +53,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<MkInfo v-if="isBaseSilenced" warn>{{ i18n.ts.silencedByBase }}</MkInfo>
|
||||
<MkSwitch v-model="isSilenced" :disabled="!meta || !instance || isBaseSilenced" @update:modelValue="toggleSilenced">{{ i18n.ts.silenceThisInstance }}</MkSwitch>
|
||||
<MkSwitch v-model="isNSFW" :disabled="!instance" @update:modelValue="toggleNSFW">{{ i18n.ts.markInstanceAsNSFW }}</MkSwitch>
|
||||
<MkSwitch v-model="rejectQuotes" :disabled="!instance" @update:modelValue="toggleRejectQuotes">{{ i18n.ts.rejectQuotesInstance }}</MkSwitch>
|
||||
<MkSwitch v-model="rejectReports" :disabled="!instance" @update:modelValue="toggleRejectReports">{{ i18n.ts.rejectReports }}</MkSwitch>
|
||||
<MkInfo v-if="isBaseMediaSilenced" warn>{{ i18n.ts.mediaSilencedByBase }}</MkInfo>
|
||||
<MkSwitch v-model="isMediaSilenced" :disabled="!meta || !instance || isBaseMediaSilenced" @update:modelValue="toggleMediaSilenced">{{ i18n.ts.mediaSilenceThisInstance }}</MkSwitch>
|
||||
@@ -211,6 +212,7 @@ const isSuspended = ref(false);
|
||||
const isBlocked = ref(false);
|
||||
const isSilenced = ref(false);
|
||||
const isNSFW = ref(false);
|
||||
const rejectQuotes = ref(false);
|
||||
const rejectReports = ref(false);
|
||||
const isMediaSilenced = ref(false);
|
||||
const faviconUrl = ref<string | null>(null);
|
||||
@@ -282,6 +284,7 @@ async function fetch(): Promise<void> {
|
||||
isSilenced.value = instance.value?.isSilenced ?? false;
|
||||
isNSFW.value = instance.value?.isNSFW ?? false;
|
||||
rejectReports.value = instance.value?.rejectReports ?? false;
|
||||
rejectQuotes.value = instance.value?.rejectQuotes ?? false;
|
||||
isMediaSilenced.value = instance.value?.isMediaSilenced ?? false;
|
||||
faviconUrl.value = getProxiedImageUrlNullable(instance.value?.faviconUrl, 'preview') ?? getProxiedImageUrlNullable(instance.value?.iconUrl, 'preview');
|
||||
moderationNote.value = instance.value?.moderationNote ?? '';
|
||||
@@ -347,6 +350,15 @@ async function toggleRejectReports(): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
async function toggleRejectQuotes(): Promise<void> {
|
||||
if (!iAmModerator) return;
|
||||
if (!instance.value) throw new Error('No instance?');
|
||||
await misskeyApi('admin/federation/update-instance', {
|
||||
host: instance.value.host,
|
||||
rejectQuotes: rejectQuotes.value,
|
||||
});
|
||||
}
|
||||
|
||||
function refreshMetadata(): void {
|
||||
if (!iAmModerator) return;
|
||||
if (!instance.value) throw new Error('No instance?');
|
||||
|
||||
@@ -21,6 +21,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</div>
|
||||
<div class="_margin _gaps_s">
|
||||
<MkRemoteCaution v-if="note.user.host != null" :href="note.url ?? note.uri"/>
|
||||
<SkErrorList :errors="note.processErrors"/>
|
||||
<MkNoteDetailed :key="note.id" v-model:note="note" :initialTab="initialTab" :class="$style.note" :expandAllCws="expandAllCws"/>
|
||||
</div>
|
||||
<div v-if="clips && clips.length > 0" class="_margin">
|
||||
@@ -60,6 +61,7 @@ import { definePageMetadata } from '@/scripts/page-metadata.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { dateString } from '@/filters/date.js';
|
||||
import MkClipPreview from '@/components/MkClipPreview.vue';
|
||||
import SkErrorList from '@/components/SkErrorList.vue';
|
||||
import { defaultStore } from '@/store.js';
|
||||
import { pleaseLogin } from '@/scripts/please-login.js';
|
||||
import { serverContext, assertServerContext } from '@/server-context.js';
|
||||
@@ -69,9 +71,9 @@ import { $i } from '@/account.js';
|
||||
const CTX_NOTE = !$i && assertServerContext(serverContext, 'note') ? serverContext.note : null;
|
||||
|
||||
const MkNoteDetailed = defineAsyncComponent(() =>
|
||||
(defaultStore.state.noteDesign === 'misskey') ? import('@/components/MkNoteDetailed.vue') :
|
||||
(defaultStore.state.noteDesign === 'sharkey') ? import('@/components/SkNoteDetailed.vue') :
|
||||
null
|
||||
(defaultStore.state.noteDesign === 'misskey')
|
||||
? import('@/components/MkNoteDetailed.vue')
|
||||
: import('@/components/SkNoteDetailed.vue'),
|
||||
);
|
||||
|
||||
const props = defineProps<{
|
||||
|
||||
@@ -89,6 +89,7 @@ export const permissions = [
|
||||
'write:admin:unset-user-avatar',
|
||||
'write:admin:unset-user-banner',
|
||||
'write:admin:unsuspend-user',
|
||||
'write:admin:reject-quotes',
|
||||
'write:admin:meta',
|
||||
'write:admin:user-note',
|
||||
'write:admin:roles',
|
||||
@@ -491,4 +492,22 @@ export type ModerationLogPayloads = {
|
||||
postUserUsername: string;
|
||||
post: GalleryPost;
|
||||
};
|
||||
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;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -60,6 +60,18 @@ export type ModerationLog = {
|
||||
} | {
|
||||
type: 'unsuspend';
|
||||
info: ModerationLogPayloads['unsuspend'];
|
||||
} | {
|
||||
type: 'acceptQuotesUser';
|
||||
info: ModerationLogPayloads['acceptQuotesUser'];
|
||||
} | {
|
||||
type: 'rejectQuotesUser';
|
||||
info: ModerationLogPayloads['rejectQuotesUser'];
|
||||
} | {
|
||||
type: 'acceptQuotesInstance';
|
||||
info: ModerationLogPayloads['acceptQuotesInstance'];
|
||||
} | {
|
||||
type: 'rejectQuotesInstance';
|
||||
info: ModerationLogPayloads['rejectQuotesInstance'];
|
||||
} | {
|
||||
type: 'updateUserNote';
|
||||
info: ModerationLogPayloads['updateUserNote'];
|
||||
|
||||
Reference in New Issue
Block a user