Merge branch 'develop' into 'feature/dialog-announcement-cap'
# Conflicts: # packages/backend/src/core/AnnouncementService.ts # packages/backend/src/server/api/endpoints/admin/announcements/create.ts # packages/backend/src/server/api/endpoints/admin/announcements/update.ts
This commit is contained in:
commit
5760c021fe
121 changed files with 2016 additions and 672 deletions
|
|
@ -14,6 +14,7 @@ import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
|
|||
import { ModerationLogService } from '@/core/ModerationLogService.js';
|
||||
import { SystemAccountService } from '@/core/SystemAccountService.js';
|
||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||
import { trackPromise } from '@/misc/promise-tracker.js';
|
||||
import { IdService } from './IdService.js';
|
||||
|
||||
@Injectable()
|
||||
|
|
@ -68,11 +69,11 @@ export class AbuseReportService {
|
|||
reports.push(report);
|
||||
}
|
||||
|
||||
return Promise.all([
|
||||
trackPromise(Promise.all([
|
||||
this.abuseReportNotificationService.notifyAdminStream(reports),
|
||||
this.abuseReportNotificationService.notifySystemWebhook(reports, 'abuseReport'),
|
||||
this.abuseReportNotificationService.notifyMail(reports),
|
||||
]);
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { GlobalEventService } from '@/core/GlobalEventService.js';
|
|||
import { ModerationLogService } from '@/core/ModerationLogService.js';
|
||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
|
||||
@Injectable()
|
||||
export class AnnouncementService {
|
||||
|
|
@ -36,6 +37,7 @@ export class AnnouncementService {
|
|||
private globalEventService: GlobalEventService,
|
||||
private moderationLogService: ModerationLogService,
|
||||
private announcementEntityService: AnnouncementEntityService,
|
||||
private roleService: RoleService,
|
||||
) {
|
||||
}
|
||||
|
||||
|
|
@ -51,6 +53,7 @@ export class AnnouncementService {
|
|||
const readsQuery = this.announcementReadsRepository.createQueryBuilder('read')
|
||||
.select('read.announcementId')
|
||||
.where('read.userId = :userId', { userId: user.id });
|
||||
const roles = await this.roleService.getUserRoles(user);
|
||||
|
||||
const q = this.announcementsRepository.createQueryBuilder('announcement')
|
||||
.where('announcement.isActive = true')
|
||||
|
|
@ -63,6 +66,10 @@ export class AnnouncementService {
|
|||
qb.orWhere('announcement.forExistingUsers = false');
|
||||
qb.orWhere('announcement.id > :userId', { userId: user.id });
|
||||
}))
|
||||
.andWhere(new Brackets(qb => {
|
||||
qb.orWhere('announcement.forRoles && :roles', { roles: roles.map((r) => r.id) });
|
||||
qb.orWhere('announcement.forRoles = \'{}\'');
|
||||
}))
|
||||
.andWhere(`announcement.id NOT IN (${ readsQuery.getQuery() })`);
|
||||
|
||||
q.setParameters(readsQuery.getParameters());
|
||||
|
|
@ -85,6 +92,7 @@ export class AnnouncementService {
|
|||
icon: values.icon,
|
||||
display: values.display,
|
||||
forExistingUsers: values.forExistingUsers,
|
||||
forRoles: values.forRoles,
|
||||
silence: values.silence,
|
||||
needConfirmationToRead: values.needConfirmationToRead,
|
||||
confetti: values.confetti,
|
||||
|
|
@ -143,6 +151,7 @@ export class AnnouncementService {
|
|||
display: values.display,
|
||||
icon: values.icon,
|
||||
forExistingUsers: values.forExistingUsers,
|
||||
forRoles: values.forRoles,
|
||||
silence: values.silence,
|
||||
needConfirmationToRead: values.needConfirmationToRead,
|
||||
confetti: values.confetti,
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { In, IsNull } from 'typeorm';
|
|||
import type { BlockingsRepository, FollowingsRepository, MutingsRepository, RenoteMutingsRepository, MiUserProfile, UserProfilesRepository, UsersRepository, MiNote, MiFollowing, NoteThreadMutingsRepository } from '@/models/_.js';
|
||||
import { MemoryKVCache, RedisKVCache } from '@/misc/cache.js';
|
||||
import { QuantumKVCache } from '@/misc/QuantumKVCache.js';
|
||||
import type { MiLocalUser, MiUser } from '@/models/User.js';
|
||||
import type { MiLocalUser, MiRemoteUser, MiUser } from '@/models/User.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
|
|
@ -387,6 +387,22 @@ export class CacheService implements OnApplicationShutdown {
|
|||
}) ?? null;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async findRemoteUserById(userId: MiUser['id']): Promise<MiRemoteUser | null> {
|
||||
const user = await this.findUserById(userId);
|
||||
|
||||
if (user.host == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return user as MiRemoteUser;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public findOptionalUserById(userId: MiUser['id']) {
|
||||
return this.userByIdCache.fetchMaybe(userId, async () => await this.usersRepository.findOneBy({ id: userId }) ?? undefined);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async getFollowStats(userId: MiUser['id']): Promise<FollowStats> {
|
||||
return await this.userFollowStatsCache.fetch(userId, async () => {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,10 @@ import { bindThis } from '@/decorators.js';
|
|||
import { MetaService } from '@/core/MetaService.js';
|
||||
import { MiMeta } from '@/models/Meta.js';
|
||||
import Logger from '@/logger.js';
|
||||
import { LoggerService } from './LoggerService.js';
|
||||
import { LoggerService } from '@/core/LoggerService.js';
|
||||
import { CaptchaError } from '@/misc/captcha-error.js';
|
||||
|
||||
export { CaptchaError } from '@/misc/captcha-error.js';
|
||||
|
||||
export const supportedCaptchaProviders = ['none', 'hcaptcha', 'mcaptcha', 'recaptcha', 'turnstile', 'fc', 'testcaptcha'] as const;
|
||||
export type CaptchaProvider = typeof supportedCaptchaProviders[number];
|
||||
|
|
@ -49,18 +52,6 @@ export type CaptchaSetting = {
|
|||
}
|
||||
};
|
||||
|
||||
export class CaptchaError extends Error {
|
||||
public readonly code: CaptchaErrorCode;
|
||||
public readonly cause?: unknown;
|
||||
|
||||
constructor(code: CaptchaErrorCode, message: string, cause?: unknown) {
|
||||
super(message, cause ? { cause } : undefined);
|
||||
this.code = code;
|
||||
this.cause = cause;
|
||||
this.name = 'CaptchaError';
|
||||
}
|
||||
}
|
||||
|
||||
export type CaptchaSaveSuccess = {
|
||||
success: true;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -580,11 +580,20 @@ export class ChatService {
|
|||
public async deleteRoom(room: MiChatRoom, deleter?: MiUser) {
|
||||
await this.chatRoomsRepository.delete(room.id);
|
||||
|
||||
// Erase any message notifications for this room
|
||||
const redisPipeline = this.redisClient.pipeline();
|
||||
const memberships = await this.chatRoomMembershipsRepository.findBy({ roomId: room.id });
|
||||
for (const membership of memberships) {
|
||||
redisPipeline.del(`newRoomChatMessageExists:${membership.userId}:${room.id}`);
|
||||
redisPipeline.srem(`newChatMessagesExists:${membership.userId}`, `room:${room.id}`);
|
||||
}
|
||||
await redisPipeline.exec();
|
||||
|
||||
if (deleter) {
|
||||
const deleterIsModerator = await this.roleService.isModerator(deleter);
|
||||
|
||||
if (deleterIsModerator) {
|
||||
this.moderationLogService.log(deleter, 'deleteChatRoom', {
|
||||
await this.moderationLogService.log(deleter, 'deleteChatRoom', {
|
||||
roomId: room.id,
|
||||
room: room,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import type Logger from '@/logger.js';
|
|||
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { renderInlineError } from '@/misc/render-inline-error.js';
|
||||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
|
||||
@Injectable()
|
||||
export class DownloadService {
|
||||
|
|
@ -30,6 +31,7 @@ export class DownloadService {
|
|||
|
||||
private httpRequestService: HttpRequestService,
|
||||
private loggerService: LoggerService,
|
||||
private readonly utilityService: UtilityService,
|
||||
) {
|
||||
this.logger = this.loggerService.getLogger('download');
|
||||
}
|
||||
|
|
@ -38,6 +40,8 @@ export class DownloadService {
|
|||
public async downloadUrl(url: string, path: string, options: { timeout?: number, operationTimeout?: number, maxSize?: number } = {} ): Promise<{
|
||||
filename: string;
|
||||
}> {
|
||||
this.utilityService.assertUrl(url);
|
||||
|
||||
this.logger.debug(`Downloading ${chalk.cyan(url)} to ${chalk.cyanBright(path)} ...`);
|
||||
|
||||
const timeout = options.timeout ?? 30 * 1000;
|
||||
|
|
|
|||
|
|
@ -154,8 +154,8 @@ export class DriveService {
|
|||
@bindThis
|
||||
private async save(file: MiDriveFile, path: string, name: string, info: FileInfo): Promise<MiDriveFile> {
|
||||
const type = info.type.mime;
|
||||
const hash = info.md5;
|
||||
const size = info.size;
|
||||
let hash = info.md5;
|
||||
let size = info.size;
|
||||
|
||||
// thunbnail, webpublic を必要なら生成
|
||||
const alts = await this.generateAlts(path, type, !file.uri);
|
||||
|
|
@ -163,6 +163,9 @@ export class DriveService {
|
|||
if (type && type.startsWith('video/')) {
|
||||
try {
|
||||
await this.videoProcessingService.webOptimizeVideo(path, type);
|
||||
const newInfo = await this.fileInfoService.getFileInfo(path);
|
||||
hash = newInfo.md5;
|
||||
size = newInfo.size;
|
||||
} catch (err) {
|
||||
this.registerLogger.warn(`Video optimization failed: ${renderInlineError(err)}`);
|
||||
}
|
||||
|
|
@ -738,14 +741,14 @@ export class DriveService {
|
|||
@bindThis
|
||||
public async deleteFile(file: MiDriveFile, isExpired = false, deleter?: MiUser) {
|
||||
if (file.storedInternal) {
|
||||
this.internalStorageService.del(file.accessKey!);
|
||||
this.deleteLocalFile(file.accessKey!);
|
||||
|
||||
if (file.thumbnailUrl) {
|
||||
this.internalStorageService.del(file.thumbnailAccessKey!);
|
||||
this.deleteLocalFile(file.thumbnailAccessKey!);
|
||||
}
|
||||
|
||||
if (file.webpublicUrl) {
|
||||
this.internalStorageService.del(file.webpublicAccessKey!);
|
||||
this.deleteLocalFile(file.webpublicAccessKey!);
|
||||
}
|
||||
} else if (!file.isLink) {
|
||||
this.queueService.createDeleteObjectStorageFileJob(file.accessKey!);
|
||||
|
|
@ -767,14 +770,14 @@ export class DriveService {
|
|||
const promises = [];
|
||||
|
||||
if (file.storedInternal) {
|
||||
promises.push(this.internalStorageService.del(file.accessKey!));
|
||||
promises.push(this.deleteLocalFile(file.accessKey!));
|
||||
|
||||
if (file.thumbnailUrl) {
|
||||
promises.push(this.internalStorageService.del(file.thumbnailAccessKey!));
|
||||
promises.push(this.deleteLocalFile(file.thumbnailAccessKey!));
|
||||
}
|
||||
|
||||
if (file.webpublicUrl) {
|
||||
promises.push(this.internalStorageService.del(file.webpublicAccessKey!));
|
||||
promises.push(this.deleteLocalFile(file.webpublicAccessKey!));
|
||||
}
|
||||
} else if (!file.isLink) {
|
||||
promises.push(this.deleteObjectStorageFile(file.accessKey!));
|
||||
|
|
@ -861,6 +864,22 @@ export class DriveService {
|
|||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async deleteLocalFile(key: string) {
|
||||
try {
|
||||
await this.internalStorageService.del(key);
|
||||
} catch (err: any) {
|
||||
if (err.code === 'ENOENT') {
|
||||
this.deleteLogger.warn(`The file to delete did not exist: ${key}. Skipping this.`);
|
||||
return;
|
||||
} else {
|
||||
throw new Error(`Failed to delete the file: ${key}`, {
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async uploadFromUrl({
|
||||
url,
|
||||
|
|
|
|||
|
|
@ -17,9 +17,9 @@ import { StatusError } from '@/misc/status-error.js';
|
|||
import { bindThis } from '@/decorators.js';
|
||||
import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js';
|
||||
import type { IObject, IObjectWithId } from '@/core/activitypub/type.js';
|
||||
import { ApUtilityService } from './activitypub/ApUtilityService.js';
|
||||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
import { ApUtilityService } from '@/core/activitypub/ApUtilityService.js';
|
||||
import type { Response } from 'node-fetch';
|
||||
import type { URL } from 'node:url';
|
||||
import type { Socket } from 'node:net';
|
||||
|
||||
export type HttpRequestSendOptions = {
|
||||
|
|
@ -27,7 +27,27 @@ export type HttpRequestSendOptions = {
|
|||
validators?: ((res: Response) => void)[];
|
||||
};
|
||||
|
||||
export function isPrivateIp(allowedPrivateNetworks: PrivateNetwork[] | undefined, ip: string, port?: number): boolean {
|
||||
export async function isPrivateUrl(url: URL, lookup: net.LookupFunction): Promise<boolean> {
|
||||
const ip = await resolveIp(url, lookup);
|
||||
return ip.range() !== 'unicast';
|
||||
}
|
||||
|
||||
export async function resolveIp(url: URL, lookup: net.LookupFunction) {
|
||||
if (ipaddr.isValid(url.hostname)) {
|
||||
return ipaddr.parse(url.hostname);
|
||||
}
|
||||
|
||||
const resolvedIp = await new Promise<string>((resolve, reject) => {
|
||||
lookup(url.hostname, {}, (err, address) => {
|
||||
if (err) reject(err);
|
||||
else resolve(address as string);
|
||||
});
|
||||
});
|
||||
|
||||
return ipaddr.parse(resolvedIp);
|
||||
}
|
||||
|
||||
export function isAllowedPrivateIp(allowedPrivateNetworks: PrivateNetwork[] | undefined, ip: string, port?: number): boolean {
|
||||
const parsedIp = ipaddr.parse(ip);
|
||||
|
||||
for (const { cidr, ports } of allowedPrivateNetworks ?? []) {
|
||||
|
|
@ -44,7 +64,7 @@ export function isPrivateIp(allowedPrivateNetworks: PrivateNetwork[] | undefined
|
|||
export function validateSocketConnect(allowedPrivateNetworks: PrivateNetwork[] | undefined, socket: Socket): void {
|
||||
const address = socket.remoteAddress;
|
||||
if (address && ipaddr.isValid(address)) {
|
||||
if (isPrivateIp(allowedPrivateNetworks, address, socket.remotePort)) {
|
||||
if (isAllowedPrivateIp(allowedPrivateNetworks, address, socket.remotePort)) {
|
||||
socket.destroy(new Error(`Blocked address: ${address}`));
|
||||
}
|
||||
}
|
||||
|
|
@ -128,10 +148,16 @@ export class HttpRequestService {
|
|||
*/
|
||||
public readonly httpsAgent: https.Agent;
|
||||
|
||||
/**
|
||||
* Get shared DNS resolver
|
||||
*/
|
||||
public readonly lookup: net.LookupFunction;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
private readonly apUtilityService: ApUtilityService,
|
||||
private readonly utilityService: UtilityService,
|
||||
) {
|
||||
const cache = new CacheableLookup({
|
||||
maxTtl: 3600, // 1hours
|
||||
|
|
@ -139,6 +165,8 @@ export class HttpRequestService {
|
|||
lookup: false, // nativeのdns.lookupにfallbackしない
|
||||
});
|
||||
|
||||
this.lookup = cache.lookup as unknown as net.LookupFunction;
|
||||
|
||||
const agentOption = {
|
||||
keepAlive: true,
|
||||
keepAliveMsecs: 30 * 1000,
|
||||
|
|
@ -236,8 +264,6 @@ export class HttpRequestService {
|
|||
|
||||
@bindThis
|
||||
public async getActivityJson(url: string, isLocalAddressAllowed = false, allowAnonymous = false): Promise<IObjectWithId> {
|
||||
this.apUtilityService.assertApUrl(url);
|
||||
|
||||
const res = await this.send(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
|
|
@ -303,6 +329,7 @@ export class HttpRequestService {
|
|||
timeout?: number,
|
||||
size?: number,
|
||||
isLocalAddressAllowed?: boolean,
|
||||
allowHttp?: boolean,
|
||||
} = {},
|
||||
extra: HttpRequestSendOptions = {
|
||||
throwErrorWhenResponseNotOk: true,
|
||||
|
|
@ -311,6 +338,10 @@ export class HttpRequestService {
|
|||
): Promise<Response> {
|
||||
const timeout = args.timeout ?? 5000;
|
||||
|
||||
const parsedUrl = new URL(url);
|
||||
const allowHttp = args.allowHttp || await isPrivateUrl(parsedUrl, this.lookup);
|
||||
this.utilityService.assertUrl(parsedUrl, allowHttp);
|
||||
|
||||
const controller = new AbortController();
|
||||
setTimeout(() => {
|
||||
controller.abort();
|
||||
|
|
@ -318,7 +349,7 @@ export class HttpRequestService {
|
|||
|
||||
const isLocalAddressAllowed = args.isLocalAddressAllowed ?? false;
|
||||
|
||||
const res = await fetch(url, {
|
||||
const res = await fetch(parsedUrl, {
|
||||
method: args.method ?? 'GET',
|
||||
headers: {
|
||||
'User-Agent': this.config.userAgent,
|
||||
|
|
|
|||
|
|
@ -377,7 +377,7 @@ export class MfmService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public toHtml(nodes: mfm.MfmNode[] | null, mentionedRemoteUsers: IMentionedRemoteUsers = [], additionalAppenders: Appender[] = []) {
|
||||
public toHtml(nodes: mfm.MfmNode[] | null, mentionedRemoteUsers: IMentionedRemoteUsers = [], additionalAppenders: Appender[] = [], inline = false) {
|
||||
if (nodes == null) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -626,9 +626,15 @@ export class MfmService {
|
|||
additionalAppender(doc, body);
|
||||
}
|
||||
|
||||
return domserializer.render(body, {
|
||||
let result = domserializer.render(body, {
|
||||
encodeEntities: 'utf8'
|
||||
});
|
||||
|
||||
if (inline) {
|
||||
result = result.replace(/^<p>/, '').replace(/<\/p>$/, '');
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// the toMastoApiHtml function was taken from Iceshrimp and written by zotan and modified by marie to work with the current MK version
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { bindThis } from '@/decorators.js';
|
|||
import type { Antenna } from '@/server/api/endpoints/i/import-antennas.js';
|
||||
import { ApRequestCreator } from '@/core/activitypub/ApRequestService.js';
|
||||
import { type SystemWebhookPayload } from '@/core/SystemWebhookService.js';
|
||||
import { MiNote } from '@/models/Note.js';
|
||||
import { type UserWebhookPayload } from './UserWebhookService.js';
|
||||
import type {
|
||||
DbJobData,
|
||||
|
|
@ -40,7 +41,6 @@ import type {
|
|||
} from './QueueModule.js';
|
||||
import type httpSignature from '@peertube/http-signature';
|
||||
import type * as Bull from 'bullmq';
|
||||
import { MiNote } from '@/models/Note.js';
|
||||
|
||||
export const QUEUE_TYPES = [
|
||||
'system',
|
||||
|
|
@ -231,6 +231,9 @@ export class QueueService {
|
|||
age: 3600 * 24 * 7, // keep up to 7 days
|
||||
count: 100,
|
||||
},
|
||||
deduplication: activity.id ? {
|
||||
id: activity.id,
|
||||
} : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -247,6 +250,9 @@ export class QueueService {
|
|||
age: 3600 * 24 * 7, // keep up to 7 days
|
||||
count: 100,
|
||||
},
|
||||
deduplication: {
|
||||
id: user.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -263,6 +269,9 @@ export class QueueService {
|
|||
age: 3600 * 24 * 7, // keep up to 7 days
|
||||
count: 100,
|
||||
},
|
||||
deduplication: {
|
||||
id: user.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -273,6 +282,9 @@ export class QueueService {
|
|||
}, {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
deduplication: {
|
||||
id: user.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -289,6 +301,9 @@ export class QueueService {
|
|||
age: 3600 * 24 * 7, // keep up to 7 days
|
||||
count: 100,
|
||||
},
|
||||
deduplication: {
|
||||
id: user.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -305,6 +320,9 @@ export class QueueService {
|
|||
age: 3600 * 24 * 7, // keep up to 7 days
|
||||
count: 100,
|
||||
},
|
||||
deduplication: {
|
||||
id: user.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -321,6 +339,9 @@ export class QueueService {
|
|||
age: 3600 * 24 * 7, // keep up to 7 days
|
||||
count: 100,
|
||||
},
|
||||
deduplication: {
|
||||
id: user.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -339,6 +360,9 @@ export class QueueService {
|
|||
age: 3600 * 24 * 7, // keep up to 7 days
|
||||
count: 100,
|
||||
},
|
||||
deduplication: {
|
||||
id: user.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -355,6 +379,9 @@ export class QueueService {
|
|||
age: 3600 * 24 * 7, // keep up to 7 days
|
||||
count: 100,
|
||||
},
|
||||
deduplication: {
|
||||
id: user.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -371,6 +398,9 @@ export class QueueService {
|
|||
age: 3600 * 24 * 7, // keep up to 7 days
|
||||
count: 100,
|
||||
},
|
||||
deduplication: {
|
||||
id: user.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -387,6 +417,9 @@ export class QueueService {
|
|||
age: 3600 * 24 * 7, // keep up to 7 days
|
||||
count: 100,
|
||||
},
|
||||
deduplication: {
|
||||
id: user.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -403,6 +436,9 @@ export class QueueService {
|
|||
age: 3600 * 24 * 7, // keep up to 7 days
|
||||
count: 100,
|
||||
},
|
||||
deduplication: {
|
||||
id: user.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -421,6 +457,9 @@ export class QueueService {
|
|||
age: 3600 * 24 * 7, // keep up to 7 days
|
||||
count: 100,
|
||||
},
|
||||
deduplication: {
|
||||
id: `${user.id}_${fileId}_${withReplies ?? false}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -433,6 +472,9 @@ export class QueueService {
|
|||
}, {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
deduplication: {
|
||||
id: `${user.id}_${fileId}_${type ?? null}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -492,6 +534,9 @@ export class QueueService {
|
|||
age: 3600 * 24 * 7, // keep up to 7 days
|
||||
count: 100,
|
||||
},
|
||||
deduplication: {
|
||||
id: `${user.id}_${fileId}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -509,6 +554,9 @@ export class QueueService {
|
|||
age: 3600 * 24 * 7, // keep up to 7 days
|
||||
count: 100,
|
||||
},
|
||||
deduplication: {
|
||||
id: `${user.id}_${fileId}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -554,6 +602,9 @@ export class QueueService {
|
|||
age: 3600 * 24 * 7, // keep up to 7 days
|
||||
count: 100,
|
||||
},
|
||||
deduplication: {
|
||||
id: `${user.id}_${fileId}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -571,14 +622,18 @@ export class QueueService {
|
|||
age: 3600 * 24 * 7, // keep up to 7 days
|
||||
count: 100,
|
||||
},
|
||||
deduplication: {
|
||||
id: `${user.id}_${fileId}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public createImportAntennasJob(user: ThinUser, antenna: Antenna) {
|
||||
public createImportAntennasJob(user: ThinUser, antenna: Antenna, fileId: MiDriveFile['id']) {
|
||||
return this.dbQueue.add('importAntennas', {
|
||||
user: { id: user.id },
|
||||
antenna,
|
||||
fileId,
|
||||
}, {
|
||||
removeOnComplete: {
|
||||
age: 3600 * 24 * 7, // keep up to 7 days
|
||||
|
|
@ -588,6 +643,9 @@ export class QueueService {
|
|||
age: 3600 * 24 * 7, // keep up to 7 days
|
||||
count: 100,
|
||||
},
|
||||
deduplication: {
|
||||
id: `${user.id}_${fileId}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -605,6 +663,9 @@ export class QueueService {
|
|||
age: 3600 * 24 * 7, // keep up to 7 days
|
||||
count: 100,
|
||||
},
|
||||
deduplication: {
|
||||
id: user.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -663,6 +724,9 @@ export class QueueService {
|
|||
count: 100,
|
||||
},
|
||||
...opts,
|
||||
deduplication: {
|
||||
id: `${data.from.id}_${data.to.id}_${data.requestId ?? ''}_${data.silent ?? false}_${data.withReplies ?? false}`,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -680,6 +744,9 @@ export class QueueService {
|
|||
age: 3600 * 24 * 7, // keep up to 7 days
|
||||
count: 100,
|
||||
},
|
||||
deduplication: {
|
||||
id: key,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -697,6 +764,9 @@ export class QueueService {
|
|||
age: 3600 * 24 * 7, // keep up to 7 days
|
||||
count: 100,
|
||||
},
|
||||
deduplication: {
|
||||
id: `${olderThanSeconds}_${keepFilesInUse}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -879,7 +949,7 @@ export class QueueService {
|
|||
public async queueGetJobs(queueType: typeof QUEUE_TYPES[number], jobTypes: JobType[], search?: string) {
|
||||
const RETURN_LIMIT = 100;
|
||||
const queue = this.getQueue(queueType);
|
||||
let jobs: Bull.Job[];
|
||||
let jobs: (Bull.Job | null)[];
|
||||
|
||||
if (search) {
|
||||
jobs = await queue.getJobs(jobTypes, 0, 1000);
|
||||
|
|
@ -896,7 +966,9 @@ export class QueueService {
|
|||
jobs = await queue.getJobs(jobTypes, 0, RETURN_LIMIT);
|
||||
}
|
||||
|
||||
return jobs.map(job => this.packJobData(job));
|
||||
return jobs
|
||||
.filter(job => job != null) // not sure how this happens, but it does
|
||||
.map(job => this.packJobData(job));
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Not, IsNull } from 'typeorm';
|
||||
import { Not, IsNull, DataSource } from 'typeorm';
|
||||
import type { FollowingsRepository, FollowRequestsRepository, UsersRepository } from '@/models/_.js';
|
||||
import { MiUser } from '@/models/User.js';
|
||||
import { QueueService } from '@/core/QueueService.js';
|
||||
|
|
@ -21,6 +21,7 @@ import { LoggerService } from '@/core/LoggerService.js';
|
|||
import type Logger from '@/logger.js';
|
||||
import { renderInlineError } from '@/misc/render-inline-error.js';
|
||||
import { trackPromise } from '@/misc/promise-tracker.js';
|
||||
import { InternalEventService } from '@/core/InternalEventService.js';
|
||||
|
||||
@Injectable()
|
||||
export class UserSuspendService {
|
||||
|
|
@ -36,12 +37,16 @@ export class UserSuspendService {
|
|||
@Inject(DI.followRequestsRepository)
|
||||
private followRequestsRepository: FollowRequestsRepository,
|
||||
|
||||
@Inject(DI.db)
|
||||
private db: DataSource,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
private queueService: QueueService,
|
||||
private globalEventService: GlobalEventService,
|
||||
private apRendererService: ApRendererService,
|
||||
private moderationLogService: ModerationLogService,
|
||||
private readonly cacheService: CacheService,
|
||||
private readonly internalEventService: InternalEventService,
|
||||
|
||||
loggerService: LoggerService,
|
||||
) {
|
||||
|
|
@ -56,6 +61,8 @@ export class UserSuspendService {
|
|||
isSuspended: true,
|
||||
});
|
||||
|
||||
await this.internalEventService.emit(user.host == null ? 'localUserUpdated' : 'remoteUserUpdated', { id: user.id });
|
||||
|
||||
await this.moderationLogService.log(moderator, 'suspend', {
|
||||
userId: user.id,
|
||||
userUsername: user.username,
|
||||
|
|
@ -74,6 +81,8 @@ export class UserSuspendService {
|
|||
isSuspended: false,
|
||||
});
|
||||
|
||||
await this.internalEventService.emit(user.host == null ? 'localUserUpdated' : 'remoteUserUpdated', { id: user.id });
|
||||
|
||||
await this.moderationLogService.log(moderator, 'unsuspend', {
|
||||
userId: user.id,
|
||||
userUsername: user.username,
|
||||
|
|
@ -178,30 +187,29 @@ export class UserSuspendService {
|
|||
// Freeze follow relations with all remote users
|
||||
await this.followingsRepository
|
||||
.createQueryBuilder('following')
|
||||
.orWhere({
|
||||
followeeId: user.id,
|
||||
followerHost: Not(IsNull()),
|
||||
})
|
||||
.update({
|
||||
isFollowerHibernated: true,
|
||||
})
|
||||
.where({
|
||||
followeeId: user.id,
|
||||
followerHost: Not(IsNull()),
|
||||
})
|
||||
.execute();
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async unFreezeAll(user: MiUser): Promise<void> {
|
||||
// Restore follow relations with all remote users
|
||||
await this.followingsRepository
|
||||
.createQueryBuilder('following')
|
||||
.innerJoin(MiUser, 'follower', 'user.id = following.followerId')
|
||||
.andWhere('follower.isHibernated = false') // Don't unfreeze if the follower is *actually* frozen
|
||||
.andWhere({
|
||||
followeeId: user.id,
|
||||
followerHost: Not(IsNull()),
|
||||
})
|
||||
.update({
|
||||
isFollowerHibernated: false,
|
||||
})
|
||||
.execute();
|
||||
|
||||
// TypeORM does not support UPDATE with JOIN: https://github.com/typeorm/typeorm/issues/564#issuecomment-310331468
|
||||
await this.db.query(`
|
||||
UPDATE "following"
|
||||
SET "isFollowerHibernated" = false
|
||||
FROM "user"
|
||||
WHERE "user"."id" = "following"."followerId"
|
||||
AND "user"."isHibernated" = false -- Don't unfreeze if the follower is *actually* frozen
|
||||
AND "followeeId" = $1
|
||||
AND "followeeHost" IS NOT NULL
|
||||
`, [user.id]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,8 +11,10 @@ import semver from 'semver';
|
|||
import { DI } from '@/di-symbols.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { MiMeta, SoftwareSuspension } from '@/models/Meta.js';
|
||||
import { MiInstance } from '@/models/Instance.js';
|
||||
import type { MiMeta, SoftwareSuspension } from '@/models/Meta.js';
|
||||
import type { MiInstance } from '@/models/Instance.js';
|
||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||
import { EnvService } from '@/core/EnvService.js';
|
||||
|
||||
@Injectable()
|
||||
export class UtilityService {
|
||||
|
|
@ -22,6 +24,8 @@ export class UtilityService {
|
|||
|
||||
@Inject(DI.meta)
|
||||
private meta: MiMeta,
|
||||
|
||||
private readonly envService: EnvService,
|
||||
) {
|
||||
}
|
||||
|
||||
|
|
@ -183,8 +187,8 @@ export class UtilityService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public punyHostPSLDomain(url: string): string {
|
||||
const urlObj = new URL(url);
|
||||
public punyHostPSLDomain(url: string | URL): string {
|
||||
const urlObj = typeof(url) === 'object' ? url : new URL(url);
|
||||
const hostname = urlObj.hostname;
|
||||
const domain = this.specialSuffix(hostname) ?? psl.get(hostname) ?? hostname;
|
||||
const host = `${this.toPuny(domain)}${urlObj.port.length > 0 ? ':' + urlObj.port : ''}`;
|
||||
|
|
@ -218,17 +222,84 @@ export class UtilityService {
|
|||
|
||||
@bindThis
|
||||
public isDeliverSuspendedSoftware(software: Pick<MiInstance, 'softwareName' | 'softwareVersion'>): SoftwareSuspension | undefined {
|
||||
if (software.softwareName == null) return undefined;
|
||||
if (software.softwareVersion == null) {
|
||||
// software version is null; suspend iff versionRange is *
|
||||
return this.meta.deliverSuspendedSoftware.find(x =>
|
||||
x.software === software.softwareName
|
||||
&& x.versionRange.trim() === '*');
|
||||
} else {
|
||||
const softwareVersion = software.softwareVersion;
|
||||
return this.meta.deliverSuspendedSoftware.find(x =>
|
||||
x.software === software.softwareName
|
||||
&& semver.satisfies(softwareVersion, x.versionRange, { includePrerelease: true }));
|
||||
// a missing name or version is treated as the empty string
|
||||
const softwareName = software.softwareName ?? '';
|
||||
const softwareVersion = software.softwareVersion ?? '';
|
||||
|
||||
function maybeRegexpMatch(test: string, target: string): boolean {
|
||||
const regexpStrPair = test.trim().match(/^\/(.+)\/(.*)$/);
|
||||
if (!regexpStrPair) return false; // not a regexp, can't match
|
||||
|
||||
try {
|
||||
return new RE2(regexpStrPair[1], regexpStrPair[2]).test(target);
|
||||
} catch (err) {
|
||||
return false; // not a well-formed regexp, can't match
|
||||
}
|
||||
}
|
||||
|
||||
// each element of `meta.deliverSuspendedSoftware` can have a
|
||||
// normal string, a `*`, or a `/regexp/` for software or
|
||||
// versionRange
|
||||
return this.meta.deliverSuspendedSoftware.find(
|
||||
x => (
|
||||
(
|
||||
x.software.trim() === '*' ||
|
||||
x.software === softwareName ||
|
||||
maybeRegexpMatch(x.software, softwareName)
|
||||
) && (
|
||||
x.versionRange.trim() === '*' ||
|
||||
semver.satisfies(softwareVersion, x.versionRange, { includePrerelease: true }) ||
|
||||
maybeRegexpMatch(x.versionRange, softwareVersion)
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies that a provided URL is in a format acceptable for federation.
|
||||
* @throws {IdentifiableError} If URL cannot be parsed
|
||||
* @throws {IdentifiableError} If URL is not HTTPS
|
||||
* @throws {IdentifiableError} If URL contains credentials
|
||||
*/
|
||||
@bindThis
|
||||
public assertUrl(url: string | URL, allowHttp?: boolean): URL | never {
|
||||
// If string, parse and validate
|
||||
if (typeof(url) === 'string') {
|
||||
try {
|
||||
url = new URL(url);
|
||||
} catch {
|
||||
throw new IdentifiableError('0bedd29b-e3bf-4604-af51-d3352e2518af', `invalid url ${url}: not a valid URL`);
|
||||
}
|
||||
}
|
||||
|
||||
// Must be HTTPS
|
||||
if (!this.checkHttps(url, allowHttp)) {
|
||||
throw new IdentifiableError('0bedd29b-e3bf-4604-af51-d3352e2518af', `invalid url ${url}: unsupported protocol ${url.protocol}`);
|
||||
}
|
||||
|
||||
// Must not have credentials
|
||||
if (url.username || url.password) {
|
||||
throw new IdentifiableError('0bedd29b-e3bf-4604-af51-d3352e2518af', `invalid url ${url}: contains embedded credentials`);
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the URL contains HTTPS.
|
||||
* Additionally, allows HTTP in non-production environments.
|
||||
* Based on check-https.ts.
|
||||
*/
|
||||
@bindThis
|
||||
public checkHttps(url: string | URL, allowHttp = false): boolean {
|
||||
const isNonProd = this.envService.env.NODE_ENV !== 'production';
|
||||
|
||||
try {
|
||||
const proto = new URL(url).protocol;
|
||||
return proto === 'https:' || (proto === 'http:' && (isNonProd || allowHttp));
|
||||
} catch {
|
||||
// Invalid URLs don't "count" as HTTPS
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -102,6 +102,7 @@ export class VideoProcessingService {
|
|||
.format(outputFormat) // Specify output format
|
||||
.addOutputOptions('-c copy') // Copy streams without re-encoding
|
||||
.addOutputOptions('-movflags +faststart')
|
||||
.addOutputOptions('-map 0')
|
||||
.on('error', reject)
|
||||
.on('end', async () => {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -443,6 +443,8 @@ export class WebhookTestService {
|
|||
return {
|
||||
...user,
|
||||
createdAt: this.idService.parse(user.id).date.toISOString(),
|
||||
updatedAt: null,
|
||||
lastFetchedAt: null,
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
username: user.username,
|
||||
|
|
|
|||
|
|
@ -585,7 +585,7 @@ export class ApRendererService {
|
|||
const attachment = profile.fields.map(field => ({
|
||||
type: 'PropertyValue',
|
||||
name: field.name,
|
||||
value: this.mfmService.toHtml(mfm.parse(field.value)),
|
||||
value: this.mfmService.toHtml(mfm.parse(field.value), [], [], true),
|
||||
}));
|
||||
|
||||
const emojis = await this.getEmojis(user.emojis);
|
||||
|
|
@ -750,9 +750,11 @@ export class ApRendererService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public renderUpdate(object: string | IObject, user: { id: MiUser['id'] }): IUpdate {
|
||||
public renderUpdate(object: IObject, user: { id: MiUser['id'] }): IUpdate {
|
||||
// Deterministic activity IDs to allow de-duplication by remote instances
|
||||
const updatedAt = object.updated ? new Date(object.updated).getTime() : Date.now();
|
||||
return {
|
||||
id: `${this.config.url}/users/${user.id}#updates/${new Date().getTime()}`,
|
||||
id: `${this.config.url}/users/${user.id}#updates/${updatedAt}`,
|
||||
actor: this.userEntityService.genLocalUserUri(user.id),
|
||||
type: 'Update',
|
||||
to: ['https://www.w3.org/ns/activitystreams#Public'],
|
||||
|
|
|
|||
|
|
@ -157,8 +157,6 @@ export class ApRequestService {
|
|||
|
||||
@bindThis
|
||||
public async signedPost(user: { id: MiUser['id'] }, url: string, object: unknown, digest?: string): Promise<void> {
|
||||
this.apUtilityService.assertApUrl(url);
|
||||
|
||||
const body = typeof object === 'string' ? object : JSON.stringify(object);
|
||||
|
||||
const keypair = await this.userKeypairService.getUserKeypair(user.id);
|
||||
|
|
@ -191,8 +189,6 @@ export class ApRequestService {
|
|||
*/
|
||||
@bindThis
|
||||
public async signedGet(url: string, user: { id: MiUser['id'] }, allowAnonymous = false, followAlternate?: boolean): Promise<IObjectWithId> {
|
||||
this.apUtilityService.assertApUrl(url);
|
||||
|
||||
const _followAlternate = followAlternate ?? true;
|
||||
const keypair = await this.userKeypairService.getUserKeypair(user.id);
|
||||
|
||||
|
|
|
|||
|
|
@ -7,20 +7,29 @@ import { Injectable } from '@nestjs/common';
|
|||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||
import { toArray } from '@/misc/prelude/array.js';
|
||||
import { EnvService } from '@/core/EnvService.js';
|
||||
import { getApId, getOneApHrefNullable, IObject } from './type.js';
|
||||
import { getApId, getNullableApId, getOneApHrefNullable } from '@/core/activitypub/type.js';
|
||||
import type { IObject, IObjectWithId } from '@/core/activitypub/type.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { renderInlineError } from '@/misc/render-inline-error.js';
|
||||
import { LoggerService } from '@/core/LoggerService.js';
|
||||
import type Logger from '@/logger.js';
|
||||
|
||||
@Injectable()
|
||||
export class ApUtilityService {
|
||||
private readonly logger: Logger;
|
||||
|
||||
constructor(
|
||||
private readonly utilityService: UtilityService,
|
||||
private readonly envService: EnvService,
|
||||
) {}
|
||||
loggerService: LoggerService,
|
||||
) {
|
||||
this.logger = loggerService.getLogger('ap-utility');
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies that the object's ID has the same authority as the provided URL.
|
||||
* Returns on success, throws on any validation error.
|
||||
*/
|
||||
@bindThis
|
||||
public assertIdMatchesUrlAuthority(object: IObject, url: string): void {
|
||||
// This throws if the ID is missing or invalid, but that's ok.
|
||||
// Anonymous objects are impossible to verify, so we don't allow fetching them.
|
||||
|
|
@ -36,11 +45,15 @@ export class ApUtilityService {
|
|||
/**
|
||||
* Checks if two URLs have the same host authority
|
||||
*/
|
||||
@bindThis
|
||||
public haveSameAuthority(url1: string, url2: string): boolean {
|
||||
if (url1 === url2) return true;
|
||||
|
||||
const authority1 = this.utilityService.punyHostPSLDomain(url1);
|
||||
const authority2 = this.utilityService.punyHostPSLDomain(url2);
|
||||
const parsed1 = this.utilityService.assertUrl(url1);
|
||||
const parsed2 = this.utilityService.assertUrl(url2);
|
||||
|
||||
const authority1 = this.utilityService.punyHostPSLDomain(parsed1);
|
||||
const authority2 = this.utilityService.punyHostPSLDomain(parsed2);
|
||||
return authority1 === authority2;
|
||||
}
|
||||
|
||||
|
|
@ -50,6 +63,7 @@ export class ApUtilityService {
|
|||
* @throws {IdentifiableError} if object does not have an ID
|
||||
* @returns the best URL, or null if none were found
|
||||
*/
|
||||
@bindThis
|
||||
public findBestObjectUrl(object: IObject): string | null {
|
||||
const targetUrl = getApId(object);
|
||||
const targetAuthority = this.utilityService.punyHostPSLDomain(targetUrl);
|
||||
|
|
@ -63,12 +77,16 @@ export class ApUtilityService {
|
|||
: undefined,
|
||||
}))
|
||||
.filter(({ url, type }) => {
|
||||
if (!url) return false;
|
||||
if (!this.checkHttps(url)) return false;
|
||||
if (!isAcceptableUrlType(type)) return false;
|
||||
try {
|
||||
if (!url) return false;
|
||||
if (!isAcceptableUrlType(type)) return false;
|
||||
const parsed = this.utilityService.assertUrl(url);
|
||||
|
||||
const urlAuthority = this.utilityService.punyHostPSLDomain(url);
|
||||
return urlAuthority === targetAuthority;
|
||||
const urlAuthority = this.utilityService.punyHostPSLDomain(parsed);
|
||||
return urlAuthority === targetAuthority;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})
|
||||
.sort((a, b) => {
|
||||
return rankUrlType(a.type) - rankUrlType(b.type);
|
||||
|
|
@ -78,41 +96,72 @@ export class ApUtilityService {
|
|||
}
|
||||
|
||||
/**
|
||||
* Verifies that a provided URL is in a format acceptable for federation.
|
||||
* @throws {IdentifiableError} If URL cannot be parsed
|
||||
* @throws {IdentifiableError} If URL is not HTTPS
|
||||
* Sanitizes an inline / nested Object property within an AP object.
|
||||
*
|
||||
* Returns true if the property contains a valid string URL, object w/ valid ID, or an array containing one of those.
|
||||
* Returns false and erases the property if it doesn't contain a valid value.
|
||||
*
|
||||
* Arrays are automatically flattened.
|
||||
* Falsy values (including null) are collapsed to undefined.
|
||||
* @param obj Object containing the property to validate
|
||||
* @param key Key of the property in obj
|
||||
* @param parentUri URI of the object that contains this inline object.
|
||||
* @param parentHost PSL host of parentUri
|
||||
* @param keyPath If obj is *itself* a nested object, set this to the property path from root to obj (including the trailing '.'). This does not affect the logic, but improves the clarity of logs.
|
||||
*/
|
||||
public assertApUrl(url: string | URL): void {
|
||||
// If string, parse and validate
|
||||
if (typeof(url) === 'string') {
|
||||
try {
|
||||
url = new URL(url);
|
||||
} catch {
|
||||
throw new IdentifiableError('0bedd29b-e3bf-4604-af51-d3352e2518af', `invalid AP url ${url}: not a valid URL`);
|
||||
}
|
||||
@bindThis
|
||||
public sanitizeInlineObject<Key extends string>(obj: Partial<Record<Key, string | { id?: string } | (string | { id?: string })[]>>, key: Key, parentUri: string | URL, parentHost: string, keyPath = ''): obj is Partial<Record<Key, string | { id: string }>> {
|
||||
let value: unknown = obj[key];
|
||||
|
||||
// Unpack arrays
|
||||
if (Array.isArray(value)) {
|
||||
value = value[0];
|
||||
}
|
||||
|
||||
// Must be HTTPS
|
||||
if (!this.checkHttps(url)) {
|
||||
throw new IdentifiableError('0bedd29b-e3bf-4604-af51-d3352e2518af', `invalid AP url ${url}: unsupported protocol ${url.protocol}`);
|
||||
}
|
||||
}
|
||||
// Clear the value - we'll add it back once we have a confirmed ID
|
||||
obj[key] = undefined;
|
||||
|
||||
/**
|
||||
* Checks if the URL contains HTTPS.
|
||||
* Additionally, allows HTTP in non-production environments.
|
||||
* Based on check-https.ts.
|
||||
*/
|
||||
private checkHttps(url: string | URL): boolean {
|
||||
const isNonProd = this.envService.env.NODE_ENV !== 'production';
|
||||
|
||||
try {
|
||||
const proto = new URL(url).protocol;
|
||||
return proto === 'https:' || (proto === 'http:' && isNonProd);
|
||||
} catch {
|
||||
// Invalid URLs don't "count" as HTTPS
|
||||
// Collapse falsy values to undefined
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Exclude nested arrays
|
||||
if (Array.isArray(value)) {
|
||||
this.logger.warn(`Excluding ${keyPath}${key} from object ${parentUri}: nested arrays are prohibited`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Exclude incorrect types
|
||||
if (typeof(value) !== 'string' && typeof(value) !== 'object') {
|
||||
this.logger.warn(`Excluding ${keyPath}${key} from object ${parentUri}: incorrect type ${typeof(value)}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const valueId = getNullableApId(value);
|
||||
if (!valueId) {
|
||||
// Exclude missing ID
|
||||
this.logger.warn(`Excluding ${keyPath}${key} from object ${parentUri}: missing or invalid ID`);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = this.utilityService.assertUrl(valueId);
|
||||
const parsedHost = this.utilityService.punyHostPSLDomain(parsed);
|
||||
if (parsedHost !== parentHost) {
|
||||
// Exclude wrong host
|
||||
this.logger.warn(`Excluding ${keyPath}${key} from object ${parentUri}: wrong host in ${valueId} (got ${parsedHost}, expected ${parentHost})`);
|
||||
return false;
|
||||
}
|
||||
} catch (err) {
|
||||
// Exclude invalid URLs
|
||||
this.logger.warn(`Excluding ${keyPath}${key} from object ${parentUri}: invalid URL ${valueId}: ${renderInlineError(err)}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Success - store the sanitized value and return
|
||||
obj[key] = value as string | IObjectWithId;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -100,7 +100,7 @@ export class ApNoteService {
|
|||
actor?: MiRemoteUser,
|
||||
user?: MiRemoteUser,
|
||||
): Error | null {
|
||||
this.apUtilityService.assertApUrl(uri);
|
||||
this.utilityService.assertUrl(uri);
|
||||
const expectHost = this.utilityService.extractDbHost(uri);
|
||||
const apType = getApType(object);
|
||||
|
||||
|
|
|
|||
|
|
@ -153,89 +153,88 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown {
|
|||
*/
|
||||
@bindThis
|
||||
private validateActor(x: IObject, uri: string): IActor {
|
||||
this.apUtilityService.assertApUrl(uri);
|
||||
const expectHost = this.utilityService.punyHostPSLDomain(uri);
|
||||
const parsedUri = this.utilityService.assertUrl(uri);
|
||||
const expectHost = this.utilityService.punyHostPSLDomain(parsedUri);
|
||||
|
||||
// Validate type
|
||||
if (!isActor(x)) {
|
||||
throw new UnrecoverableError(`invalid Actor ${uri}: unknown type '${x.type}'`);
|
||||
}
|
||||
|
||||
if (!(typeof x.id === 'string' && x.id.length > 0)) {
|
||||
throw new UnrecoverableError(`invalid Actor ${uri}: wrong id type`);
|
||||
// Validate id
|
||||
if (!x.id) {
|
||||
throw new UnrecoverableError(`invalid Actor ${uri}: missing id`);
|
||||
}
|
||||
if (typeof(x.id) !== 'string') {
|
||||
throw new UnrecoverableError(`invalid Actor ${uri}: wrong id type ${typeof(x.id)}`);
|
||||
}
|
||||
const parsedId = this.utilityService.assertUrl(x.id);
|
||||
const idHost = this.utilityService.punyHostPSLDomain(parsedId);
|
||||
if (idHost !== expectHost) {
|
||||
throw new UnrecoverableError(`invalid Actor ${uri}: wrong host in id ${x.id} (got ${parsedId}, expected ${expectHost})`);
|
||||
}
|
||||
|
||||
if (!(typeof x.inbox === 'string' && x.inbox.length > 0)) {
|
||||
throw new UnrecoverableError(`invalid Actor ${uri}: wrong inbox type`);
|
||||
// Validate inbox
|
||||
this.apUtilityService.sanitizeInlineObject(x, 'inbox', parsedUri, expectHost);
|
||||
if (!x.inbox || typeof(x.inbox) !== 'string') {
|
||||
throw new UnrecoverableError(`invalid Actor ${uri}: missing or invalid inbox ${x.inbox}`);
|
||||
}
|
||||
|
||||
this.apUtilityService.assertApUrl(x.inbox);
|
||||
const inboxHost = this.utilityService.punyHostPSLDomain(x.inbox);
|
||||
if (inboxHost !== expectHost) {
|
||||
throw new UnrecoverableError(`invalid Actor ${uri}: wrong inbox host ${inboxHost}`);
|
||||
// Sanitize sharedInbox
|
||||
this.apUtilityService.sanitizeInlineObject(x, 'sharedInbox', parsedUri, expectHost);
|
||||
|
||||
// Sanitize endpoints object
|
||||
if (typeof(x.endpoints) === 'object') {
|
||||
x.endpoints = {
|
||||
sharedInbox: x.endpoints.sharedInbox,
|
||||
};
|
||||
} else {
|
||||
x.endpoints = undefined;
|
||||
}
|
||||
|
||||
const sharedInboxObject = x.sharedInbox ?? (x.endpoints ? x.endpoints.sharedInbox : undefined);
|
||||
if (sharedInboxObject != null) {
|
||||
const sharedInbox = getApId(sharedInboxObject);
|
||||
this.apUtilityService.assertApUrl(sharedInbox);
|
||||
if (!(typeof sharedInbox === 'string' && sharedInbox.length > 0 && this.utilityService.punyHostPSLDomain(sharedInbox) === expectHost)) {
|
||||
throw new UnrecoverableError(`invalid Actor ${uri}: wrong shared inbox ${sharedInbox}`);
|
||||
// Sanitize endpoints.sharedInbox
|
||||
if (x.endpoints) {
|
||||
this.apUtilityService.sanitizeInlineObject(x.endpoints, 'sharedInbox', parsedUri, expectHost, 'endpoints.');
|
||||
|
||||
if (!x.endpoints.sharedInbox) {
|
||||
x.endpoints = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
for (const collection of ['outbox', 'followers', 'following'] as (keyof IActor)[]) {
|
||||
const xCollection = (x as IActor)[collection];
|
||||
if (xCollection != null) {
|
||||
const collectionUri = getApId(xCollection);
|
||||
if (typeof collectionUri === 'string' && collectionUri.length > 0) {
|
||||
this.apUtilityService.assertApUrl(collectionUri);
|
||||
if (this.utilityService.punyHostPSLDomain(collectionUri) !== expectHost) {
|
||||
throw new UnrecoverableError(`invalid Actor ${uri}: wrong ${collection} host ${collectionUri}`);
|
||||
}
|
||||
} else if (collectionUri != null) {
|
||||
throw new UnrecoverableError(`invalid Actor ${uri}: wrong ${collection} type`);
|
||||
}
|
||||
}
|
||||
// Sanitize collections
|
||||
for (const collection of ['outbox', 'followers', 'following', 'featured'] as const) {
|
||||
this.apUtilityService.sanitizeInlineObject(x, collection, parsedUri, expectHost);
|
||||
}
|
||||
|
||||
// Validate username
|
||||
if (!(typeof x.preferredUsername === 'string' && x.preferredUsername.length > 0 && x.preferredUsername.length <= 128 && /^\w([\w-.]*\w)?$/.test(x.preferredUsername))) {
|
||||
throw new UnrecoverableError(`invalid Actor ${uri}: wrong username`);
|
||||
}
|
||||
|
||||
// Sanitize name
|
||||
// These fields are only informational, and some AP software allows these
|
||||
// fields to be very long. If they are too long, we cut them off. This way
|
||||
// we can at least see these users and their activities.
|
||||
if (x.name) {
|
||||
if (!(typeof x.name === 'string' && x.name.length > 0)) {
|
||||
throw new UnrecoverableError(`invalid Actor ${uri}: wrong name`);
|
||||
}
|
||||
x.name = truncate(x.name, nameLength);
|
||||
} else if (x.name === '') {
|
||||
// Mastodon emits empty string when the name is not set.
|
||||
if (!x.name) {
|
||||
x.name = undefined;
|
||||
} else if (typeof(x.name) !== 'string') {
|
||||
this.logger.warn(`Excluding name from object ${uri}: incorrect type ${typeof(x)}`);
|
||||
x.name = undefined;
|
||||
} else {
|
||||
x.name = truncate(x.name, nameLength);
|
||||
}
|
||||
if (x.summary) {
|
||||
if (!(typeof x.summary === 'string' && x.summary.length > 0)) {
|
||||
throw new UnrecoverableError(`invalid Actor ${uri}: wrong summary`);
|
||||
}
|
||||
|
||||
// Sanitize summary
|
||||
if (!x.summary) {
|
||||
x.summary = undefined;
|
||||
} else if (typeof(x.summary) !== 'string') {
|
||||
this.logger.warn(`Excluding summary from object ${uri}: incorrect type ${typeof(x)}`);
|
||||
} else {
|
||||
x.summary = truncate(x.summary, this.config.maxRemoteBioLength);
|
||||
}
|
||||
|
||||
const idHost = this.utilityService.punyHostPSLDomain(x.id);
|
||||
if (idHost !== expectHost) {
|
||||
throw new UnrecoverableError(`invalid Actor ${uri}: wrong id ${x.id}`);
|
||||
}
|
||||
|
||||
if (x.publicKey) {
|
||||
if (typeof x.publicKey.id !== 'string') {
|
||||
throw new UnrecoverableError(`invalid Actor ${uri}: wrong publicKey.id type`);
|
||||
}
|
||||
|
||||
const publicKeyIdHost = this.utilityService.punyHostPSLDomain(x.publicKey.id);
|
||||
if (publicKeyIdHost !== expectHost) {
|
||||
throw new UnrecoverableError(`invalid Actor ${uri}: wrong publicKey.id ${x.publicKey.id}`);
|
||||
}
|
||||
}
|
||||
// Sanitize publicKey
|
||||
this.apUtilityService.sanitizeInlineObject(x, 'publicKey', parsedUri, expectHost);
|
||||
|
||||
return x;
|
||||
}
|
||||
|
|
@ -375,7 +374,8 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown {
|
|||
|
||||
const url = this.apUtilityService.findBestObjectUrl(person);
|
||||
|
||||
const verifiedLinks = url ? await verifyFieldLinks(fields, url, this.httpRequestService) : [];
|
||||
const profileUrls = url ? [url, person.id] : [person.id];
|
||||
const verifiedLinks = await verifyFieldLinks(fields, profileUrls, this.httpRequestService);
|
||||
|
||||
// Create user
|
||||
let user: MiRemoteUser | null = null;
|
||||
|
|
@ -494,7 +494,7 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown {
|
|||
user = u as MiRemoteUser;
|
||||
publicKey = await this.userPublickeysRepository.findOneBy({ userId: user.id });
|
||||
} else {
|
||||
this.logger.error('Error creating Person:', e instanceof Error ? e : new Error(e as string));
|
||||
this.logger.error(`Error creating Person ${uri}: ${renderInlineError(e)}`);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
|
@ -623,7 +623,8 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown {
|
|||
|
||||
const url = this.apUtilityService.findBestObjectUrl(person);
|
||||
|
||||
const verifiedLinks = url ? await verifyFieldLinks(fields, url, this.httpRequestService) : [];
|
||||
const profileUrls = url ? [url, person.id] : [person.id];
|
||||
const verifiedLinks = await verifyFieldLinks(fields, profileUrls, this.httpRequestService);
|
||||
|
||||
const updates = {
|
||||
lastFetchedAt: new Date(),
|
||||
|
|
@ -776,7 +777,7 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown {
|
|||
return result;
|
||||
})
|
||||
.catch(e => {
|
||||
this.logger.info(`Processing Move Failed @${updated.username}@${updated.host} (${uri})`, { stack: e });
|
||||
this.logger.info(`Processing Move Failed @${updated.username}@${updated.host} (${uri}): ${renderInlineError(e)}`);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -28,8 +28,9 @@ export interface IObject {
|
|||
inReplyTo?: any;
|
||||
replies?: ICollection | IOrderedCollection | string;
|
||||
content?: string | null;
|
||||
startTime?: Date;
|
||||
endTime?: Date;
|
||||
startTime?: Date; // TODO these are wrong - should be string
|
||||
endTime?: Date; // TODO these are wrong - should be string
|
||||
updated?: string;
|
||||
icon?: any;
|
||||
image?: any;
|
||||
mediaType?: string;
|
||||
|
|
@ -86,7 +87,7 @@ export function getOneApId(value: ApObject): string {
|
|||
/**
|
||||
* Get ActivityStreams Object id
|
||||
*/
|
||||
export function getApId(value: string | IObject | [string | IObject], sourceForLogs?: string): string {
|
||||
export function getApId(value: unknown | [unknown] | unknown[], sourceForLogs?: string): string {
|
||||
const id = getNullableApId(value);
|
||||
|
||||
if (id == null) {
|
||||
|
|
@ -102,7 +103,7 @@ export function getApId(value: string | IObject | [string | IObject], sourceForL
|
|||
/**
|
||||
* Get ActivityStreams Object id, or null if not present
|
||||
*/
|
||||
export function getNullableApId(source: string | IObject | [string | IObject]): string | null {
|
||||
export function getNullableApId(source: unknown | [unknown] | unknown[]): string | null {
|
||||
const value: unknown = fromTuple(source);
|
||||
|
||||
if (value != null) {
|
||||
|
|
@ -216,7 +217,6 @@ export interface IPost extends IObject {
|
|||
quoteUrl?: string;
|
||||
quoteUri?: string;
|
||||
quote?: string;
|
||||
updated?: string;
|
||||
}
|
||||
|
||||
export interface IQuestion extends IObject {
|
||||
|
|
@ -276,7 +276,7 @@ export interface IActor extends IObject {
|
|||
followers?: string | ICollection | IOrderedCollection;
|
||||
following?: string | ICollection | IOrderedCollection;
|
||||
featured?: string | IOrderedCollection;
|
||||
outbox: string | IOrderedCollection;
|
||||
outbox?: string | IOrderedCollection;
|
||||
endpoints?: {
|
||||
sharedInbox?: string;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -75,6 +75,7 @@ export class MetaEntityService {
|
|||
shortName: instance.shortName,
|
||||
uri: this.config.url,
|
||||
description: instance.description,
|
||||
about: instance.about,
|
||||
langs: instance.langs,
|
||||
tosUrl: instance.termsOfServiceUrl,
|
||||
repositoryUrl: instance.repositoryUrl,
|
||||
|
|
|
|||
|
|
@ -546,6 +546,8 @@ export class UserEntityService implements OnModuleInit {
|
|||
avatarBlurhash: (user.avatarId == null ? null : user.avatarBlurhash),
|
||||
description: mastoapi ? mastoapi.description : profile ? profile.description : '',
|
||||
createdAt: this.idService.parse(user.id).date.toISOString(),
|
||||
updatedAt: user.updatedAt ? user.updatedAt.toISOString() : null,
|
||||
lastFetchedAt: user.lastFetchedAt ? user.lastFetchedAt.toISOString() : null,
|
||||
avatarDecorations: user.avatarDecorations.length > 0 ? this.avatarDecorationService.getAll().then(decorations => user.avatarDecorations.filter(ud => decorations.some(d => d.id === ud.id)).map(ud => ({
|
||||
id: ud.id,
|
||||
angle: ud.angle || undefined,
|
||||
|
|
@ -601,8 +603,6 @@ export class UserEntityService implements OnModuleInit {
|
|||
? Promise.all(user.alsoKnownAs.map(uri => Promise.resolve(opts.userIdsByUri?.get(uri) ?? this.apPersonService.fetchPerson(uri).then(user => user?.id).catch(() => null))))
|
||||
.then(xs => xs.length === 0 ? null : xs.filter(x => x != null))
|
||||
: null,
|
||||
updatedAt: user.updatedAt ? user.updatedAt.toISOString() : null,
|
||||
lastFetchedAt: user.lastFetchedAt ? user.lastFetchedAt.toISOString() : null,
|
||||
bannerUrl: user.bannerId == null ? null : user.bannerUrl,
|
||||
bannerBlurhash: user.bannerId == null ? null : user.bannerBlurhash,
|
||||
backgroundUrl: user.backgroundId == null ? null : user.backgroundUrl,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue