mirror of
https://codeberg.org/yeentown/barkey.git
synced 2025-12-10 17:08:26 +00:00
Merge tag '2025.4.4' into upstream-2025.4.4
This commit is contained in:
commit
0aafcdd1b9
30 changed files with 595 additions and 226 deletions
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "sharkey",
|
"name": "sharkey",
|
||||||
"version": "2025.4.3",
|
"version": "2025.4.4",
|
||||||
"codename": "shonk",
|
"codename": "shonk",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
|
|
||||||
|
|
@ -55,44 +55,42 @@ async function main() {
|
||||||
// Display detail of unhandled promise rejection
|
// Display detail of unhandled promise rejection
|
||||||
if (!envOption.quiet) {
|
if (!envOption.quiet) {
|
||||||
process.on('unhandledRejection', e => {
|
process.on('unhandledRejection', e => {
|
||||||
try {
|
logger.error('Unhandled rejection:', inspect(e));
|
||||||
logger.error('Unhandled rejection:', inspect(e));
|
|
||||||
} catch {
|
|
||||||
console.error('Unhandled rejection:', inspect(e));
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Display detail of uncaught exception
|
process.on('uncaughtException', (err) => {
|
||||||
process.on('uncaughtExceptionMonitor', ((err, origin) => {
|
// Workaround for https://github.com/node-fetch/node-fetch/issues/954
|
||||||
try {
|
if (String(err).match(/^TypeError: .+ is an? url with embedded credentials.$/)) {
|
||||||
logger.error(`Uncaught exception (${origin}):`, err);
|
logger.debug('Suppressed node-fetch issue#954, but the current job may fail.');
|
||||||
} catch {
|
return;
|
||||||
console.error(`Uncaught exception (${origin}):`, err);
|
|
||||||
}
|
}
|
||||||
}));
|
|
||||||
|
// Workaround for https://github.com/node-fetch/node-fetch/issues/1845
|
||||||
|
if (String(err) === 'TypeError: Cannot read properties of undefined (reading \'body\')') {
|
||||||
|
logger.debug('Suppressed node-fetch issue#1845, but the current job may fail.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Throw all other errors to avoid inconsistent state.
|
||||||
|
// (per NodeJS docs, it's unsafe to suppress arbitrary errors in an uncaughtException handler.)
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Display detail of uncaught exception
|
||||||
|
process.on('uncaughtExceptionMonitor', (err, origin) => {
|
||||||
|
logger.error(`Uncaught exception (${origin}):`, err);
|
||||||
|
});
|
||||||
|
|
||||||
// Dying away...
|
// Dying away...
|
||||||
process.on('disconnect', () => {
|
process.on('disconnect', () => {
|
||||||
try {
|
logger.warn('IPC channel disconnected! The process may soon die.');
|
||||||
logger.warn('IPC channel disconnected! The process may soon die.');
|
|
||||||
} catch {
|
|
||||||
console.warn('IPC channel disconnected! The process may soon die.');
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
process.on('beforeExit', code => {
|
process.on('beforeExit', code => {
|
||||||
try {
|
logger.warn(`Event loop died! Process will exit with code ${code}.`);
|
||||||
logger.warn(`Event loop died! Process will exit with code ${code}.`);
|
|
||||||
} catch {
|
|
||||||
console.warn(`Event loop died! Process will exit with code ${code}.`);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
process.on('exit', code => {
|
process.on('exit', code => {
|
||||||
try {
|
logger.info(`The process is going to exit with code ${code}`);
|
||||||
logger.info(`The process is going to exit with code ${code}`);
|
|
||||||
} catch {
|
|
||||||
console.info(`The process is going to exit with code ${code}`);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
//#endregion
|
//#endregion
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
|
||||||
import { ModerationLogService } from '@/core/ModerationLogService.js';
|
import { ModerationLogService } from '@/core/ModerationLogService.js';
|
||||||
import { SystemAccountService } from '@/core/SystemAccountService.js';
|
import { SystemAccountService } from '@/core/SystemAccountService.js';
|
||||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||||
|
import { trackPromise } from '@/misc/promise-tracker.js';
|
||||||
import { IdService } from './IdService.js';
|
import { IdService } from './IdService.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
|
@ -68,11 +69,11 @@ export class AbuseReportService {
|
||||||
reports.push(report);
|
reports.push(report);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Promise.all([
|
trackPromise(Promise.all([
|
||||||
this.abuseReportNotificationService.notifyAdminStream(reports),
|
this.abuseReportNotificationService.notifyAdminStream(reports),
|
||||||
this.abuseReportNotificationService.notifySystemWebhook(reports, 'abuseReport'),
|
this.abuseReportNotificationService.notifySystemWebhook(reports, 'abuseReport'),
|
||||||
this.abuseReportNotificationService.notifyMail(reports),
|
this.abuseReportNotificationService.notifyMail(reports),
|
||||||
]);
|
]));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -350,6 +350,11 @@ export class CacheService implements OnApplicationShutdown {
|
||||||
}) ?? null;
|
}) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public findOptionalUserById(userId: MiUser['id']) {
|
||||||
|
return this.userByIdCache.fetchMaybe(userId, async () => await this.usersRepository.findOneBy({ id: userId }) ?? undefined);
|
||||||
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async getFollowStats(userId: MiUser['id']): Promise<FollowStats> {
|
public async getFollowStats(userId: MiUser['id']): Promise<FollowStats> {
|
||||||
return await this.userFollowStatsCache.fetch(userId, async () => {
|
return await this.userFollowStatsCache.fetch(userId, async () => {
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,10 @@ import { bindThis } from '@/decorators.js';
|
||||||
import { MetaService } from '@/core/MetaService.js';
|
import { MetaService } from '@/core/MetaService.js';
|
||||||
import { MiMeta } from '@/models/Meta.js';
|
import { MiMeta } from '@/models/Meta.js';
|
||||||
import Logger from '@/logger.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 const supportedCaptchaProviders = ['none', 'hcaptcha', 'mcaptcha', 'recaptcha', 'turnstile', 'fc', 'testcaptcha'] as const;
|
||||||
export type CaptchaProvider = typeof supportedCaptchaProviders[number];
|
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 = {
|
export type CaptchaSaveSuccess = {
|
||||||
success: true;
|
success: true;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import type Logger from '@/logger.js';
|
||||||
|
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
import { renderInlineError } from '@/misc/render-inline-error.js';
|
import { renderInlineError } from '@/misc/render-inline-error.js';
|
||||||
|
import { UtilityService } from '@/core/UtilityService.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DownloadService {
|
export class DownloadService {
|
||||||
|
|
@ -30,6 +31,7 @@ export class DownloadService {
|
||||||
|
|
||||||
private httpRequestService: HttpRequestService,
|
private httpRequestService: HttpRequestService,
|
||||||
private loggerService: LoggerService,
|
private loggerService: LoggerService,
|
||||||
|
private readonly utilityService: UtilityService,
|
||||||
) {
|
) {
|
||||||
this.logger = this.loggerService.getLogger('download');
|
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<{
|
public async downloadUrl(url: string, path: string, options: { timeout?: number, operationTimeout?: number, maxSize?: number } = {} ): Promise<{
|
||||||
filename: string;
|
filename: string;
|
||||||
}> {
|
}> {
|
||||||
|
this.utilityService.assertUrl(url);
|
||||||
|
|
||||||
this.logger.debug(`Downloading ${chalk.cyan(url)} to ${chalk.cyanBright(path)} ...`);
|
this.logger.debug(`Downloading ${chalk.cyan(url)} to ${chalk.cyanBright(path)} ...`);
|
||||||
|
|
||||||
const timeout = options.timeout ?? 30 * 1000;
|
const timeout = options.timeout ?? 30 * 1000;
|
||||||
|
|
|
||||||
|
|
@ -154,8 +154,8 @@ export class DriveService {
|
||||||
@bindThis
|
@bindThis
|
||||||
private async save(file: MiDriveFile, path: string, name: string, info: FileInfo): Promise<MiDriveFile> {
|
private async save(file: MiDriveFile, path: string, name: string, info: FileInfo): Promise<MiDriveFile> {
|
||||||
const type = info.type.mime;
|
const type = info.type.mime;
|
||||||
const hash = info.md5;
|
let hash = info.md5;
|
||||||
const size = info.size;
|
let size = info.size;
|
||||||
|
|
||||||
// thunbnail, webpublic を必要なら生成
|
// thunbnail, webpublic を必要なら生成
|
||||||
const alts = await this.generateAlts(path, type, !file.uri);
|
const alts = await this.generateAlts(path, type, !file.uri);
|
||||||
|
|
@ -163,6 +163,9 @@ export class DriveService {
|
||||||
if (type && type.startsWith('video/')) {
|
if (type && type.startsWith('video/')) {
|
||||||
try {
|
try {
|
||||||
await this.videoProcessingService.webOptimizeVideo(path, type);
|
await this.videoProcessingService.webOptimizeVideo(path, type);
|
||||||
|
const newInfo = await this.fileInfoService.getFileInfo(path);
|
||||||
|
hash = newInfo.md5;
|
||||||
|
size = newInfo.size;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.registerLogger.warn(`Video optimization failed: ${renderInlineError(err)}`);
|
this.registerLogger.warn(`Video optimization failed: ${renderInlineError(err)}`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,9 +17,9 @@ import { StatusError } from '@/misc/status-error.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js';
|
import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js';
|
||||||
import type { IObject, IObjectWithId } from '@/core/activitypub/type.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 { Response } from 'node-fetch';
|
||||||
import type { URL } from 'node:url';
|
|
||||||
import type { Socket } from 'node:net';
|
import type { Socket } from 'node:net';
|
||||||
|
|
||||||
export type HttpRequestSendOptions = {
|
export type HttpRequestSendOptions = {
|
||||||
|
|
@ -27,7 +27,27 @@ export type HttpRequestSendOptions = {
|
||||||
validators?: ((res: Response) => void)[];
|
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);
|
const parsedIp = ipaddr.parse(ip);
|
||||||
|
|
||||||
for (const { cidr, ports } of allowedPrivateNetworks ?? []) {
|
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 {
|
export function validateSocketConnect(allowedPrivateNetworks: PrivateNetwork[] | undefined, socket: Socket): void {
|
||||||
const address = socket.remoteAddress;
|
const address = socket.remoteAddress;
|
||||||
if (address && ipaddr.isValid(address)) {
|
if (address && ipaddr.isValid(address)) {
|
||||||
if (isPrivateIp(allowedPrivateNetworks, address, socket.remotePort)) {
|
if (isAllowedPrivateIp(allowedPrivateNetworks, address, socket.remotePort)) {
|
||||||
socket.destroy(new Error(`Blocked address: ${address}`));
|
socket.destroy(new Error(`Blocked address: ${address}`));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -128,10 +148,16 @@ export class HttpRequestService {
|
||||||
*/
|
*/
|
||||||
public readonly httpsAgent: https.Agent;
|
public readonly httpsAgent: https.Agent;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get shared DNS resolver
|
||||||
|
*/
|
||||||
|
public readonly lookup: net.LookupFunction;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(DI.config)
|
@Inject(DI.config)
|
||||||
private config: Config,
|
private config: Config,
|
||||||
private readonly apUtilityService: ApUtilityService,
|
private readonly apUtilityService: ApUtilityService,
|
||||||
|
private readonly utilityService: UtilityService,
|
||||||
) {
|
) {
|
||||||
const cache = new CacheableLookup({
|
const cache = new CacheableLookup({
|
||||||
maxTtl: 3600, // 1hours
|
maxTtl: 3600, // 1hours
|
||||||
|
|
@ -139,6 +165,8 @@ export class HttpRequestService {
|
||||||
lookup: false, // nativeのdns.lookupにfallbackしない
|
lookup: false, // nativeのdns.lookupにfallbackしない
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.lookup = cache.lookup as unknown as net.LookupFunction;
|
||||||
|
|
||||||
const agentOption = {
|
const agentOption = {
|
||||||
keepAlive: true,
|
keepAlive: true,
|
||||||
keepAliveMsecs: 30 * 1000,
|
keepAliveMsecs: 30 * 1000,
|
||||||
|
|
@ -236,8 +264,6 @@ export class HttpRequestService {
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async getActivityJson(url: string, isLocalAddressAllowed = false, allowAnonymous = false): Promise<IObjectWithId> {
|
public async getActivityJson(url: string, isLocalAddressAllowed = false, allowAnonymous = false): Promise<IObjectWithId> {
|
||||||
this.apUtilityService.assertApUrl(url);
|
|
||||||
|
|
||||||
const res = await this.send(url, {
|
const res = await this.send(url, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
|
|
@ -303,6 +329,7 @@ export class HttpRequestService {
|
||||||
timeout?: number,
|
timeout?: number,
|
||||||
size?: number,
|
size?: number,
|
||||||
isLocalAddressAllowed?: boolean,
|
isLocalAddressAllowed?: boolean,
|
||||||
|
allowHttp?: boolean,
|
||||||
} = {},
|
} = {},
|
||||||
extra: HttpRequestSendOptions = {
|
extra: HttpRequestSendOptions = {
|
||||||
throwErrorWhenResponseNotOk: true,
|
throwErrorWhenResponseNotOk: true,
|
||||||
|
|
@ -311,6 +338,10 @@ export class HttpRequestService {
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const timeout = args.timeout ?? 5000;
|
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();
|
const controller = new AbortController();
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
controller.abort();
|
controller.abort();
|
||||||
|
|
@ -318,7 +349,7 @@ export class HttpRequestService {
|
||||||
|
|
||||||
const isLocalAddressAllowed = args.isLocalAddressAllowed ?? false;
|
const isLocalAddressAllowed = args.isLocalAddressAllowed ?? false;
|
||||||
|
|
||||||
const res = await fetch(url, {
|
const res = await fetch(parsedUrl, {
|
||||||
method: args.method ?? 'GET',
|
method: args.method ?? 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
'User-Agent': this.config.userAgent,
|
'User-Agent': this.config.userAgent,
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
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 type { FollowingsRepository, FollowRequestsRepository, UsersRepository } from '@/models/_.js';
|
||||||
import { MiUser } from '@/models/User.js';
|
import { MiUser } from '@/models/User.js';
|
||||||
import { QueueService } from '@/core/QueueService.js';
|
import { QueueService } from '@/core/QueueService.js';
|
||||||
|
|
@ -21,6 +21,7 @@ import { LoggerService } from '@/core/LoggerService.js';
|
||||||
import type Logger from '@/logger.js';
|
import type Logger from '@/logger.js';
|
||||||
import { renderInlineError } from '@/misc/render-inline-error.js';
|
import { renderInlineError } from '@/misc/render-inline-error.js';
|
||||||
import { trackPromise } from '@/misc/promise-tracker.js';
|
import { trackPromise } from '@/misc/promise-tracker.js';
|
||||||
|
import { InternalEventService } from '@/core/InternalEventService.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UserSuspendService {
|
export class UserSuspendService {
|
||||||
|
|
@ -36,12 +37,16 @@ export class UserSuspendService {
|
||||||
@Inject(DI.followRequestsRepository)
|
@Inject(DI.followRequestsRepository)
|
||||||
private followRequestsRepository: FollowRequestsRepository,
|
private followRequestsRepository: FollowRequestsRepository,
|
||||||
|
|
||||||
|
@Inject(DI.db)
|
||||||
|
private db: DataSource,
|
||||||
|
|
||||||
private userEntityService: UserEntityService,
|
private userEntityService: UserEntityService,
|
||||||
private queueService: QueueService,
|
private queueService: QueueService,
|
||||||
private globalEventService: GlobalEventService,
|
private globalEventService: GlobalEventService,
|
||||||
private apRendererService: ApRendererService,
|
private apRendererService: ApRendererService,
|
||||||
private moderationLogService: ModerationLogService,
|
private moderationLogService: ModerationLogService,
|
||||||
private readonly cacheService: CacheService,
|
private readonly cacheService: CacheService,
|
||||||
|
private readonly internalEventService: InternalEventService,
|
||||||
|
|
||||||
loggerService: LoggerService,
|
loggerService: LoggerService,
|
||||||
) {
|
) {
|
||||||
|
|
@ -56,6 +61,8 @@ export class UserSuspendService {
|
||||||
isSuspended: true,
|
isSuspended: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await this.internalEventService.emit(user.host == null ? 'localUserUpdated' : 'remoteUserUpdated', { id: user.id });
|
||||||
|
|
||||||
await this.moderationLogService.log(moderator, 'suspend', {
|
await this.moderationLogService.log(moderator, 'suspend', {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
userUsername: user.username,
|
userUsername: user.username,
|
||||||
|
|
@ -74,6 +81,8 @@ export class UserSuspendService {
|
||||||
isSuspended: false,
|
isSuspended: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await this.internalEventService.emit(user.host == null ? 'localUserUpdated' : 'remoteUserUpdated', { id: user.id });
|
||||||
|
|
||||||
await this.moderationLogService.log(moderator, 'unsuspend', {
|
await this.moderationLogService.log(moderator, 'unsuspend', {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
userUsername: user.username,
|
userUsername: user.username,
|
||||||
|
|
@ -178,30 +187,29 @@ export class UserSuspendService {
|
||||||
// Freeze follow relations with all remote users
|
// Freeze follow relations with all remote users
|
||||||
await this.followingsRepository
|
await this.followingsRepository
|
||||||
.createQueryBuilder('following')
|
.createQueryBuilder('following')
|
||||||
.orWhere({
|
|
||||||
followeeId: user.id,
|
|
||||||
followerHost: Not(IsNull()),
|
|
||||||
})
|
|
||||||
.update({
|
.update({
|
||||||
isFollowerHibernated: true,
|
isFollowerHibernated: true,
|
||||||
})
|
})
|
||||||
|
.where({
|
||||||
|
followeeId: user.id,
|
||||||
|
followerHost: Not(IsNull()),
|
||||||
|
})
|
||||||
.execute();
|
.execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
private async unFreezeAll(user: MiUser): Promise<void> {
|
private async unFreezeAll(user: MiUser): Promise<void> {
|
||||||
// Restore follow relations with all remote users
|
// Restore follow relations with all remote users
|
||||||
await this.followingsRepository
|
|
||||||
.createQueryBuilder('following')
|
// TypeORM does not support UPDATE with JOIN: https://github.com/typeorm/typeorm/issues/564#issuecomment-310331468
|
||||||
.innerJoin(MiUser, 'follower', 'user.id = following.followerId')
|
await this.db.query(`
|
||||||
.andWhere('follower.isHibernated = false') // Don't unfreeze if the follower is *actually* frozen
|
UPDATE "following"
|
||||||
.andWhere({
|
SET "isFollowerHibernated" = false
|
||||||
followeeId: user.id,
|
FROM "user"
|
||||||
followerHost: Not(IsNull()),
|
WHERE "user"."id" = "following"."followerId"
|
||||||
})
|
AND "user"."isHibernated" = false -- Don't unfreeze if the follower is *actually* frozen
|
||||||
.update({
|
AND "followeeId" = $1
|
||||||
isFollowerHibernated: false,
|
AND "followeeHost" IS NOT NULL
|
||||||
})
|
`, [user.id]);
|
||||||
.execute();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,10 @@ import psl from 'psl';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import type { Config } from '@/config.js';
|
import type { Config } from '@/config.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
import { MiMeta } from '@/models/Meta.js';
|
import type { MiMeta } 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()
|
@Injectable()
|
||||||
export class UtilityService {
|
export class UtilityService {
|
||||||
|
|
@ -20,6 +23,8 @@ export class UtilityService {
|
||||||
|
|
||||||
@Inject(DI.meta)
|
@Inject(DI.meta)
|
||||||
private meta: MiMeta,
|
private meta: MiMeta,
|
||||||
|
|
||||||
|
private readonly envService: EnvService,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -181,8 +186,8 @@ export class UtilityService {
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public punyHostPSLDomain(url: string): string {
|
public punyHostPSLDomain(url: string | URL): string {
|
||||||
const urlObj = new URL(url);
|
const urlObj = typeof(url) === 'object' ? url : new URL(url);
|
||||||
const hostname = urlObj.hostname;
|
const hostname = urlObj.hostname;
|
||||||
const domain = this.specialSuffix(hostname) ?? psl.get(hostname) ?? hostname;
|
const domain = this.specialSuffix(hostname) ?? psl.get(hostname) ?? hostname;
|
||||||
const host = `${this.toPuny(domain)}${urlObj.port.length > 0 ? ':' + urlObj.port : ''}`;
|
const host = `${this.toPuny(domain)}${urlObj.port.length > 0 ? ':' + urlObj.port : ''}`;
|
||||||
|
|
@ -213,4 +218,52 @@ export class UtilityService {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -555,7 +555,8 @@ export class ApRendererService {
|
||||||
quoteUrl: quote,
|
quoteUrl: quote,
|
||||||
quoteUri: quote,
|
quoteUri: quote,
|
||||||
// https://codeberg.org/fediverse/fep/src/branch/main/fep/044f/fep-044f.md
|
// https://codeberg.org/fediverse/fep/src/branch/main/fep/044f/fep-044f.md
|
||||||
quote: quote,
|
// Disabled since Mastodon hides the fallback link when this is set
|
||||||
|
// quote: quote,
|
||||||
published: this.idService.parse(note.id).date.toISOString(),
|
published: this.idService.parse(note.id).date.toISOString(),
|
||||||
to,
|
to,
|
||||||
cc,
|
cc,
|
||||||
|
|
|
||||||
|
|
@ -157,8 +157,6 @@ export class ApRequestService {
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async signedPost(user: { id: MiUser['id'] }, url: string, object: unknown, digest?: string): Promise<void> {
|
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 body = typeof object === 'string' ? object : JSON.stringify(object);
|
||||||
|
|
||||||
const keypair = await this.userKeypairService.getUserKeypair(user.id);
|
const keypair = await this.userKeypairService.getUserKeypair(user.id);
|
||||||
|
|
@ -191,8 +189,6 @@ export class ApRequestService {
|
||||||
*/
|
*/
|
||||||
@bindThis
|
@bindThis
|
||||||
public async signedGet(url: string, user: { id: MiUser['id'] }, allowAnonymous = false, followAlternate?: boolean): Promise<IObjectWithId> {
|
public async signedGet(url: string, user: { id: MiUser['id'] }, allowAnonymous = false, followAlternate?: boolean): Promise<IObjectWithId> {
|
||||||
this.apUtilityService.assertApUrl(url);
|
|
||||||
|
|
||||||
const _followAlternate = followAlternate ?? true;
|
const _followAlternate = followAlternate ?? true;
|
||||||
const keypair = await this.userKeypairService.getUserKeypair(user.id);
|
const keypair = await this.userKeypairService.getUserKeypair(user.id);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,20 +7,29 @@ import { Injectable } from '@nestjs/common';
|
||||||
import { UtilityService } from '@/core/UtilityService.js';
|
import { UtilityService } from '@/core/UtilityService.js';
|
||||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||||
import { toArray } from '@/misc/prelude/array.js';
|
import { toArray } from '@/misc/prelude/array.js';
|
||||||
import { EnvService } from '@/core/EnvService.js';
|
import { getApId, getNullableApId, getOneApHrefNullable } from '@/core/activitypub/type.js';
|
||||||
import { getApId, getOneApHrefNullable, IObject } from './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()
|
@Injectable()
|
||||||
export class ApUtilityService {
|
export class ApUtilityService {
|
||||||
|
private readonly logger: Logger;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly utilityService: UtilityService,
|
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.
|
* Verifies that the object's ID has the same authority as the provided URL.
|
||||||
* Returns on success, throws on any validation error.
|
* Returns on success, throws on any validation error.
|
||||||
*/
|
*/
|
||||||
|
@bindThis
|
||||||
public assertIdMatchesUrlAuthority(object: IObject, url: string): void {
|
public assertIdMatchesUrlAuthority(object: IObject, url: string): void {
|
||||||
// This throws if the ID is missing or invalid, but that's ok.
|
// 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.
|
// 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
|
* Checks if two URLs have the same host authority
|
||||||
*/
|
*/
|
||||||
|
@bindThis
|
||||||
public haveSameAuthority(url1: string, url2: string): boolean {
|
public haveSameAuthority(url1: string, url2: string): boolean {
|
||||||
if (url1 === url2) return true;
|
if (url1 === url2) return true;
|
||||||
|
|
||||||
const authority1 = this.utilityService.punyHostPSLDomain(url1);
|
const parsed1 = this.utilityService.assertUrl(url1);
|
||||||
const authority2 = this.utilityService.punyHostPSLDomain(url2);
|
const parsed2 = this.utilityService.assertUrl(url2);
|
||||||
|
|
||||||
|
const authority1 = this.utilityService.punyHostPSLDomain(parsed1);
|
||||||
|
const authority2 = this.utilityService.punyHostPSLDomain(parsed2);
|
||||||
return authority1 === authority2;
|
return authority1 === authority2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -50,6 +63,7 @@ export class ApUtilityService {
|
||||||
* @throws {IdentifiableError} if object does not have an ID
|
* @throws {IdentifiableError} if object does not have an ID
|
||||||
* @returns the best URL, or null if none were found
|
* @returns the best URL, or null if none were found
|
||||||
*/
|
*/
|
||||||
|
@bindThis
|
||||||
public findBestObjectUrl(object: IObject): string | null {
|
public findBestObjectUrl(object: IObject): string | null {
|
||||||
const targetUrl = getApId(object);
|
const targetUrl = getApId(object);
|
||||||
const targetAuthority = this.utilityService.punyHostPSLDomain(targetUrl);
|
const targetAuthority = this.utilityService.punyHostPSLDomain(targetUrl);
|
||||||
|
|
@ -63,12 +77,16 @@ export class ApUtilityService {
|
||||||
: undefined,
|
: undefined,
|
||||||
}))
|
}))
|
||||||
.filter(({ url, type }) => {
|
.filter(({ url, type }) => {
|
||||||
if (!url) return false;
|
try {
|
||||||
if (!this.checkHttps(url)) return false;
|
if (!url) return false;
|
||||||
if (!isAcceptableUrlType(type)) return false;
|
if (!isAcceptableUrlType(type)) return false;
|
||||||
|
const parsed = this.utilityService.assertUrl(url);
|
||||||
|
|
||||||
const urlAuthority = this.utilityService.punyHostPSLDomain(url);
|
const urlAuthority = this.utilityService.punyHostPSLDomain(parsed);
|
||||||
return urlAuthority === targetAuthority;
|
return urlAuthority === targetAuthority;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
return rankUrlType(a.type) - rankUrlType(b.type);
|
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.
|
* Sanitizes an inline / nested Object property within an AP object.
|
||||||
* @throws {IdentifiableError} If URL cannot be parsed
|
*
|
||||||
* @throws {IdentifiableError} If URL is not HTTPS
|
* 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 {
|
@bindThis
|
||||||
// If string, parse and validate
|
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 }>> {
|
||||||
if (typeof(url) === 'string') {
|
let value: unknown = obj[key];
|
||||||
try {
|
|
||||||
url = new URL(url);
|
// Unpack arrays
|
||||||
} catch {
|
if (Array.isArray(value)) {
|
||||||
throw new IdentifiableError('0bedd29b-e3bf-4604-af51-d3352e2518af', `invalid AP url ${url}: not a valid URL`);
|
value = value[0];
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Must be HTTPS
|
// Clear the value - we'll add it back once we have a confirmed ID
|
||||||
if (!this.checkHttps(url)) {
|
obj[key] = undefined;
|
||||||
throw new IdentifiableError('0bedd29b-e3bf-4604-af51-d3352e2518af', `invalid AP url ${url}: unsupported protocol ${url.protocol}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
// Collapse falsy values to undefined
|
||||||
* Checks if the URL contains HTTPS.
|
if (!value) {
|
||||||
* 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
|
|
||||||
return false;
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -96,7 +96,7 @@ export class ApNoteService {
|
||||||
actor?: MiRemoteUser,
|
actor?: MiRemoteUser,
|
||||||
user?: MiRemoteUser,
|
user?: MiRemoteUser,
|
||||||
): Error | null {
|
): Error | null {
|
||||||
this.apUtilityService.assertApUrl(uri);
|
this.utilityService.assertUrl(uri);
|
||||||
const expectHost = this.utilityService.extractDbHost(uri);
|
const expectHost = this.utilityService.extractDbHost(uri);
|
||||||
const apType = getApType(object);
|
const apType = getApType(object);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ import { HttpRequestService } from '@/core/HttpRequestService.js';
|
||||||
import { verifyFieldLinks } from '@/misc/verify-field-link.js';
|
import { verifyFieldLinks } from '@/misc/verify-field-link.js';
|
||||||
import { isRetryableError } from '@/misc/is-retryable-error.js';
|
import { isRetryableError } from '@/misc/is-retryable-error.js';
|
||||||
import { renderInlineError } from '@/misc/render-inline-error.js';
|
import { renderInlineError } from '@/misc/render-inline-error.js';
|
||||||
|
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||||
import { getApId, getApType, isActor, isCollection, isCollectionOrOrderedCollection, isPropertyValue } from '../type.js';
|
import { getApId, getApType, isActor, isCollection, isCollectionOrOrderedCollection, isPropertyValue } from '../type.js';
|
||||||
import { extractApHashtags } from './tag.js';
|
import { extractApHashtags } from './tag.js';
|
||||||
import type { OnModuleInit } from '@nestjs/common';
|
import type { OnModuleInit } from '@nestjs/common';
|
||||||
|
|
@ -55,7 +56,6 @@ import type { ApLoggerService } from '../ApLoggerService.js';
|
||||||
|
|
||||||
import type { ApImageService } from './ApImageService.js';
|
import type { ApImageService } from './ApImageService.js';
|
||||||
import type { IActor, ICollection, IObject, IOrderedCollection } from '../type.js';
|
import type { IActor, ICollection, IObject, IOrderedCollection } from '../type.js';
|
||||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
|
||||||
|
|
||||||
const nameLength = 128;
|
const nameLength = 128;
|
||||||
const summaryLength = 2048;
|
const summaryLength = 2048;
|
||||||
|
|
@ -155,89 +155,88 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown {
|
||||||
*/
|
*/
|
||||||
@bindThis
|
@bindThis
|
||||||
private validateActor(x: IObject, uri: string): IActor {
|
private validateActor(x: IObject, uri: string): IActor {
|
||||||
this.apUtilityService.assertApUrl(uri);
|
const parsedUri = this.utilityService.assertUrl(uri);
|
||||||
const expectHost = this.utilityService.punyHostPSLDomain(uri);
|
const expectHost = this.utilityService.punyHostPSLDomain(parsedUri);
|
||||||
|
|
||||||
|
// Validate type
|
||||||
if (!isActor(x)) {
|
if (!isActor(x)) {
|
||||||
throw new UnrecoverableError(`invalid Actor ${uri}: unknown type '${x.type}'`);
|
throw new UnrecoverableError(`invalid Actor ${uri}: unknown type '${x.type}'`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!(typeof x.id === 'string' && x.id.length > 0)) {
|
// Validate id
|
||||||
throw new UnrecoverableError(`invalid Actor ${uri}: wrong id type`);
|
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)) {
|
// Validate inbox
|
||||||
throw new UnrecoverableError(`invalid Actor ${uri}: wrong inbox type`);
|
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);
|
// Sanitize sharedInbox
|
||||||
const inboxHost = this.utilityService.punyHostPSLDomain(x.inbox);
|
this.apUtilityService.sanitizeInlineObject(x, 'sharedInbox', parsedUri, expectHost);
|
||||||
if (inboxHost !== expectHost) {
|
|
||||||
throw new UnrecoverableError(`invalid Actor ${uri}: wrong inbox host ${inboxHost}`);
|
// 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);
|
// Sanitize endpoints.sharedInbox
|
||||||
if (sharedInboxObject != null) {
|
if (x.endpoints) {
|
||||||
const sharedInbox = getApId(sharedInboxObject);
|
this.apUtilityService.sanitizeInlineObject(x.endpoints, 'sharedInbox', parsedUri, expectHost, 'endpoints.');
|
||||||
this.apUtilityService.assertApUrl(sharedInbox);
|
|
||||||
if (!(typeof sharedInbox === 'string' && sharedInbox.length > 0 && this.utilityService.punyHostPSLDomain(sharedInbox) === expectHost)) {
|
if (!x.endpoints.sharedInbox) {
|
||||||
throw new UnrecoverableError(`invalid Actor ${uri}: wrong shared inbox ${sharedInbox}`);
|
x.endpoints = undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const collection of ['outbox', 'followers', 'following'] as (keyof IActor)[]) {
|
// Sanitize collections
|
||||||
const xCollection = (x as IActor)[collection];
|
for (const collection of ['outbox', 'followers', 'following', 'featured'] as const) {
|
||||||
if (xCollection != null) {
|
this.apUtilityService.sanitizeInlineObject(x, collection, parsedUri, expectHost);
|
||||||
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`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate username
|
||||||
if (!(typeof x.preferredUsername === 'string' && x.preferredUsername.length > 0 && x.preferredUsername.length <= 128 && /^\w([\w-.]*\w)?$/.test(x.preferredUsername))) {
|
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`);
|
throw new UnrecoverableError(`invalid Actor ${uri}: wrong username`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sanitize name
|
||||||
// These fields are only informational, and some AP software allows these
|
// 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
|
// 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.
|
// we can at least see these users and their activities.
|
||||||
if (x.name) {
|
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.
|
|
||||||
x.name = undefined;
|
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)) {
|
// Sanitize summary
|
||||||
throw new UnrecoverableError(`invalid Actor ${uri}: wrong 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, summaryLength);
|
x.summary = truncate(x.summary, summaryLength);
|
||||||
}
|
}
|
||||||
|
|
||||||
const idHost = this.utilityService.punyHostPSLDomain(x.id);
|
// Sanitize publicKey
|
||||||
if (idHost !== expectHost) {
|
this.apUtilityService.sanitizeInlineObject(x, 'publicKey', parsedUri, 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}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return x;
|
return x;
|
||||||
}
|
}
|
||||||
|
|
@ -377,7 +376,8 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown {
|
||||||
|
|
||||||
const url = this.apUtilityService.findBestObjectUrl(person);
|
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
|
// Create user
|
||||||
let user: MiRemoteUser | null = null;
|
let user: MiRemoteUser | null = null;
|
||||||
|
|
@ -626,7 +626,8 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown {
|
||||||
|
|
||||||
const url = this.apUtilityService.findBestObjectUrl(person);
|
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 = {
|
const updates = {
|
||||||
lastFetchedAt: new Date(),
|
lastFetchedAt: new Date(),
|
||||||
|
|
|
||||||
|
|
@ -86,7 +86,7 @@ export function getOneApId(value: ApObject): string {
|
||||||
/**
|
/**
|
||||||
* Get ActivityStreams Object id
|
* 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);
|
const id = getNullableApId(value);
|
||||||
|
|
||||||
if (id == null) {
|
if (id == null) {
|
||||||
|
|
@ -102,7 +102,7 @@ export function getApId(value: string | IObject | [string | IObject], sourceForL
|
||||||
/**
|
/**
|
||||||
* Get ActivityStreams Object id, or null if not present
|
* 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);
|
const value: unknown = fromTuple(source);
|
||||||
|
|
||||||
if (value != null) {
|
if (value != null) {
|
||||||
|
|
@ -276,7 +276,7 @@ export interface IActor extends IObject {
|
||||||
followers?: string | ICollection | IOrderedCollection;
|
followers?: string | ICollection | IOrderedCollection;
|
||||||
following?: string | ICollection | IOrderedCollection;
|
following?: string | ICollection | IOrderedCollection;
|
||||||
featured?: string | IOrderedCollection;
|
featured?: string | IOrderedCollection;
|
||||||
outbox: string | IOrderedCollection;
|
outbox?: string | IOrderedCollection;
|
||||||
endpoints?: {
|
endpoints?: {
|
||||||
sharedInbox?: string;
|
sharedInbox?: string;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
18
packages/backend/src/misc/captcha-error.ts
Normal file
18
packages/backend/src/misc/captcha-error.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { CaptchaErrorCode } from '@/core/CaptchaService.js';
|
||||||
|
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
|
|
||||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||||
import { StatusError } from '@/misc/status-error.js';
|
import { StatusError } from '@/misc/status-error.js';
|
||||||
import { CaptchaError } from '@/core/CaptchaService.js';
|
import { CaptchaError } from '@/misc/captcha-error.js';
|
||||||
|
|
||||||
export function renderInlineError(err: unknown): string {
|
export function renderInlineError(err: unknown): string {
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
|
|
|
||||||
|
|
@ -8,17 +8,18 @@ import type { HttpRequestService } from '@/core/HttpRequestService.js';
|
||||||
|
|
||||||
type Field = { name: string, value: string };
|
type Field = { name: string, value: string };
|
||||||
|
|
||||||
export async function verifyFieldLinks(fields: Field[], profile_url: string, httpRequestService: HttpRequestService): Promise<string[]> {
|
export async function verifyFieldLinks(fields: Field[], profileUrls: string[], httpRequestService: HttpRequestService): Promise<string[]> {
|
||||||
const verified_links = [];
|
const verified_links = [];
|
||||||
for (const field_url of fields.filter(x => URL.canParse(x.value) && ['http:', 'https:'].includes((new URL(x.value).protocol)))) {
|
for (const field_url of fields) {
|
||||||
try {
|
try {
|
||||||
|
// getHtml validates the input URL, so we can safely pass in untrusted values
|
||||||
const html = await httpRequestService.getHtml(field_url.value);
|
const html = await httpRequestService.getHtml(field_url.value);
|
||||||
|
|
||||||
const doc = cheerio(html);
|
const doc = cheerio(html);
|
||||||
|
|
||||||
const links = doc('a[rel~="me"][href], link[rel~="me"][href]').toArray();
|
const links = doc('a[rel~="me"][href], link[rel~="me"][href]').toArray();
|
||||||
|
|
||||||
const includesProfileLinks = links.some(link => link.attribs.href === profile_url);
|
const includesProfileLinks = links.some(link => profileUrls.includes(link.attribs.href));
|
||||||
if (includesProfileLinks) {
|
if (includesProfileLinks) {
|
||||||
verified_links.push(field_url.value);
|
verified_links.push(field_url.value);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,7 @@ The Atomic Leaky Bucket algorithm is described here, in pseudocode:
|
||||||
# * Delta Timestamp - Difference between current and expected timestamp value
|
# * Delta Timestamp - Difference between current and expected timestamp value
|
||||||
|
|
||||||
# 0 - Calculations
|
# 0 - Calculations
|
||||||
dripRate = ceil(limit.dripRate ?? 1000);
|
dripRate = ceil((limit.dripRate ?? 1000) * factor);
|
||||||
dripSize = ceil(limit.dripSize ?? 1);
|
dripSize = ceil(limit.dripSize ?? 1);
|
||||||
bucketSize = max(ceil(limit.size / factor), 1);
|
bucketSize = max(ceil(limit.size / factor), 1);
|
||||||
maxExpiration = max(ceil((dripRate * ceil(bucketSize / dripSize)) / 1000), 1);;
|
maxExpiration = max(ceil((dripRate * ceil(bucketSize / dripSize)) / 1000), 1);;
|
||||||
|
|
|
||||||
|
|
@ -206,7 +206,7 @@ export class SkRateLimiterService {
|
||||||
// 0 - Calculate
|
// 0 - Calculate
|
||||||
const now = this.timeService.now;
|
const now = this.timeService.now;
|
||||||
const bucketSize = Math.max(Math.ceil(limit.size / factor), 1);
|
const bucketSize = Math.max(Math.ceil(limit.size / factor), 1);
|
||||||
const dripRate = Math.ceil(limit.dripRate ?? 1000);
|
const dripRate = Math.ceil((limit.dripRate ?? 1000) * factor);
|
||||||
const dripSize = Math.ceil(limit.dripSize ?? 1);
|
const dripSize = Math.ceil(limit.dripSize ?? 1);
|
||||||
const fullResetMs = dripRate * Math.ceil(bucketSize / dripSize);
|
const fullResetMs = dripRate * Math.ceil(bucketSize / dripSize);
|
||||||
const fullResetSec = Math.max(Math.ceil(fullResetMs / 1000), 1);
|
const fullResetSec = Math.max(Math.ceil(fullResetMs / 1000), 1);
|
||||||
|
|
|
||||||
|
|
@ -116,7 +116,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
// user has many notifications, the pagination will break the
|
// user has many notifications, the pagination will break the
|
||||||
// groups
|
// groups
|
||||||
|
|
||||||
// scan `notifications` newest-to-oldest
|
// scan `notifications` newest-to-oldest (unless we have sinceId && !untilId, in which case it's oldest-to-newest)
|
||||||
for (let i = 0; i < notifications.length; i++) {
|
for (let i = 0; i < notifications.length; i++) {
|
||||||
const notification = notifications[i];
|
const notification = notifications[i];
|
||||||
|
|
||||||
|
|
@ -135,7 +135,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
if (prevReaction.type !== 'reaction:grouped') {
|
if (prevReaction.type !== 'reaction:grouped') {
|
||||||
prevReaction = groupedNotifications[reactionIdx] = {
|
prevReaction = groupedNotifications[reactionIdx] = {
|
||||||
type: 'reaction:grouped',
|
type: 'reaction:grouped',
|
||||||
id: prevReaction.id, // this will be the newest id in this group
|
id: '',
|
||||||
createdAt: prevReaction.createdAt,
|
createdAt: prevReaction.createdAt,
|
||||||
noteId: prevReaction.noteId!,
|
noteId: prevReaction.noteId!,
|
||||||
reactions: [{
|
reactions: [{
|
||||||
|
|
@ -149,6 +149,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
userId: notification.notifierId!,
|
userId: notification.notifierId!,
|
||||||
reaction: notification.reaction!,
|
reaction: notification.reaction!,
|
||||||
});
|
});
|
||||||
|
prevReaction.id = notification.id; // this will be the *oldest* id in this group (newest if sinceId && !untilId)
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -167,7 +168,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
if (prevRenote.type !== 'renote:grouped') {
|
if (prevRenote.type !== 'renote:grouped') {
|
||||||
prevRenote = groupedNotifications[renoteIdx] = {
|
prevRenote = groupedNotifications[renoteIdx] = {
|
||||||
type: 'renote:grouped',
|
type: 'renote:grouped',
|
||||||
id: prevRenote.id, // this will be the newest id in this group
|
id: '',
|
||||||
createdAt: prevRenote.createdAt,
|
createdAt: prevRenote.createdAt,
|
||||||
noteId: prevRenote.noteId!,
|
noteId: prevRenote.noteId!,
|
||||||
userIds: [prevRenote.notifierId!],
|
userIds: [prevRenote.notifierId!],
|
||||||
|
|
@ -175,6 +176,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
}
|
}
|
||||||
// add this new renote to the existing group
|
// add this new renote to the existing group
|
||||||
(prevRenote as FilterUnionByProperty<MiGroupedNotification, 'type', 'renote:grouped'>).userIds.push(notification.notifierId!);
|
(prevRenote as FilterUnionByProperty<MiGroupedNotification, 'type', 'renote:grouped'>).userIds.push(notification.notifierId!);
|
||||||
|
prevRenote.id = notification.id; // this will be the *oldest* id in this group (newest if sinceId && !untilId)
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -182,10 +184,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
groupedNotifications.push(notification);
|
groupedNotifications.push(notification);
|
||||||
}
|
}
|
||||||
|
|
||||||
// sort the groups by their id, newest first
|
// sort the groups by their id
|
||||||
groupedNotifications.sort(
|
groupedNotifications.sort(
|
||||||
(a, b) => a.id < b.id ? 1 : a.id > b.id ? -1 : 0,
|
(a, b) => a.id < b.id ? 1 : a.id > b.id ? -1 : 0,
|
||||||
);
|
);
|
||||||
|
// this matches the logic in NotificationService and it's what MkPagination expects
|
||||||
|
if (ps.sinceId && !ps.untilId) groupedNotifications.reverse();
|
||||||
|
|
||||||
return await this.notificationEntityService.packGroupedMany(groupedNotifications, me.id);
|
return await this.notificationEntityService.packGroupedMany(groupedNotifications, me.id);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -603,11 +603,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
this.globalEventService.publishInternalEvent('localUserUpdated', { id: user.id });
|
this.globalEventService.publishInternalEvent('localUserUpdated', { id: user.id });
|
||||||
}
|
}
|
||||||
|
|
||||||
const verified_links = await verifyFieldLinks(newFields, `${this.config.url}/@${user.username}`, this.httpRequestService);
|
const profileUrls = [
|
||||||
|
this.userEntityService.genLocalUserUri(user.id),
|
||||||
|
`${this.config.url}/@${user.username}`,
|
||||||
|
];
|
||||||
|
const verifiedLinks = await verifyFieldLinks(newFields, profileUrls, this.httpRequestService);
|
||||||
|
|
||||||
await this.userProfilesRepository.update(user.id, {
|
await this.userProfilesRepository.update(user.id, {
|
||||||
...profileUpdates,
|
...profileUpdates,
|
||||||
verifiedLinks: verified_links,
|
verifiedLinks,
|
||||||
});
|
});
|
||||||
|
|
||||||
const iObj = await this.userEntityService.pack(user.id, user, {
|
const iObj = await this.userEntityService.pack(user.id, user, {
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import { GetterService } from '@/server/api/GetterService.js';
|
||||||
import { RoleService } from '@/core/RoleService.js';
|
import { RoleService } from '@/core/RoleService.js';
|
||||||
import { AbuseReportService } from '@/core/AbuseReportService.js';
|
import { AbuseReportService } from '@/core/AbuseReportService.js';
|
||||||
import { ApiError } from '../../error.js';
|
import { ApiError } from '../../error.js';
|
||||||
|
import { CacheService } from '@/core/CacheService.js';
|
||||||
|
|
||||||
export const meta = {
|
export const meta = {
|
||||||
tags: ['users'],
|
tags: ['users'],
|
||||||
|
|
@ -60,13 +61,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
private getterService: GetterService,
|
private getterService: GetterService,
|
||||||
private roleService: RoleService,
|
private roleService: RoleService,
|
||||||
private abuseReportService: AbuseReportService,
|
private abuseReportService: AbuseReportService,
|
||||||
|
private readonly cacheService: CacheService,
|
||||||
) {
|
) {
|
||||||
super(meta, paramDef, async (ps, me) => {
|
super(meta, paramDef, async (ps, me) => {
|
||||||
// Lookup user
|
// Lookup user
|
||||||
const targetUser = await this.getterService.getUser(ps.userId).catch(err => {
|
const targetUser = await this.cacheService.findOptionalUserById(ps.userId);
|
||||||
if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser);
|
if (!targetUser) {
|
||||||
throw err;
|
throw new ApiError(meta.errors.noSuchUser);
|
||||||
});
|
}
|
||||||
|
|
||||||
if (targetUser.id === me.id) {
|
if (targetUser.id === me.id) {
|
||||||
throw new ApiError(meta.errors.cannotReportYourself);
|
throw new ApiError(meta.errors.cannotReportYourself);
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import { HttpRequestService } from '@/core/HttpRequestService.js';
|
||||||
import { LoggerService } from '@/core/LoggerService.js';
|
import { LoggerService } from '@/core/LoggerService.js';
|
||||||
import { UtilityService } from '@/core/UtilityService.js';
|
import { UtilityService } from '@/core/UtilityService.js';
|
||||||
import { IdService } from '@/core/IdService.js';
|
import { IdService } from '@/core/IdService.js';
|
||||||
|
import { EnvService } from '@/core/EnvService.js';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
|
|
||||||
function mockRedis() {
|
function mockRedis() {
|
||||||
|
|
@ -46,6 +47,7 @@ describe('FetchInstanceMetadataService', () => {
|
||||||
LoggerService,
|
LoggerService,
|
||||||
UtilityService,
|
UtilityService,
|
||||||
IdService,
|
IdService,
|
||||||
|
EnvService,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
.useMocker((token) => {
|
.useMocker((token) => {
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,11 @@
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { jest } from '@jest/globals';
|
import { describe, jest } from '@jest/globals';
|
||||||
import type { Mock } from 'jest-mock';
|
import type { Mock } from 'jest-mock';
|
||||||
import type { PrivateNetwork } from '@/config.js';
|
import type { PrivateNetwork } from '@/config.js';
|
||||||
import type { Socket } from 'net';
|
import type { Socket } from 'net';
|
||||||
import { HttpRequestService, isPrivateIp, validateSocketConnect } from '@/core/HttpRequestService.js';
|
import { HttpRequestService, isAllowedPrivateIp, isPrivateUrl, resolveIp, validateSocketConnect } from '@/core/HttpRequestService.js';
|
||||||
import { parsePrivateNetworks } from '@/config.js';
|
import { parsePrivateNetworks } from '@/config.js';
|
||||||
|
|
||||||
describe(HttpRequestService, () => {
|
describe(HttpRequestService, () => {
|
||||||
|
|
@ -21,38 +21,85 @@ describe(HttpRequestService, () => {
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('isPrivateIp', () => {
|
describe(isAllowedPrivateIp, () => {
|
||||||
it('should return false when ip public', () => {
|
it('should return false when ip public', () => {
|
||||||
const result = isPrivateIp(allowedPrivateNetworks, '74.125.127.100', 80);
|
const result = isAllowedPrivateIp(allowedPrivateNetworks, '74.125.127.100', 80);
|
||||||
expect(result).toBeFalsy();
|
expect(result).toBeFalsy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return false when ip private and port matches', () => {
|
it('should return false when ip private and port matches', () => {
|
||||||
const result = isPrivateIp(allowedPrivateNetworks, '127.0.0.1', 1);
|
const result = isAllowedPrivateIp(allowedPrivateNetworks, '127.0.0.1', 1);
|
||||||
expect(result).toBeFalsy();
|
expect(result).toBeFalsy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return false when ip private and all ports undefined', () => {
|
it('should return false when ip private and all ports undefined', () => {
|
||||||
const result = isPrivateIp(allowedPrivateNetworks, '10.0.0.1', undefined);
|
const result = isAllowedPrivateIp(allowedPrivateNetworks, '10.0.0.1', undefined);
|
||||||
expect(result).toBeFalsy();
|
expect(result).toBeFalsy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return true when ip private and no ports specified', () => {
|
it('should return true when ip private and no ports specified', () => {
|
||||||
const result = isPrivateIp(allowedPrivateNetworks, '10.0.0.2', 80);
|
const result = isAllowedPrivateIp(allowedPrivateNetworks, '10.0.0.2', 80);
|
||||||
expect(result).toBeTruthy();
|
expect(result).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return true when ip private and port does not match', () => {
|
it('should return true when ip private and port does not match', () => {
|
||||||
const result = isPrivateIp(allowedPrivateNetworks, '127.0.0.1', 80);
|
const result = isAllowedPrivateIp(allowedPrivateNetworks, '127.0.0.1', 80);
|
||||||
expect(result).toBeTruthy();
|
expect(result).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return true when ip private and port is null but ports are specified', () => {
|
it('should return true when ip private and port is null but ports are specified', () => {
|
||||||
const result = isPrivateIp(allowedPrivateNetworks, '127.0.0.1', undefined);
|
const result = isAllowedPrivateIp(allowedPrivateNetworks, '127.0.0.1', undefined);
|
||||||
expect(result).toBeTruthy();
|
expect(result).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const fakeLookup = (host: string, _: unknown, callback: (err: Error | null, ip: string) => void) => {
|
||||||
|
if (host === 'localhost') {
|
||||||
|
callback(null, '127.0.0.1');
|
||||||
|
} else {
|
||||||
|
callback(null, '23.192.228.80');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
describe(resolveIp, () => {
|
||||||
|
it('should parse inline IPs', async () => {
|
||||||
|
const result = await resolveIp(new URL('https://10.0.0.1'), fakeLookup);
|
||||||
|
expect(result.toString()).toEqual('10.0.0.1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should resolve domain names', async () => {
|
||||||
|
const result = await resolveIp(new URL('https://localhost'), fakeLookup);
|
||||||
|
expect(result.toString()).toEqual('127.0.0.1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe(isPrivateUrl, () => {
|
||||||
|
it('should return false when URL is public host', async () => {
|
||||||
|
const result = await isPrivateUrl(new URL('https://example.com'), fakeLookup);
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true when URL is private host', async () => {
|
||||||
|
const result = await isPrivateUrl(new URL('https://localhost'), fakeLookup);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when IP is public', async () => {
|
||||||
|
const result = await isPrivateUrl(new URL('https://23.192.228.80'), fakeLookup);
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true when IP is private', async () => {
|
||||||
|
const result = await isPrivateUrl(new URL('https://127.0.0.1'), fakeLookup);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true when IP is private with port and path', async () => {
|
||||||
|
const result = await isPrivateUrl(new URL('https://127.0.0.1:443/some/path'), fakeLookup);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('validateSocketConnect', () => {
|
describe('validateSocketConnect', () => {
|
||||||
let fakeSocket: Socket;
|
let fakeSocket: Socket;
|
||||||
let fakeSocketMutable: {
|
let fakeSocketMutable: {
|
||||||
|
|
|
||||||
|
|
@ -3,30 +3,52 @@
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { UtilityService } from '@/core/UtilityService.js';
|
|
||||||
import type { IObject } from '@/core/activitypub/type.js';
|
import type { IObject } from '@/core/activitypub/type.js';
|
||||||
import type { EnvService } from '@/core/EnvService.js';
|
import type { EnvService } from '@/core/EnvService.js';
|
||||||
|
import type { MiMeta } from '@/models/Meta.js';
|
||||||
|
import type { Config } from '@/config.js';
|
||||||
|
import type { LoggerService } from '@/core/LoggerService.js';
|
||||||
|
import Logger from '@/logger.js';
|
||||||
import { ApUtilityService } from '@/core/activitypub/ApUtilityService.js';
|
import { ApUtilityService } from '@/core/activitypub/ApUtilityService.js';
|
||||||
|
import { UtilityService } from '@/core/UtilityService.js';
|
||||||
|
|
||||||
describe(ApUtilityService, () => {
|
describe(ApUtilityService, () => {
|
||||||
let serviceUnderTest: ApUtilityService;
|
let serviceUnderTest: ApUtilityService;
|
||||||
let env: Record<string, string>;
|
let env: Record<string, string>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
const utilityService = {
|
|
||||||
punyHostPSLDomain(input: string) {
|
|
||||||
const host = new URL(input).host;
|
|
||||||
const parts = host.split('.');
|
|
||||||
return `${parts[parts.length - 2]}.${parts[parts.length - 1]}`;
|
|
||||||
},
|
|
||||||
} as unknown as UtilityService;
|
|
||||||
|
|
||||||
env = {};
|
env = {};
|
||||||
const envService = {
|
const envService = {
|
||||||
env,
|
env,
|
||||||
} as unknown as EnvService;
|
} as unknown as EnvService;
|
||||||
|
|
||||||
serviceUnderTest = new ApUtilityService(utilityService, envService);
|
const config = {
|
||||||
|
host: 'example.com',
|
||||||
|
blockedHosts: [],
|
||||||
|
silencedHosts: [],
|
||||||
|
mediaSilencedHosts: [],
|
||||||
|
federationHosts: [],
|
||||||
|
bubbleInstances: [],
|
||||||
|
deliverSuspendedSoftware: [],
|
||||||
|
federation: 'all',
|
||||||
|
} as unknown as Config;
|
||||||
|
const meta = {
|
||||||
|
|
||||||
|
} as MiMeta;
|
||||||
|
|
||||||
|
const utilityService = new UtilityService(config, meta, envService);
|
||||||
|
|
||||||
|
const loggerService = {
|
||||||
|
getLogger(domain: string) {
|
||||||
|
const logger = new Logger(domain);
|
||||||
|
Object.defineProperty(logger, 'log', {
|
||||||
|
value: () => {},
|
||||||
|
});
|
||||||
|
return logger;
|
||||||
|
},
|
||||||
|
} as unknown as LoggerService;
|
||||||
|
|
||||||
|
serviceUnderTest = new ApUtilityService(utilityService, loggerService);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('assertIdMatchesUrlAuthority', () => {
|
describe('assertIdMatchesUrlAuthority', () => {
|
||||||
|
|
@ -351,4 +373,102 @@ describe(ApUtilityService, () => {
|
||||||
expect(result).toBe('http://example.com/1');
|
expect(result).toBe('http://example.com/1');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('sanitizeInlineObject', () => {
|
||||||
|
it('should exclude nested arrays', () => {
|
||||||
|
const input = {
|
||||||
|
test: [[]] as unknown as string[],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = serviceUnderTest.sanitizeInlineObject(input, 'test', 'https://example.com/actor', 'example.com');
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should exclude incorrect type', () => {
|
||||||
|
const input = {
|
||||||
|
test: 0 as unknown as string,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = serviceUnderTest.sanitizeInlineObject(input, 'test', 'https://example.com/actor', 'example.com');
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should exclude missing ID', () => {
|
||||||
|
const input = {
|
||||||
|
test: {
|
||||||
|
id: undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = serviceUnderTest.sanitizeInlineObject(input, 'test', 'https://example.com/actor', 'example.com');
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should exclude wrong host', () => {
|
||||||
|
const input = {
|
||||||
|
test: 'https://wrong.com/object',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = serviceUnderTest.sanitizeInlineObject(input, 'test', 'https://example.com/actor', 'example.com');
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should exclude invalid URLs', () => {
|
||||||
|
const input = {
|
||||||
|
test: 'https://user@example.com/object',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = serviceUnderTest.sanitizeInlineObject(input, 'test', 'https://example.com/actor', 'example.com');
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accept string', () => {
|
||||||
|
const input = {
|
||||||
|
test: 'https://example.com/object',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = serviceUnderTest.sanitizeInlineObject(input, 'test', 'https://example.com/actor', 'example.com');
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accept array of string', () => {
|
||||||
|
const input = {
|
||||||
|
test: ['https://example.com/object'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = serviceUnderTest.sanitizeInlineObject(input, 'test', 'https://example.com/actor', 'example.com');
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accept object', () => {
|
||||||
|
const input = {
|
||||||
|
test: {
|
||||||
|
id: 'https://example.com/object',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = serviceUnderTest.sanitizeInlineObject(input, 'test', 'https://example.com/actor', 'example.com');
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accept array of object', () => {
|
||||||
|
const input = {
|
||||||
|
test: [{
|
||||||
|
id: 'https://example.com/object',
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = serviceUnderTest.sanitizeInlineObject(input, 'test', 'https://example.com/actor', 'example.com');
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -303,9 +303,12 @@ describe(SkRateLimiterService, () => {
|
||||||
|
|
||||||
const i1 = await serviceUnderTest().limit(limit, actor); // 1 + 1 = 2
|
const i1 = await serviceUnderTest().limit(limit, actor); // 1 + 1 = 2
|
||||||
const i2 = await serviceUnderTest().limit(limit, actor); // 2 + 1 = 3
|
const i2 = await serviceUnderTest().limit(limit, actor); // 2 + 1 = 3
|
||||||
|
mockTimeService.now += 500; // 3 - 1 = 2 (at 1/2 time)
|
||||||
|
const i3 = await serviceUnderTest().limit(limit, actor);
|
||||||
|
|
||||||
expect(i1.blocked).toBeFalsy();
|
expect(i1.blocked).toBeFalsy();
|
||||||
expect(i2.blocked).toBeTruthy();
|
expect(i2.blocked).toBeTruthy();
|
||||||
|
expect(i3.blocked).toBeFalsy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should set counter expiration', async () => {
|
it('should set counter expiration', async () => {
|
||||||
|
|
@ -563,11 +566,15 @@ describe(SkRateLimiterService, () => {
|
||||||
mockDefaultUserPolicies.rateLimitFactor = 0.5;
|
mockDefaultUserPolicies.rateLimitFactor = 0.5;
|
||||||
limitCounter = 1;
|
limitCounter = 1;
|
||||||
limitTimestamp = 0;
|
limitTimestamp = 0;
|
||||||
|
|
||||||
|
const i1 = await serviceUnderTest().limit(limit, actor);
|
||||||
|
const i2 = await serviceUnderTest().limit(limit, actor);
|
||||||
mockTimeService.now += 500;
|
mockTimeService.now += 500;
|
||||||
|
const i3 = await serviceUnderTest().limit(limit, actor);
|
||||||
|
|
||||||
const info = await serviceUnderTest().limit(limit, actor);
|
expect(i1.blocked).toBeFalsy();
|
||||||
|
expect(i2.blocked).toBeTruthy();
|
||||||
expect(info.blocked).toBeFalsy();
|
expect(i3.blocked).toBeFalsy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should set counter expiration', async () => {
|
it('should set counter expiration', async () => {
|
||||||
|
|
@ -738,12 +745,17 @@ describe(SkRateLimiterService, () => {
|
||||||
|
|
||||||
it('should scale limit by factor', async () => {
|
it('should scale limit by factor', async () => {
|
||||||
mockDefaultUserPolicies.rateLimitFactor = 0.5;
|
mockDefaultUserPolicies.rateLimitFactor = 0.5;
|
||||||
limitCounter = 10;
|
limitCounter = 1;
|
||||||
limitTimestamp = 0;
|
limitTimestamp = 0;
|
||||||
|
|
||||||
const info = await serviceUnderTest().limit(limit, actor); // 10 + 1 = 11
|
const i1 = await serviceUnderTest().limit(limit, actor);
|
||||||
|
const i2 = await serviceUnderTest().limit(limit, actor);
|
||||||
|
mockTimeService.now += 500;
|
||||||
|
const i3 = await serviceUnderTest().limit(limit, actor);
|
||||||
|
|
||||||
expect(info.blocked).toBeTruthy();
|
expect(i1.blocked).toBeFalsy();
|
||||||
|
expect(i2.blocked).toBeTruthy();
|
||||||
|
expect(i3.blocked).toBeFalsy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should set counter expiration', async () => {
|
it('should set counter expiration', async () => {
|
||||||
|
|
@ -932,13 +944,17 @@ describe(SkRateLimiterService, () => {
|
||||||
|
|
||||||
it('should scale limit and interval by factor', async () => {
|
it('should scale limit and interval by factor', async () => {
|
||||||
mockDefaultUserPolicies.rateLimitFactor = 0.5;
|
mockDefaultUserPolicies.rateLimitFactor = 0.5;
|
||||||
limitCounter = 5;
|
limitCounter = 19;
|
||||||
limitTimestamp = 0;
|
limitTimestamp = 0;
|
||||||
|
|
||||||
|
const i1 = await serviceUnderTest().limit(limit, actor);
|
||||||
|
const i2 = await serviceUnderTest().limit(limit, actor);
|
||||||
mockTimeService.now += 500;
|
mockTimeService.now += 500;
|
||||||
|
const i3 = await serviceUnderTest().limit(limit, actor);
|
||||||
|
|
||||||
const info = await serviceUnderTest().limit(limit, actor);
|
expect(i1.blocked).toBeFalsy();
|
||||||
|
expect(i2.blocked).toBeTruthy();
|
||||||
expect(info.blocked).toBeFalsy();
|
expect(i3.blocked).toBeFalsy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should set counter expiration', async () => {
|
it('should set counter expiration', async () => {
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
:moveClass=" $style.transition_x_move"
|
:moveClass=" $style.transition_x_move"
|
||||||
tag="div"
|
tag="div"
|
||||||
>
|
>
|
||||||
<div v-for="(notification, i) in notifications" :key="notification.id">
|
<div v-for="(notification, i) in sortedByTime(notifications)" :key="notification.id">
|
||||||
<DynamicNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :class="$style.item" :note="notification.note" :withHardMute="true" :data-scroll-anchor="notification.id"/>
|
<DynamicNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :class="$style.item" :note="notification.note" :withHardMute="true" :data-scroll-anchor="notification.id"/>
|
||||||
<XNotification v-else :class="$style.item" :notification="notification" :withTime="true" :full="true" :data-scroll-anchor="notification.id"/>
|
<XNotification v-else :class="$style.item" :notification="notification" :withTime="true" :full="true" :data-scroll-anchor="notification.id"/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -67,6 +67,20 @@ const pagination = computed(() => prefer.r.useGroupedNotifications.value ? {
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// for pagination reasons, each notification group needs to have the
|
||||||
|
// id of the oldest notification inside it, but we want to show the
|
||||||
|
// groups sorted by the time of the *newest* notification; so we re-sort
|
||||||
|
// them here
|
||||||
|
function sortedByTime(notifications) {
|
||||||
|
return notifications.toSorted(
|
||||||
|
(a, b) => {
|
||||||
|
if (a.createdAt < b.createdAt) return 1;
|
||||||
|
if (a.createdAt > b.createdAt) return -1;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function onNotification(notification) {
|
function onNotification(notification) {
|
||||||
const isMuted = props.excludeTypes ? props.excludeTypes.includes(notification.type) : false;
|
const isMuted = props.excludeTypes ? props.excludeTypes.includes(notification.type) : false;
|
||||||
if (isMuted || window.document.visibilityState === 'visible') {
|
if (isMuted || window.document.visibilityState === 'visible') {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"name": "misskey-js",
|
"name": "misskey-js",
|
||||||
"version": "2025.4.3",
|
"version": "2025.4.4",
|
||||||
"description": "Misskey SDK for JavaScript",
|
"description": "Misskey SDK for JavaScript",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"main": "./built/index.js",
|
"main": "./built/index.js",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue