Compare commits

..

9 commits

Author SHA1 Message Date
zima
c5b83819a1 Merge remote-tracking branch 'origin/dev' into stable 2025-07-03 21:37:22 -07:00
zima
0978ac33f8 Merge pull request '1.1+2025.2.3' (#76) from dev into stable
Reviewed-on: https://codeberg.org/yeentown/barkey/pulls/76
2025-07-03 19:32:54 +02:00
zima
e63ea0eb40 Merge pull request 'Merge 2025.2.3 into stable' (#70) from dev into stable
Reviewed-on: https://codeberg.org/yeentown/barkey/pulls/70
2025-05-07 21:02:56 +00:00
zima
3760f74f43 Merge pull request '2025.2.2' (#62) from dev into stable
Reviewed-on: https://codeberg.org/yeentown/barkey/pulls/62
2025-03-28 00:15:49 +00:00
zima
e94bc67833 Merge pull request 'Fix emoji category sorting' (#56) from dev into stable
Reviewed-on: https://codeberg.org/yeentown/barkey/pulls/56
2025-01-22 23:00:00 +00:00
zima
52b68f383d Merge pull request 'Merge dev into stable' (#54) from dev into stable
Reviewed-on: https://codeberg.org/yeentown/barkey/pulls/54
2025-01-15 23:50:12 +00:00
zima
2c777025da Merge pull request 'v0.2.0' (#48) from dev into stable
Reviewed-on: https://codeberg.org/yeentown/barkey/pulls/48
2025-01-14 02:54:03 +00:00
zima
45b26d3219 Merge pull request 'Hotfix: Relaxed the rate limit set for the /proxy endpoint' (#40) from dev into stable
Reviewed-on: https://codeberg.org/yeentown/barkey/pulls/40
2024-12-26 02:13:15 +00:00
zima
1fc5b17082 Merge pull request 'v0.1.0' (#36) from dev into stable
Reviewed-on: https://codeberg.org/yeentown/barkey/pulls/36
2024-12-24 01:59:12 +00:00
32 changed files with 225 additions and 602 deletions

View file

@ -33,7 +33,7 @@ steps:
repo: codeberg.org/yeentown/barkey repo: codeberg.org/yeentown/barkey
registry: codeberg.org registry: codeberg.org
dockerfile: Dockerfile dockerfile: Dockerfile
platforms: linux/amd64 platforms: linux/amd64, linux/arm64
tag: dev tag: dev
username: username:
from_secret: docker_username from_secret: docker_username
@ -47,7 +47,7 @@ steps:
repo: codeberg.org/yeentown/barkey repo: codeberg.org/yeentown/barkey
registry: codeberg.org registry: codeberg.org
dockerfile: Dockerfile dockerfile: Dockerfile
platforms: linux/amd64 platforms: linux/amd64, linux/arm64
tag: latest tag: latest
auto_tag: true auto_tag: true
username: username:

View file

@ -1,13 +1,5 @@
# Barkey
[![status-badge](https://ci.zima.ong/api/badges/1/status.svg?branch=stable)](https://ci.zima.ong/repos/1/branches/stable) [![status-badge](https://ci.zima.ong/api/badges/1/status.svg?branch=stable)](https://ci.zima.ong/repos/1/branches/stable)
Barkey is Sharkey with a few minor tweaks. It was created as a personal project so that I can learn some development fundamentals, and is used in production on [yeentown](https://yeen.town). For several reasons, I **highly encourage** you to use Sharkey instead of this fork.
> ⚠️ I have temporarily stopped creating arm64 images due to the ridiculous time it takes to build in QEMU. These will be back in the future when I am able to set up an arm64 node. If you require arm builds, please build from source for the time being. Thank you for your understanding.
---
<div align="center"> <div align="center">
<a href="https://joinsharkey.org/"> <a href="https://joinsharkey.org/">
<img src="https://activitypub.software/TransFem-org/Sharkey/-/raw/develop/packages/frontend/assets/sharkey.svg" alt="Sharkey logo" style="border-radius:50%" width="300"/> <img src="https://activitypub.software/TransFem-org/Sharkey/-/raw/develop/packages/frontend/assets/sharkey.svg" alt="Sharkey logo" style="border-radius:50%" width="300"/>

View file

@ -1,6 +1,6 @@
{ {
"name": "sharkey", "name": "sharkey",
"version": "2025.4.4", "version": "2025.4.3",
"codename": "shonk", "codename": "shonk",
"repository": { "repository": {
"type": "git", "type": "git",

View file

@ -55,42 +55,44 @@ 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 => {
logger.error('Unhandled rejection:', inspect(e)); try {
logger.error('Unhandled rejection:', inspect(e));
} catch {
console.error('Unhandled rejection:', inspect(e));
}
}); });
} }
process.on('uncaughtException', (err) => {
// Workaround for https://github.com/node-fetch/node-fetch/issues/954
if (String(err).match(/^TypeError: .+ is an? url with embedded credentials.$/)) {
logger.debug('Suppressed node-fetch issue#954, but the current job may fail.');
return;
}
// 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 // Display detail of uncaught exception
process.on('uncaughtExceptionMonitor', (err, origin) => { process.on('uncaughtExceptionMonitor', ((err, origin) => {
logger.error(`Uncaught exception (${origin}):`, err); try {
}); logger.error(`Uncaught exception (${origin}):`, err);
} catch {
console.error(`Uncaught exception (${origin}):`, err);
}
}));
// Dying away... // Dying away...
process.on('disconnect', () => { process.on('disconnect', () => {
logger.warn('IPC channel disconnected! The process may soon die.'); try {
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 => {
logger.warn(`Event loop died! Process will exit with code ${code}.`); try {
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 => {
logger.info(`The process is going to exit with code ${code}`); try {
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

View file

@ -14,7 +14,6 @@ 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()
@ -69,11 +68,11 @@ export class AbuseReportService {
reports.push(report); reports.push(report);
} }
trackPromise(Promise.all([ return 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),
])); ]);
} }
/** /**

View file

@ -350,11 +350,6 @@ 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 () => {

View file

@ -9,10 +9,7 @@ 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 '@/core/LoggerService.js'; import { LoggerService } from './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];
@ -52,6 +49,18 @@ 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;
}; };

View file

@ -19,7 +19,6 @@ 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 {
@ -31,7 +30,6 @@ 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');
} }
@ -40,8 +38,6 @@ 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;

View file

@ -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;
let hash = info.md5; const hash = info.md5;
let size = info.size; const 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,9 +163,6 @@ 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)}`);
} }

View file

@ -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 { UtilityService } from '@/core/UtilityService.js'; import { ApUtilityService } from './activitypub/ApUtilityService.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,27 +27,7 @@ export type HttpRequestSendOptions = {
validators?: ((res: Response) => void)[]; validators?: ((res: Response) => void)[];
}; };
export async function isPrivateUrl(url: URL, lookup: net.LookupFunction): Promise<boolean> { export function isPrivateIp(allowedPrivateNetworks: PrivateNetwork[] | undefined, ip: string, port?: number): 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 ?? []) {
@ -64,7 +44,7 @@ export function isAllowedPrivateIp(allowedPrivateNetworks: PrivateNetwork[] | un
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 (isAllowedPrivateIp(allowedPrivateNetworks, address, socket.remotePort)) { if (isPrivateIp(allowedPrivateNetworks, address, socket.remotePort)) {
socket.destroy(new Error(`Blocked address: ${address}`)); socket.destroy(new Error(`Blocked address: ${address}`));
} }
} }
@ -148,16 +128,10 @@ 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
@ -165,8 +139,6 @@ 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,
@ -264,6 +236,8 @@ 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: {
@ -329,7 +303,6 @@ 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,
@ -338,10 +311,6 @@ 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();
@ -349,7 +318,7 @@ export class HttpRequestService {
const isLocalAddressAllowed = args.isLocalAddressAllowed ?? false; const isLocalAddressAllowed = args.isLocalAddressAllowed ?? false;
const res = await fetch(parsedUrl, { const res = await fetch(url, {
method: args.method ?? 'GET', method: args.method ?? 'GET',
headers: { headers: {
'User-Agent': this.config.userAgent, 'User-Agent': this.config.userAgent,

View file

@ -4,7 +4,7 @@
*/ */
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { Not, IsNull, DataSource } from 'typeorm'; import { Not, IsNull } 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,7 +21,6 @@ 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 {
@ -37,16 +36,12 @@ 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,
) { ) {
@ -61,8 +56,6 @@ 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,
@ -81,8 +74,6 @@ 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,
@ -187,29 +178,30 @@ 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')
.update({ .orWhere({
isFollowerHibernated: true,
})
.where({
followeeId: user.id, followeeId: user.id,
followerHost: Not(IsNull()), followerHost: Not(IsNull()),
}) })
.update({
isFollowerHibernated: true,
})
.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
// TypeORM does not support UPDATE with JOIN: https://github.com/typeorm/typeorm/issues/564#issuecomment-310331468 .createQueryBuilder('following')
await this.db.query(` .innerJoin(MiUser, 'follower', 'user.id = following.followerId')
UPDATE "following" .andWhere('follower.isHibernated = false') // Don't unfreeze if the follower is *actually* frozen
SET "isFollowerHibernated" = false .andWhere({
FROM "user" followeeId: user.id,
WHERE "user"."id" = "following"."followerId" followerHost: Not(IsNull()),
AND "user"."isHibernated" = false -- Don't unfreeze if the follower is *actually* frozen })
AND "followeeId" = $1 .update({
AND "followeeHost" IS NOT NULL isFollowerHibernated: false,
`, [user.id]); })
.execute();
} }
} }

View file

@ -10,10 +10,7 @@ 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 type { MiMeta } from '@/models/Meta.js'; import { 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 {
@ -23,8 +20,6 @@ export class UtilityService {
@Inject(DI.meta) @Inject(DI.meta)
private meta: MiMeta, private meta: MiMeta,
private readonly envService: EnvService,
) { ) {
} }
@ -186,8 +181,8 @@ export class UtilityService {
} }
@bindThis @bindThis
public punyHostPSLDomain(url: string | URL): string { public punyHostPSLDomain(url: string): string {
const urlObj = typeof(url) === 'object' ? url : new URL(url); const urlObj = 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 : ''}`;
@ -218,52 +213,4 @@ 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;
}
}
} }

View file

@ -555,8 +555,7 @@ 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
// Disabled since Mastodon hides the fallback link when this is set quote: quote,
// quote: quote,
published: this.idService.parse(note.id).date.toISOString(), published: this.idService.parse(note.id).date.toISOString(),
to, to,
cc, cc,

View file

@ -157,6 +157,8 @@ 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);
@ -189,6 +191,8 @@ 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);

View file

@ -7,29 +7,20 @@ 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 { getApId, getNullableApId, getOneApHrefNullable } from '@/core/activitypub/type.js'; import { EnvService } from '@/core/EnvService.js';
import type { IObject, IObjectWithId } from '@/core/activitypub/type.js'; import { getApId, getOneApHrefNullable, IObject } from './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,
loggerService: LoggerService, private readonly envService: EnvService,
) { ) {}
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.
@ -45,15 +36,11 @@ 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 parsed1 = this.utilityService.assertUrl(url1); const authority1 = this.utilityService.punyHostPSLDomain(url1);
const parsed2 = this.utilityService.assertUrl(url2); const authority2 = this.utilityService.punyHostPSLDomain(url2);
const authority1 = this.utilityService.punyHostPSLDomain(parsed1);
const authority2 = this.utilityService.punyHostPSLDomain(parsed2);
return authority1 === authority2; return authority1 === authority2;
} }
@ -63,7 +50,6 @@ 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);
@ -77,16 +63,12 @@ export class ApUtilityService {
: undefined, : undefined,
})) }))
.filter(({ url, type }) => { .filter(({ url, type }) => {
try { if (!url) return false;
if (!url) return false; if (!this.checkHttps(url)) return false;
if (!isAcceptableUrlType(type)) return false; if (!isAcceptableUrlType(type)) return false;
const parsed = this.utilityService.assertUrl(url);
const urlAuthority = this.utilityService.punyHostPSLDomain(parsed); const urlAuthority = this.utilityService.punyHostPSLDomain(url);
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);
@ -96,72 +78,41 @@ export class ApUtilityService {
} }
/** /**
* Sanitizes an inline / nested Object property within an AP object. * Verifies that a provided URL is in a format acceptable for federation.
* * @throws {IdentifiableError} If URL cannot be parsed
* Returns true if the property contains a valid string URL, object w/ valid ID, or an array containing one of those. * @throws {IdentifiableError} If URL is not HTTPS
* 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.
*/ */
@bindThis public assertApUrl(url: string | URL): void {
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 string, parse and validate
let value: unknown = obj[key]; if (typeof(url) === 'string') {
try {
// Unpack arrays url = new URL(url);
if (Array.isArray(value)) { } catch {
value = value[0]; throw new IdentifiableError('0bedd29b-e3bf-4604-af51-d3352e2518af', `invalid AP url ${url}: not a valid URL`);
}
} }
// Clear the value - we'll add it back once we have a confirmed ID // Must be HTTPS
obj[key] = undefined; if (!this.checkHttps(url)) {
throw new IdentifiableError('0bedd29b-e3bf-4604-af51-d3352e2518af', `invalid AP url ${url}: unsupported protocol ${url.protocol}`);
// Collapse falsy values to undefined
if (!value) {
return false;
} }
}
// Exclude nested arrays /**
if (Array.isArray(value)) { * Checks if the URL contains HTTPS.
this.logger.warn(`Excluding ${keyPath}${key} from object ${parentUri}: nested arrays are prohibited`); * Additionally, allows HTTP in non-production environments.
return false; * Based on check-https.ts.
} */
private checkHttps(url: string | URL): boolean {
// Exclude incorrect types const isNonProd = this.envService.env.NODE_ENV !== 'production';
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 { try {
const parsed = this.utilityService.assertUrl(valueId); const proto = new URL(url).protocol;
const parsedHost = this.utilityService.punyHostPSLDomain(parsed); return proto === 'https:' || (proto === 'http:' && isNonProd);
if (parsedHost !== parentHost) { } catch {
// Exclude wrong host // Invalid URLs don't "count" as HTTPS
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; return false;
} }
// Success - store the sanitized value and return
obj[key] = value as string | IObjectWithId;
return true;
} }
} }

View file

@ -96,7 +96,7 @@ export class ApNoteService {
actor?: MiRemoteUser, actor?: MiRemoteUser,
user?: MiRemoteUser, user?: MiRemoteUser,
): Error | null { ): Error | null {
this.utilityService.assertUrl(uri); this.apUtilityService.assertApUrl(uri);
const expectHost = this.utilityService.extractDbHost(uri); const expectHost = this.utilityService.extractDbHost(uri);
const apType = getApType(object); const apType = getApType(object);

View file

@ -45,7 +45,6 @@ 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';
@ -56,6 +55,7 @@ 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,88 +155,89 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown {
*/ */
@bindThis @bindThis
private validateActor(x: IObject, uri: string): IActor { private validateActor(x: IObject, uri: string): IActor {
const parsedUri = this.utilityService.assertUrl(uri); this.apUtilityService.assertApUrl(uri);
const expectHost = this.utilityService.punyHostPSLDomain(parsedUri); const expectHost = this.utilityService.punyHostPSLDomain(uri);
// 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}'`);
} }
// Validate id if (!(typeof x.id === 'string' && x.id.length > 0)) {
if (!x.id) { throw new UnrecoverableError(`invalid Actor ${uri}: wrong id type`);
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})`);
} }
// Validate inbox if (!(typeof x.inbox === 'string' && x.inbox.length > 0)) {
this.apUtilityService.sanitizeInlineObject(x, 'inbox', parsedUri, expectHost); throw new UnrecoverableError(`invalid Actor ${uri}: wrong inbox type`);
if (!x.inbox || typeof(x.inbox) !== 'string') {
throw new UnrecoverableError(`invalid Actor ${uri}: missing or invalid inbox ${x.inbox}`);
} }
// Sanitize sharedInbox this.apUtilityService.assertApUrl(x.inbox);
this.apUtilityService.sanitizeInlineObject(x, 'sharedInbox', parsedUri, expectHost); const inboxHost = this.utilityService.punyHostPSLDomain(x.inbox);
if (inboxHost !== expectHost) {
// Sanitize endpoints object throw new UnrecoverableError(`invalid Actor ${uri}: wrong inbox host ${inboxHost}`);
if (typeof(x.endpoints) === 'object') {
x.endpoints = {
sharedInbox: x.endpoints.sharedInbox,
};
} else {
x.endpoints = undefined;
} }
// Sanitize endpoints.sharedInbox const sharedInboxObject = x.sharedInbox ?? (x.endpoints ? x.endpoints.sharedInbox : undefined);
if (x.endpoints) { if (sharedInboxObject != null) {
this.apUtilityService.sanitizeInlineObject(x.endpoints, 'sharedInbox', parsedUri, expectHost, 'endpoints.'); const sharedInbox = getApId(sharedInboxObject);
this.apUtilityService.assertApUrl(sharedInbox);
if (!x.endpoints.sharedInbox) { if (!(typeof sharedInbox === 'string' && sharedInbox.length > 0 && this.utilityService.punyHostPSLDomain(sharedInbox) === expectHost)) {
x.endpoints = undefined; throw new UnrecoverableError(`invalid Actor ${uri}: wrong shared inbox ${sharedInbox}`);
} }
} }
// Sanitize collections for (const collection of ['outbox', 'followers', 'following'] as (keyof IActor)[]) {
for (const collection of ['outbox', 'followers', 'following', 'featured'] as const) { const xCollection = (x as IActor)[collection];
this.apUtilityService.sanitizeInlineObject(x, collection, parsedUri, expectHost); 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`);
}
}
} }
// 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) {
x.name = undefined; if (!(typeof x.name === 'string' && x.name.length > 0)) {
} else if (typeof(x.name) !== 'string') { throw new UnrecoverableError(`invalid Actor ${uri}: wrong name`);
this.logger.warn(`Excluding name from object ${uri}: incorrect type ${typeof(x)}`); }
x.name = undefined;
} else {
x.name = truncate(x.name, nameLength); x.name = truncate(x.name, nameLength);
} else if (x.name === '') {
// Mastodon emits empty string when the name is not set.
x.name = undefined;
} }
if (x.summary) {
// Sanitize summary if (!(typeof x.summary === 'string' && x.summary.length > 0)) {
if (!x.summary) { throw new UnrecoverableError(`invalid Actor ${uri}: wrong 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);
} }
// Sanitize publicKey const idHost = this.utilityService.punyHostPSLDomain(x.id);
this.apUtilityService.sanitizeInlineObject(x, 'publicKey', parsedUri, expectHost); 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}`);
}
}
return x; return x;
} }
@ -376,8 +377,7 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown {
const url = this.apUtilityService.findBestObjectUrl(person); const url = this.apUtilityService.findBestObjectUrl(person);
const profileUrls = url ? [url, person.id] : [person.id]; const verifiedLinks = url ? await verifyFieldLinks(fields, url, this.httpRequestService) : [];
const verifiedLinks = await verifyFieldLinks(fields, profileUrls, this.httpRequestService);
// Create user // Create user
let user: MiRemoteUser | null = null; let user: MiRemoteUser | null = null;
@ -626,8 +626,7 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown {
const url = this.apUtilityService.findBestObjectUrl(person); const url = this.apUtilityService.findBestObjectUrl(person);
const profileUrls = url ? [url, person.id] : [person.id]; const verifiedLinks = url ? await verifyFieldLinks(fields, url, this.httpRequestService) : [];
const verifiedLinks = await verifyFieldLinks(fields, profileUrls, this.httpRequestService);
const updates = { const updates = {
lastFetchedAt: new Date(), lastFetchedAt: new Date(),

View file

@ -86,7 +86,7 @@ export function getOneApId(value: ApObject): string {
/** /**
* Get ActivityStreams Object id * Get ActivityStreams Object id
*/ */
export function getApId(value: unknown | [unknown] | unknown[], sourceForLogs?: string): string { export function getApId(value: string | IObject | [string | IObject], 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: unknown | [unknown] | unknown[], sourceForLogs?:
/** /**
* Get ActivityStreams Object id, or null if not present * Get ActivityStreams Object id, or null if not present
*/ */
export function getNullableApId(source: unknown | [unknown] | unknown[]): string | null { export function getNullableApId(source: string | IObject | [string | IObject]): 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;
}; };

View file

@ -1,18 +0,0 @@
/*
* 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';
}
}

View file

@ -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 '@/misc/captcha-error.js'; import { CaptchaError } from '@/core/CaptchaService.js';
export function renderInlineError(err: unknown): string { export function renderInlineError(err: unknown): string {
const parts: string[] = []; const parts: string[] = [];

View file

@ -8,18 +8,17 @@ 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[], profileUrls: string[], httpRequestService: HttpRequestService): Promise<string[]> { export async function verifyFieldLinks(fields: Field[], profile_url: string, httpRequestService: HttpRequestService): Promise<string[]> {
const verified_links = []; const verified_links = [];
for (const field_url of fields) { for (const field_url of fields.filter(x => URL.canParse(x.value) && ['http:', 'https:'].includes((new URL(x.value).protocol)))) {
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 => profileUrls.includes(link.attribs.href)); const includesProfileLinks = links.some(link => link.attribs.href === profile_url);
if (includesProfileLinks) { if (includesProfileLinks) {
verified_links.push(field_url.value); verified_links.push(field_url.value);
} }

View file

@ -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) * factor); dripRate = ceil(limit.dripRate ?? 1000);
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);;

View file

@ -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) * factor); const dripRate = Math.ceil(limit.dripRate ?? 1000);
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);

View file

@ -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 (unless we have sinceId && !untilId, in which case it's oldest-to-newest) // scan `notifications` newest-to-oldest
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: '', id: prevReaction.id, // this will be the newest id in this group
createdAt: prevReaction.createdAt, createdAt: prevReaction.createdAt,
noteId: prevReaction.noteId!, noteId: prevReaction.noteId!,
reactions: [{ reactions: [{
@ -149,7 +149,6 @@ 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;
} }
@ -168,7 +167,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: '', id: prevRenote.id, // this will be the newest id in this group
createdAt: prevRenote.createdAt, createdAt: prevRenote.createdAt,
noteId: prevRenote.noteId!, noteId: prevRenote.noteId!,
userIds: [prevRenote.notifierId!], userIds: [prevRenote.notifierId!],
@ -176,7 +175,6 @@ 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;
} }
@ -184,12 +182,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
groupedNotifications.push(notification); groupedNotifications.push(notification);
} }
// sort the groups by their id // sort the groups by their id, newest first
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);
}); });

View file

@ -603,15 +603,11 @@ 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 profileUrls = [ const verified_links = await verifyFieldLinks(newFields, `${this.config.url}/@${user.username}`, this.httpRequestService);
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, verifiedLinks: verified_links,
}); });
const iObj = await this.userEntityService.pack(user.id, user, { const iObj = await this.userEntityService.pack(user.id, user, {

View file

@ -9,7 +9,6 @@ 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'],
@ -61,14 +60,13 @@ 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.cacheService.findOptionalUserById(ps.userId); const targetUser = await this.getterService.getUser(ps.userId).catch(err => {
if (!targetUser) { if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser);
throw new ApiError(meta.errors.noSuchUser); throw err;
} });
if (targetUser.id === me.id) { if (targetUser.id === me.id) {
throw new ApiError(meta.errors.cannotReportYourself); throw new ApiError(meta.errors.cannotReportYourself);

View file

@ -16,7 +16,6 @@ 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() {
@ -47,7 +46,6 @@ describe('FetchInstanceMetadataService', () => {
LoggerService, LoggerService,
UtilityService, UtilityService,
IdService, IdService,
EnvService,
], ],
}) })
.useMocker((token) => { .useMocker((token) => {

View file

@ -3,11 +3,11 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { describe, jest } from '@jest/globals'; import { 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, isAllowedPrivateIp, isPrivateUrl, resolveIp, validateSocketConnect } from '@/core/HttpRequestService.js'; import { HttpRequestService, isPrivateIp, validateSocketConnect } from '@/core/HttpRequestService.js';
import { parsePrivateNetworks } from '@/config.js'; import { parsePrivateNetworks } from '@/config.js';
describe(HttpRequestService, () => { describe(HttpRequestService, () => {
@ -21,85 +21,38 @@ describe(HttpRequestService, () => {
]); ]);
}); });
describe(isAllowedPrivateIp, () => { describe('isPrivateIp', () => {
it('should return false when ip public', () => { it('should return false when ip public', () => {
const result = isAllowedPrivateIp(allowedPrivateNetworks, '74.125.127.100', 80); const result = isPrivateIp(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 = isAllowedPrivateIp(allowedPrivateNetworks, '127.0.0.1', 1); const result = isPrivateIp(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 = isAllowedPrivateIp(allowedPrivateNetworks, '10.0.0.1', undefined); const result = isPrivateIp(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 = isAllowedPrivateIp(allowedPrivateNetworks, '10.0.0.2', 80); const result = isPrivateIp(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 = isAllowedPrivateIp(allowedPrivateNetworks, '127.0.0.1', 80); const result = isPrivateIp(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 = isAllowedPrivateIp(allowedPrivateNetworks, '127.0.0.1', undefined); const result = isPrivateIp(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: {

View file

@ -3,52 +3,30 @@
* 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;
const config = { serviceUnderTest = new ApUtilityService(utilityService, envService);
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', () => {
@ -373,102 +351,4 @@ 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);
});
});
}); });

View file

@ -303,12 +303,9 @@ 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 () => {
@ -566,15 +563,11 @@ 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);
expect(i1.blocked).toBeFalsy(); const info = await serviceUnderTest().limit(limit, actor);
expect(i2.blocked).toBeTruthy();
expect(i3.blocked).toBeFalsy(); expect(info.blocked).toBeFalsy();
}); });
it('should set counter expiration', async () => { it('should set counter expiration', async () => {
@ -745,17 +738,12 @@ 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 = 1; limitCounter = 10;
limitTimestamp = 0; limitTimestamp = 0;
const i1 = await serviceUnderTest().limit(limit, actor); const info = await serviceUnderTest().limit(limit, actor); // 10 + 1 = 11
const i2 = await serviceUnderTest().limit(limit, actor);
mockTimeService.now += 500;
const i3 = await serviceUnderTest().limit(limit, actor);
expect(i1.blocked).toBeFalsy(); expect(info.blocked).toBeTruthy();
expect(i2.blocked).toBeTruthy();
expect(i3.blocked).toBeFalsy();
}); });
it('should set counter expiration', async () => { it('should set counter expiration', async () => {
@ -944,17 +932,13 @@ 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 = 19; limitCounter = 5;
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);
expect(i1.blocked).toBeFalsy(); const info = await serviceUnderTest().limit(limit, actor);
expect(i2.blocked).toBeTruthy();
expect(i3.blocked).toBeFalsy(); expect(info.blocked).toBeFalsy();
}); });
it('should set counter expiration', async () => { it('should set counter expiration', async () => {

View file

@ -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 sortedByTime(notifications)" :key="notification.id"> <div v-for="(notification, i) in 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,20 +67,6 @@ 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') {

View file

@ -1,7 +1,7 @@
{ {
"type": "module", "type": "module",
"name": "misskey-js", "name": "misskey-js",
"version": "2025.4.4", "version": "2025.4.3",
"description": "Misskey SDK for JavaScript", "description": "Misskey SDK for JavaScript",
"license": "MIT", "license": "MIT",
"main": "./built/index.js", "main": "./built/index.js",