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>
This commit is contained in:
commit
f88253b95f
27 changed files with 846 additions and 135 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ export class MiUserProfile {
|
|||
@PrimaryColumn(id())
|
||||
public userId: MiUser['id'];
|
||||
|
||||
@OneToOne(type => MiUser, {
|
||||
@OneToOne(() => MiUser, user => user.userProfile, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn()
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -123,18 +123,6 @@ export class UrlPreviewService {
|
|||
request: FastifyRequest<PreviewRoute>,
|
||||
reply: FastifyReply,
|
||||
): Promise<void> {
|
||||
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}`;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue