diff --git a/locales/index.d.ts b/locales/index.d.ts index 182cf03284..826716f7d0 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -13157,6 +13157,14 @@ export interface Locale extends ILocale { * Timeout in milliseconds for translation API requests. */ "translationTimeoutCaption": string; + /** + * Staff notes + */ + "staffNotes": string; + /** + * Icon of {name} + */ + "instanceIconAlt": ParameterizedString<"name">; /** * Attribution Domains */ diff --git a/packages/backend/src/core/ReversiService.ts b/packages/backend/src/core/ReversiService.ts index e31d9e5b1a..b57ab6d9cb 100644 --- a/packages/backend/src/core/ReversiService.ts +++ b/packages/backend/src/core/ReversiService.ts @@ -588,6 +588,7 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit { lastFetchedAt: parsed.user1.lastFetchedAt != null ? new Date(parsed.user1.lastFetchedAt) : null, movedAt: parsed.user1.movedAt != null ? new Date(parsed.user1.movedAt) : null, instance: null, + userProfile: null, } : null, user2: parsed.user2 != null ? { ...parsed.user2, @@ -599,6 +600,7 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit { lastFetchedAt: parsed.user2.lastFetchedAt != null ? new Date(parsed.user2.lastFetchedAt) : null, movedAt: parsed.user2.movedAt != null ? new Date(parsed.user2.movedAt) : null, instance: null, + userProfile: null, } : null, }; } else { diff --git a/packages/backend/src/core/WebhookTestService.ts b/packages/backend/src/core/WebhookTestService.ts index 8c1508df24..c4b01d535b 100644 --- a/packages/backend/src/core/WebhookTestService.ts +++ b/packages/backend/src/core/WebhookTestService.ts @@ -77,6 +77,7 @@ function generateDummyUser(override?: Partial): MiUser { mandatoryCW: null, rejectQuotes: false, allowUnsignedFetch: 'staff', + userProfile: null, attributionDomains: [], ...override, }; @@ -363,8 +364,10 @@ export class WebhookTestService { id: 'dummy-abuse-report1', targetUserId: 'dummy-target-user', targetUser: null, + targetUserInstance: null, reporterId: 'dummy-reporter-user', reporter: null, + reporterInstance: null, assigneeId: null, assignee: null, resolved: false, diff --git a/packages/backend/src/core/entities/AbuseUserReportEntityService.ts b/packages/backend/src/core/entities/AbuseUserReportEntityService.ts index 70ead890ab..c1d877aa12 100644 --- a/packages/backend/src/core/entities/AbuseUserReportEntityService.ts +++ b/packages/backend/src/core/entities/AbuseUserReportEntityService.ts @@ -5,13 +5,14 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; -import type { AbuseUserReportsRepository } from '@/models/_.js'; +import type { AbuseUserReportsRepository, InstancesRepository, MiInstance, MiUser } from '@/models/_.js'; import { awaitAll } from '@/misc/prelude/await-all.js'; import type { MiAbuseUserReport } from '@/models/AbuseUserReport.js'; import { bindThis } from '@/decorators.js'; import { IdService } from '@/core/IdService.js'; import type { Packed } from '@/misc/json-schema.js'; import { UserEntityService } from './UserEntityService.js'; +import { InstanceEntityService } from './InstanceEntityService.js'; @Injectable() export class AbuseUserReportEntityService { @@ -19,6 +20,10 @@ export class AbuseUserReportEntityService { @Inject(DI.abuseUserReportsRepository) private abuseUserReportsRepository: AbuseUserReportsRepository, + @Inject(DI.instancesRepository) + private instancesRepository: InstancesRepository, + + private readonly instanceEntityService: InstanceEntityService, private userEntityService: UserEntityService, private idService: IdService, ) { @@ -30,11 +35,14 @@ export class AbuseUserReportEntityService { hint?: { packedReporter?: Packed<'UserDetailedNotMe'>, packedTargetUser?: Packed<'UserDetailedNotMe'>, + packedTargetInstance?: Packed<'FederationInstance'>, packedAssignee?: Packed<'UserDetailedNotMe'>, }, + me?: MiUser | null, ) { const report = typeof src === 'object' ? src : await this.abuseUserReportsRepository.findOneByOrFail({ id: src }); + // noinspection ES6MissingAwait return await awaitAll({ id: report.id, createdAt: this.idService.parse(report.id).date.toISOString(), @@ -43,13 +51,22 @@ export class AbuseUserReportEntityService { reporterId: report.reporterId, targetUserId: report.targetUserId, assigneeId: report.assigneeId, - reporter: hint?.packedReporter ?? this.userEntityService.pack(report.reporter ?? report.reporterId, null, { + reporter: hint?.packedReporter ?? this.userEntityService.pack(report.reporter ?? report.reporterId, me, { schema: 'UserDetailedNotMe', }), - targetUser: hint?.packedTargetUser ?? this.userEntityService.pack(report.targetUser ?? report.targetUserId, null, { + targetUser: hint?.packedTargetUser ?? this.userEntityService.pack(report.targetUser ?? report.targetUserId, me, { schema: 'UserDetailedNotMe', }), - assignee: report.assigneeId ? hint?.packedAssignee ?? this.userEntityService.pack(report.assignee ?? report.assigneeId, null, { + // return hint, or pack by relation, or fetch and pack by id, or null + targetInstance: hint?.packedTargetInstance ?? ( + report.targetUserInstance + ? this.instanceEntityService.pack(report.targetUserInstance, me) + : report.targetUserHost + ? this.instancesRepository.findOneBy({ host: report.targetUserHost }).then(instance => instance + ? this.instanceEntityService.pack(instance, me) + : null) + : null), + assignee: report.assigneeId ? hint?.packedAssignee ?? this.userEntityService.pack(report.assignee ?? report.assigneeId, me, { schema: 'UserDetailedNotMe', }) : null, forwarded: report.forwarded, @@ -61,21 +78,28 @@ export class AbuseUserReportEntityService { @bindThis public async packMany( reports: MiAbuseUserReport[], + me?: MiUser | null, ) { const _reporters = reports.map(({ reporter, reporterId }) => reporter ?? reporterId); const _targetUsers = reports.map(({ targetUser, targetUserId }) => targetUser ?? targetUserId); const _assignees = reports.map(({ assignee, assigneeId }) => assignee ?? assigneeId).filter(x => x != null); const _userMap = await this.userEntityService.packMany( [..._reporters, ..._targetUsers, ..._assignees], - null, + me, { schema: 'UserDetailedNotMe' }, ).then(users => new Map(users.map(u => [u.id, u]))); + const _targetInstances = reports + .map(({ targetUserInstance, targetUserHost }) => targetUserInstance ?? targetUserHost) + .filter((i): i is MiInstance | string => i != null); + const _instanceMap = await this.instanceEntityService.packMany(await this.instanceEntityService.fetchInstancesByHost(_targetInstances), me) + .then(instances => new Map(instances.map(i => [i.host, i]))); return Promise.all( reports.map(report => { const packedReporter = _userMap.get(report.reporterId); const packedTargetUser = _userMap.get(report.targetUserId); + const packedTargetInstance = report.targetUserHost ? _instanceMap.get(report.targetUserHost) : undefined; const packedAssignee = report.assigneeId != null ? _userMap.get(report.assigneeId) : undefined; - return this.pack(report, { packedReporter, packedTargetUser, packedAssignee }); + return this.pack(report, { packedReporter, packedTargetUser, packedAssignee, packedTargetInstance }, me); }), ); } diff --git a/packages/backend/src/core/entities/InstanceEntityService.ts b/packages/backend/src/core/entities/InstanceEntityService.ts index a2ee4b0505..4ca4ff650b 100644 --- a/packages/backend/src/core/entities/InstanceEntityService.ts +++ b/packages/backend/src/core/entities/InstanceEntityService.ts @@ -4,6 +4,7 @@ */ import { Inject, Injectable } from '@nestjs/common'; +import { In } from 'typeorm'; import type { Packed } from '@/misc/json-schema.js'; import type { MiInstance } from '@/models/Instance.js'; import { bindThis } from '@/decorators.js'; @@ -11,7 +12,7 @@ import { UtilityService } from '@/core/UtilityService.js'; import { RoleService } from '@/core/RoleService.js'; import { MiUser } from '@/models/User.js'; import { DI } from '@/di-symbols.js'; -import { MiMeta } from '@/models/_.js'; +import type { InstancesRepository, MiMeta } from '@/models/_.js'; @Injectable() export class InstanceEntityService { @@ -19,6 +20,9 @@ export class InstanceEntityService { @Inject(DI.meta) private meta: MiMeta, + @Inject(DI.instancesRepository) + private readonly instancesRepository: InstancesRepository, + private roleService: RoleService, private utilityService: UtilityService, @@ -73,5 +77,28 @@ export class InstanceEntityService { ) { return Promise.all(instances.map(x => this.pack(x, me))); } + + @bindThis + public async fetchInstancesByHost(instances: (MiInstance | MiInstance['host'])[]): Promise { + const result: MiInstance[] = []; + + const toFetch: string[] = []; + for (const instance of instances) { + if (typeof(instance) === 'string') { + toFetch.push(instance); + } else { + result.push(instance); + } + } + + if (toFetch.length > 0) { + const fetched = await this.instancesRepository.findBy({ + host: In(toFetch), + }); + result.push(...fetched); + } + + return result; + } } diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index 3524119ba1..326baaefd4 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -487,7 +487,10 @@ export class UserEntityService implements OnModuleInit { includeSecrets: false, }, options); - const user = typeof src === 'object' ? src : await this.usersRepository.findOneByOrFail({ id: src }); + const user = typeof src === 'object' ? src : await this.usersRepository.findOneOrFail({ + where: { id: src }, + relations: { userProfile: true }, + }); // migration if (user.avatarId != null && user.avatarUrl === null) { @@ -521,7 +524,7 @@ export class UserEntityService implements OnModuleInit { const iAmModerator = me ? await this.roleService.isModerator(me as MiUser) : false; const profile = isDetailed - ? (opts.userProfile ?? await this.userProfilesRepository.findOneByOrFail({ userId: user.id })) + ? (opts.userProfile ?? user.userProfile ?? await this.userProfilesRepository.findOneByOrFail({ userId: user.id })) : null; let relation: UserRelation | null = null; @@ -556,7 +559,7 @@ export class UserEntityService implements OnModuleInit { } } - const mastoapi = !isDetailed ? opts.userProfile ?? await this.userProfilesRepository.findOneByOrFail({ userId: user.id }) : null; + const mastoapi = !isDetailed ? opts.userProfile ?? user.userProfile ?? await this.userProfilesRepository.findOneByOrFail({ userId: user.id }) : null; const followingCount = profile == null ? null : (profile.followingVisibility === 'public') || isMe || iAmModerator ? user.followingCount : @@ -785,8 +788,13 @@ export class UserEntityService implements OnModuleInit { const _users = users.filter((user): user is MiUser => typeof user !== 'string'); if (_users.length !== users.length) { _users.push( - ...await this.usersRepository.findBy({ - id: In(users.filter((user): user is string => typeof user === 'string')), + ...await this.usersRepository.find({ + where: { + id: In(users.filter((user): user is string => typeof user === 'string')), + }, + relations: { + userProfile: true, + }, }), ); } @@ -800,8 +808,20 @@ export class UserEntityService implements OnModuleInit { let pinNotes: Map = new Map(); if (options?.schema !== 'UserLite') { - profilesMap = await this.userProfilesRepository.findBy({ userId: In(_userIds) }) - .then(profiles => new Map(profiles.map(p => [p.userId, p]))); + const _profiles: MiUserProfile[] = []; + const _profilesToFetch: string[] = []; + for (const user of _users) { + if (user.userProfile) { + _profiles.push(user.userProfile); + } else { + _profilesToFetch.push(user.id); + } + } + if (_profilesToFetch.length > 0) { + const fetched = await this.userProfilesRepository.findBy({ userId: In(_profilesToFetch) }); + _profiles.push(...fetched); + } + profilesMap = new Map(_profiles.map(p => [p.userId, p])); const meId = me ? me.id : null; if (meId) { diff --git a/packages/backend/src/models/AbuseUserReport.ts b/packages/backend/src/models/AbuseUserReport.ts index d43ebf9342..8f8d759004 100644 --- a/packages/backend/src/models/AbuseUserReport.ts +++ b/packages/backend/src/models/AbuseUserReport.ts @@ -4,6 +4,7 @@ */ import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { MiInstance } from '@/models/Instance.js'; import { id } from './util/id.js'; import { MiUser } from './User.js'; @@ -88,11 +89,31 @@ export class MiAbuseUserReport { }) public targetUserHost: string | null; + @ManyToOne(() => MiInstance, { + // TODO create a foreign key constraint after hazelnoot/labs/persisted-instance-blocks is merged + createForeignKeyConstraints: false, + }) + @JoinColumn({ + name: 'targetUserHost', + referencedColumnName: 'host', + }) + public targetUserInstance: MiInstance | null; + @Index() @Column('varchar', { length: 128, nullable: true, comment: '[Denormalized]', }) public reporterHost: string | null; + + @ManyToOne(() => MiInstance, { + // TODO create a foreign key constraint after hazelnoot/labs/persisted-instance-blocks is merged + createForeignKeyConstraints: false, + }) + @JoinColumn({ + name: 'reporterHost', + referencedColumnName: 'host', + }) + public reporterInstance: MiInstance | null; //#endregion } diff --git a/packages/backend/src/models/User.ts b/packages/backend/src/models/User.ts index 3ef5817672..2f13400944 100644 --- a/packages/backend/src/models/User.ts +++ b/packages/backend/src/models/User.ts @@ -8,6 +8,7 @@ import { type UserUnsignedFetchOption, userUnsignedFetchOptions } from '@/const. import { MiInstance } from '@/models/Instance.js'; import { id } from './util/id.js'; import { MiDriveFile } from './DriveFile.js'; +import type { MiUserProfile } from './UserProfile.js'; @Entity('user') @Index(['usernameLower', 'host'], { unique: true }) @@ -395,6 +396,9 @@ export class MiUser { }) public attributionDomains: string[]; + @OneToOne('user_profile', (profile: MiUserProfile) => profile.user) + public userProfile: MiUserProfile | null; + constructor(data: Partial) { if (data == null) return; diff --git a/packages/backend/src/models/UserProfile.ts b/packages/backend/src/models/UserProfile.ts index 29c453dd71..6ee72e6ddd 100644 --- a/packages/backend/src/models/UserProfile.ts +++ b/packages/backend/src/models/UserProfile.ts @@ -17,7 +17,7 @@ export class MiUserProfile { @PrimaryColumn(id()) public userId: MiUser['id']; - @OneToOne(type => MiUser, { + @OneToOne(() => MiUser, user => user.userProfile, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/server/api/endpoints/admin/abuse-user-reports.ts b/packages/backend/src/server/api/endpoints/admin/abuse-user-reports.ts index 0dbfaae054..b8200c09aa 100644 --- a/packages/backend/src/server/api/endpoints/admin/abuse-user-reports.ts +++ b/packages/backend/src/server/api/endpoints/admin/abuse-user-reports.ts @@ -69,6 +69,11 @@ export const meta = { nullable: false, optional: false, ref: 'UserDetailedNotMe', }, + targetInstance: { + type: 'object', + nullable: true, optional: false, + ref: 'FederationInstance', + }, assignee: { type: 'object', nullable: true, optional: false, @@ -115,7 +120,15 @@ export default class extends Endpoint { // eslint- private queryService: QueryService, ) { super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.abuseUserReportsRepository.createQueryBuilder('report'), ps.sinceId, ps.untilId); + const query = this.queryService.makePaginationQuery(this.abuseUserReportsRepository.createQueryBuilder('report'), ps.sinceId, ps.untilId) + .leftJoinAndSelect('report.targetUser', 'targetUser') + .leftJoinAndSelect('targetUser.userProfile', 'targetUserProfile') + .leftJoinAndSelect('report.targetUserInstance', 'targetUserInstance') + .leftJoinAndSelect('report.reporter', 'reporter') + .leftJoinAndSelect('reporter.userProfile', 'reporterProfile') + .leftJoinAndSelect('report.assignee', 'assignee') + .leftJoinAndSelect('assignee.userProfile', 'assigneeProfile') + ; switch (ps.state) { case 'resolved': query.andWhere('report.resolved = TRUE'); break; @@ -134,7 +147,7 @@ export default class extends Endpoint { // eslint- const reports = await query.limit(ps.limit).getMany(); - return await this.abuseUserReportEntityService.packMany(reports); + return await this.abuseUserReportEntityService.packMany(reports, me); }); } } diff --git a/packages/backend/src/server/web/UrlPreviewService.ts b/packages/backend/src/server/web/UrlPreviewService.ts index 2a300782c6..78b2204fbb 100644 --- a/packages/backend/src/server/web/UrlPreviewService.ts +++ b/packages/backend/src/server/web/UrlPreviewService.ts @@ -123,18 +123,6 @@ export class UrlPreviewService { request: FastifyRequest, reply: FastifyReply, ): Promise { - const url = request.query.url; - if (typeof url !== 'string' || !URL.canParse(url)) { - reply.code(400); - return; - } - - const lang = request.query.lang; - if (Array.isArray(lang)) { - reply.code(400); - return; - } - if (!this.meta.urlPreviewEnabled) { return reply.code(403).send({ error: { @@ -145,13 +133,44 @@ export class UrlPreviewService { }); } + const url = request.query.url; + if (typeof url !== 'string' || !URL.canParse(url)) { + reply.code(400); + return; + } + + // Enforce HTTP(S) for input URLs + const urlScheme = this.utilityService.getUrlScheme(url); + if (urlScheme !== 'http:' && urlScheme !== 'https:') { + reply.code(400); + return; + } + + const lang = request.query.lang; + if (Array.isArray(lang)) { + reply.code(400); + return; + } + + // Strip out hash (anchor) + const urlObj = new URL(url); + if (urlObj.hash) { + urlObj.hash = ''; + const params = new URLSearchParams({ url: urlObj.href }); + if (lang) params.set('lang', lang); + const newUrl = `/url?${params.toString()}`; + + reply.redirect(newUrl, 301); + return; + } + // Check rate limit const auth = await this.authenticate(request); if (!await this.checkRateLimit(auth, reply)) { return; } - if (this.utilityService.isBlockedHost(this.meta.blockedHosts, new URL(url).host)) { + if (this.utilityService.isBlockedHost(this.meta.blockedHosts, urlObj.host)) { return reply.code(403).send({ error: { message: 'URL is blocked', @@ -166,7 +185,7 @@ export class UrlPreviewService { return; } - const cacheKey = `${url}@${lang}@${cacheFormatVersion}`; + const cacheKey = getCacheKey(url, lang); if (await this.sendCachedPreview(cacheKey, reply, fetch)) { return; } @@ -217,6 +236,18 @@ export class UrlPreviewService { // Await this to avoid hammering redis when a bunch of URLs are fetched at once await this.previewCache.set(cacheKey, summary); + // Also cache the response URL in case of redirects + if (summary.url !== url) { + const responseCacheKey = getCacheKey(summary.url, lang); + await this.previewCache.set(responseCacheKey, summary); + } + + // Also cache the ActivityPub URL, if different from the others + if (summary.activityPub && summary.activityPub !== summary.url) { + const apCacheKey = getCacheKey(summary.activityPub, lang); + await this.previewCache.set(apCacheKey, summary); + } + // Cache 1 day (matching redis), but only once we finalize the result if (!summary.activityPub || summary.haveNoteLocally) { reply.header('Cache-Control', 'public, max-age=86400'); @@ -533,3 +564,7 @@ export class UrlPreviewService { return true; } } + +function getCacheKey(url: string, lang = 'none') { + return `${url}@${lang}@${cacheFormatVersion}`; +} diff --git a/packages/backend/test/unit/AbuseReportNotificationService.ts b/packages/backend/test/unit/AbuseReportNotificationService.ts index 6d555326fb..ee68b10f1b 100644 --- a/packages/backend/test/unit/AbuseReportNotificationService.ts +++ b/packages/backend/test/unit/AbuseReportNotificationService.ts @@ -367,8 +367,10 @@ describe('AbuseReportNotificationService', () => { id: idService.gen(), targetUserId: alice.id, targetUser: alice, + targetUserInstance: null, reporterId: bob.id, reporter: bob, + reporterInstance: null, assigneeId: null, assignee: null, resolved: false, diff --git a/packages/frontend/package.json b/packages/frontend/package.json index f5c7bcf1b4..640ebe70d6 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -60,6 +60,7 @@ "misskey-reversi": "workspace:*", "moment": "^2.30.1", "photoswipe": "5.4.4", + "promise-limit": "2.7.0", "punycode.js": "2.3.1", "rollup": "4.40.0", "sanitize-html": "2.16.0", diff --git a/packages/frontend/src/components/MkAbuseReport.vue b/packages/frontend/src/components/MkAbuseReport.vue index c52fdb898e..5bf5380a1e 100644 --- a/packages/frontend/src/components/MkAbuseReport.vue +++ b/packages/frontend/src/components/MkAbuseReport.vue @@ -33,10 +33,28 @@ SPDX-License-Identifier: AGPL-3.0-only - + -
- +
+ +
+ + + + + + + +
+
@@ -44,23 +62,24 @@ SPDX-License-Identifier: AGPL-3.0-only
- + +
- + -
- +
+
- +
@@ -78,8 +97,9 @@ SPDX-License-Identifier: AGPL-3.0-only diff --git a/packages/frontend/src/components/MkUrlPreview.vue b/packages/frontend/src/components/MkUrlPreview.vue index 69a1540600..5d0e6e3df7 100644 --- a/packages/frontend/src/components/MkUrlPreview.vue +++ b/packages/frontend/src/components/MkUrlPreview.vue @@ -99,13 +99,24 @@ SPDX-License-Identifier: AGPL-3.0-only
+ + diff --git a/packages/frontend/src/components/SkUrlPreviewGroup.vue b/packages/frontend/src/components/SkUrlPreviewGroup.vue new file mode 100644 index 0000000000..32b11d9db4 --- /dev/null +++ b/packages/frontend/src/components/SkUrlPreviewGroup.vue @@ -0,0 +1,348 @@ + + + + + + + diff --git a/packages/frontend/src/components/global/PageWithHeader.vue b/packages/frontend/src/components/global/PageWithHeader.vue index d2e59bf4ad..485ea687de 100644 --- a/packages/frontend/src/components/global/PageWithHeader.vue +++ b/packages/frontend/src/components/global/PageWithHeader.vue @@ -6,8 +6,8 @@ SPDX-License-Identifier: AGPL-3.0-only