summaryrefslogtreecommitdiff
path: root/packages/backend/src
diff options
context:
space:
mode:
authorHazelnoot <acomputerdog@gmail.com>2025-06-05 08:00:32 +0000
committerHazelnoot <acomputerdog@gmail.com>2025-06-05 08:00:32 +0000
commitf88253b95f5ed16f23a23796f76ba4e8ea5f99b3 (patch)
treebec1ce21bd1b2de423b110a74f4c0dd4199583d4 /packages/backend/src
parentmerge: Add option to keep CWs with "RE:" prefix (!1093) (diff)
parentsupport link attributions in SkUrlPreviewGroup (diff)
downloadsharkey-f88253b95f5ed16f23a23796f76ba4e8ea5f99b3.tar.gz
sharkey-f88253b95f5ed16f23a23796f76ba4e8ea5f99b3.tar.bz2
sharkey-f88253b95f5ed16f23a23796f76ba4e8ea5f99b3.zip
merge: Report admin UX improvements (!1060)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/1060 Approved-by: Marie <github@yuugi.dev> Approved-by: dakkar <dakkar@thenautilus.net>
Diffstat (limited to 'packages/backend/src')
-rw-r--r--packages/backend/src/core/ReversiService.ts2
-rw-r--r--packages/backend/src/core/WebhookTestService.ts3
-rw-r--r--packages/backend/src/core/entities/AbuseUserReportEntityService.ts36
-rw-r--r--packages/backend/src/core/entities/InstanceEntityService.ts29
-rw-r--r--packages/backend/src/core/entities/UserEntityService.ts34
-rw-r--r--packages/backend/src/models/AbuseUserReport.ts21
-rw-r--r--packages/backend/src/models/User.ts4
-rw-r--r--packages/backend/src/models/UserProfile.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/admin/abuse-user-reports.ts17
-rw-r--r--packages/backend/src/server/web/UrlPreviewService.ts55
10 files changed, 176 insertions, 27 deletions
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>): 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<MiInstance[]> {
+ 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<MiUser['id'], MiUserNotePining[]> = 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<MiUser>) {
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<typeof meta, typeof paramDef> { // 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<typeof meta, typeof paramDef> { // 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,26 +123,45 @@ export class UrlPreviewService {
request: FastifyRequest<PreviewRoute>,
reply: FastifyReply,
): Promise<void> {
+ if (!this.meta.urlPreviewEnabled) {
+ return reply.code(403).send({
+ error: {
+ message: 'URL preview is disabled',
+ code: 'URL_PREVIEW_DISABLED',
+ id: '58b36e13-d2f5-0323-b0c6-76aa9dabefb8',
+ },
+ });
+ }
+
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;
}
- if (!this.meta.urlPreviewEnabled) {
- return reply.code(403).send({
- error: {
- message: 'URL preview is disabled',
- code: 'URL_PREVIEW_DISABLED',
- id: '58b36e13-d2f5-0323-b0c6-76aa9dabefb8',
- },
- });
+ // 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
@@ -151,7 +170,7 @@ export class UrlPreviewService {
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}`;
+}