Merge branch 'develop' into 'feature/dialog-announcement-cap'

# Conflicts:
#   packages/backend/src/core/AnnouncementService.ts
#   packages/backend/src/server/api/endpoints/admin/announcements/create.ts
#   packages/backend/src/server/api/endpoints/admin/announcements/update.ts
This commit is contained in:
bunnybeam 2025-09-04 16:52:49 +01:00
commit 5760c021fe
121 changed files with 2016 additions and 672 deletions

View file

@ -2,28 +2,21 @@ stages:
- test
- deploy
testCommit:
.test_common: &test_common
stage: test
image: node:jod
services:
- postgres:15
- redis
image: docker.io/node:22
variables:
POSTGRES_PASSWORD: ci
COREPACK_DEFAULT_TO_LATEST: 0
script:
- apt-get update && apt-get install -y git wget curl build-essential python3 ffmpeg
before_script:
- apt-get update && apt-get install -y git wget curl build-essential python3 ffmpeg libcairo2-dev libpango1.0-dev libpangocairo-1.0
- 'echo "clusterLimit: $(nproc)" >> .config/ci.yml'
- cp .config/ci.yml .config/default.yml
- cp .config/ci.yml .config/test.yml
- corepack enable
- corepack install
- git submodule update --init
- pnpm install --frozen-lockfile
- pnpm run build
- pnpm run migrate
- pnpm run test
- pnpm run --filter=backend --filter=misskey-js --filter=frontend-shared lint
- pnpm run --filter=frontend --filter=frontend-embed eslint
cache:
key: test
policy: pull-push
@ -36,11 +29,43 @@ testCommit:
- merge_requests
- stable
getImageTag:
stage: deploy
image: ubuntu:latest
lint:
<<: *test_common
script:
- apt-get update && apt-get install -y jq
- pnpm run build
- pnpm run eslint
backend_tests:
<<: *test_common
services:
- postgres:15
- redis
script:
- >-
pnpm run build \
--filter=backend \
--filter=megalodon \
--filter=misskey-js
- pnpm run migrate
- pnpm run test --filter=backend
frontend_tests:
<<: *test_common
script:
- >-
pnpm run build \
--filter=frontend \
--filter=frontend-embed \
--filter=frontend-shared \
--filter=megalogon \
--filter=misskey-js
- pnpm run test --filter=frontend --filter=misskey-js
get_image_tag:
stage: deploy
image: docker.io/alpine:latest
script:
- apk add jq
- |
if test -n "$CI_COMMIT_TAG"; then
tag="$CI_COMMIT_TAG"
@ -62,10 +87,10 @@ getImageTag:
- develop
- tags
buildDocker:
build_image:
stage: deploy
needs:
- job: getImageTag
- job: get_image_tag
artifacts: true
parallel:
matrix:
@ -78,37 +103,36 @@ buildDocker:
entrypoint: [""]
script:
- >-
/kaniko/executor
--context "${CI_PROJECT_DIR}"
--dockerfile "${CI_PROJECT_DIR}/Dockerfile"
--destination "${CI_REGISTRY_IMAGE}:${REGISTRY_PUSH_VERSION}-${ARCH}"
/kaniko/executor \
--context "${CI_PROJECT_DIR}" \
--dockerfile "${CI_PROJECT_DIR}/Dockerfile" \
--single-snapshot \
--destination "${CI_REGISTRY_IMAGE}:${REGISTRY_PUSH_VERSION}-${ARCH}"
only:
- stable
- develop
- tags
mergeManifests:
merge_image_manifests:
stage: deploy
needs:
- job: buildDocker
- job: build_image
artifacts: false
- job: getImageTag
- job: get_image_tag
artifacts: true
tags:
- docker
image:
name: mplatform/manifest-tool:alpine
entrypoint: [""]
name: mplatform/manifest-tool:alpine
entrypoint: [""]
script:
- >-
manifest-tool
--username=${CI_REGISTRY_USER}
--password=${CI_REGISTRY_PASSWORD}
push from-args
--platforms linux/amd64,linux/arm64
--tags ${REGISTRY_PUSH_VERSION}
--template ${CI_REGISTRY_IMAGE}:${REGISTRY_PUSH_VERSION}-ARCH
--target ${CI_REGISTRY_IMAGE}:${REGISTRY_PUSH_TAG}
manifest-tool \
--username=${CI_REGISTRY_USER} \
--password=${CI_REGISTRY_PASSWORD} \
push from-args \
--platforms linux/amd64,linux/arm64 \
--tags ${REGISTRY_PUSH_VERSION} \
--template ${CI_REGISTRY_IMAGE}:${REGISTRY_PUSH_VERSION}-ARCH \
--target ${CI_REGISTRY_IMAGE}:${REGISTRY_PUSH_TAG}
only:
- stable
- develop

30
locales/index.d.ts vendored
View file

@ -6005,6 +6005,14 @@ export interface Locale extends ILocale {
* New
*/
"new": string;
/**
* Throw confetti
*/
"confetti": string;
/**
* If enabled, the announcement will display a confetti effect when viewed.
*/
"confettiDescription": string;
};
"_initialAccountSetting": {
/**
@ -6398,7 +6406,7 @@ export interface Locale extends ILocale {
*/
"deliverSuspendedSoftware": string;
/**
* semver 使>= 2024.3.1 2024.3.1-custom.0 >= 2024.3.1-0 prerelease
* You can specify a range of names and versions of the server's software to stop delivery for vulnerability or other reasons. This version information is provided by the server and is not guaranteed to be reliable. A semver range specification can be used to specify the version, but specifying >= 2024.3.1 will not include custom versions such as 2024.3.1-custom.0, so it is recommended that a prerelease specification be used, such as >= 2024.3.1-0. Specifying * will match any name or version, even when the server doesn't provide one. You can also provide a regular expression like /^sharkey-/i or /^1-/
*/
"deliverSuspendedSoftwareDescription": string;
/**
@ -6413,6 +6421,14 @@ export interface Locale extends ILocale {
* E.g. In the sidebar, to visitors and in the "About" page.
*/
"sidebarLogoUsageExample": string;
/**
* About instance
*/
"aboutInstance": string;
/**
* A longer description that will be displayed in the 'Instance Information' page, going in place of the regular instance description. Supports HTML.
*/
"aboutInstanceDescription": string;
};
"_accountMigration": {
/**
@ -11976,6 +11992,14 @@ export interface Locale extends ILocale {
* Separate with spaces for an AND condition or with line breaks for an OR condition. Using surrounding keywords with slashes will turn them into a regular expression. If you write only the domain name, it will be a backward match.
*/
"trustedLinkUrlPatternsDescription": string;
/**
* Link to external site warning exclusion list
*/
"trustedDomainsList": string;
/**
* Following links to these domains will not show a warning. Write one domain per line.
*/
"trustedDomainsListDescription": string;
/**
* Mutuals
*/
@ -13355,6 +13379,10 @@ export interface Locale extends ILocale {
*/
"keepFilesInUseDescription": string;
};
/**
* Custom font size
*/
"customFontSize": string;
}
declare const locales: {
[lang: string]: Locale;

View file

@ -0,0 +1,14 @@
/*
* SPDX-FileCopyrightText: наб and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class AnnouncementForRoles1752352800438 {
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "announcement" ADD "forRoles" text[] DEFAULT '{}'`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "announcement" DROP COLUMN "forRoles"`);
}
}

View file

@ -0,0 +1,14 @@
/*
* SPDX-FileCopyrightText: наб and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class UserPendingIp1752377661219 {
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "user_pending" ADD "requestOriginIp" varchar(128)`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "user_pending" DROP COLUMN "requestOriginIp"`);
}
}

View file

@ -0,0 +1,11 @@
export class SplitDescriptions1752607599852 {
name = 'SplitDescriptions1752607599852'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" ADD COLUMN "about" TEXT`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "about"`);
}
}

View file

@ -0,0 +1,16 @@
/*
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class ChangeChatMessageTextType1753574755478 {
name = 'ChangeChatMessageTextType1753574755478'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "chat_message" ALTER COLUMN "text" TYPE text`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "chat_message" ALTER COLUMN "text" TYPE varchar(4096)`);
}
}

View file

@ -0,0 +1,14 @@
/*
* SPDX-FileCopyrightText: Lillychan and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class MetaRulesLength1754754816000 {
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "serverRules" TYPE TEXT[]`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "serverRules" TYPE character varying(280)[]`);
}
}

View file

@ -55,44 +55,42 @@ async function main() {
// Display detail of unhandled promise rejection
if (!envOption.quiet) {
process.on('unhandledRejection', e => {
try {
logger.error('Unhandled rejection:', inspect(e));
} catch {
console.error('Unhandled rejection:', inspect(e));
}
logger.error('Unhandled rejection:', inspect(e));
});
}
// Display detail of uncaught exception
process.on('uncaughtExceptionMonitor', ((err, origin) => {
try {
logger.error(`Uncaught exception (${origin}):`, err);
} catch {
console.error(`Uncaught exception (${origin}):`, err);
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
process.on('uncaughtExceptionMonitor', (err, origin) => {
logger.error(`Uncaught exception (${origin}):`, err);
});
// Dying away...
process.on('disconnect', () => {
try {
logger.warn('IPC channel disconnected! The process may soon die.');
} catch {
console.warn('IPC channel disconnected! The process may soon die.');
}
logger.warn('IPC channel disconnected! The process may soon die.');
});
process.on('beforeExit', 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}.`);
}
logger.warn(`Event loop died! Process will exit with code ${code}.`);
});
process.on('exit', 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}`);
}
logger.info(`The process is going to exit with code ${code}`);
});
//#endregion

View file

@ -51,7 +51,7 @@ function greet() {
}
bootLogger.info('Welcome to Sharkey!');
bootLogger.info(`Sharkey v${meta.version}`, null, true);
bootLogger.info(`Sharkey v${meta.gitVersion ?? meta.version}`, null, true);
}
/**
@ -91,7 +91,7 @@ export async function masterMain() {
maxBreadcrumbs: 0,
// Set release version
release: 'Sharkey@' + meta.version,
release: 'Sharkey@' + (meta.gitVersion ?? meta.version),
...config.sentryForBackend.options,
});

View file

@ -37,7 +37,7 @@ export async function workerMain() {
maxBreadcrumbs: 0,
// Set release version
release: "Sharkey@" + meta.version,
release: "Sharkey@" + (meta.gitVersion ?? meta.version),
...config.sentryForBackend.options,
});

View file

@ -403,7 +403,7 @@ export function loadConfig(): Config {
applyEnvOverrides(config);
const url = tryCreateUrl(config.url ?? process.env.MISSKEY_URL ?? '');
const version = meta.version;
const version = meta.gitVersion ?? meta.version;
const host = url.host;
const hostname = url.hostname;
const scheme = url.protocol.replace(/:$/, '');

View file

@ -14,6 +14,7 @@ import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
import { SystemAccountService } from '@/core/SystemAccountService.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import { trackPromise } from '@/misc/promise-tracker.js';
import { IdService } from './IdService.js';
@Injectable()
@ -68,11 +69,11 @@ export class AbuseReportService {
reports.push(report);
}
return Promise.all([
trackPromise(Promise.all([
this.abuseReportNotificationService.notifyAdminStream(reports),
this.abuseReportNotificationService.notifySystemWebhook(reports, 'abuseReport'),
this.abuseReportNotificationService.notifyMail(reports),
]);
]));
}
/**

View file

@ -16,6 +16,7 @@ import { GlobalEventService } from '@/core/GlobalEventService.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import type { Config } from '@/config.js';
import { RoleService } from '@/core/RoleService.js';
@Injectable()
export class AnnouncementService {
@ -36,6 +37,7 @@ export class AnnouncementService {
private globalEventService: GlobalEventService,
private moderationLogService: ModerationLogService,
private announcementEntityService: AnnouncementEntityService,
private roleService: RoleService,
) {
}
@ -51,6 +53,7 @@ export class AnnouncementService {
const readsQuery = this.announcementReadsRepository.createQueryBuilder('read')
.select('read.announcementId')
.where('read.userId = :userId', { userId: user.id });
const roles = await this.roleService.getUserRoles(user);
const q = this.announcementsRepository.createQueryBuilder('announcement')
.where('announcement.isActive = true')
@ -63,6 +66,10 @@ export class AnnouncementService {
qb.orWhere('announcement.forExistingUsers = false');
qb.orWhere('announcement.id > :userId', { userId: user.id });
}))
.andWhere(new Brackets(qb => {
qb.orWhere('announcement.forRoles && :roles', { roles: roles.map((r) => r.id) });
qb.orWhere('announcement.forRoles = \'{}\'');
}))
.andWhere(`announcement.id NOT IN (${ readsQuery.getQuery() })`);
q.setParameters(readsQuery.getParameters());
@ -85,6 +92,7 @@ export class AnnouncementService {
icon: values.icon,
display: values.display,
forExistingUsers: values.forExistingUsers,
forRoles: values.forRoles,
silence: values.silence,
needConfirmationToRead: values.needConfirmationToRead,
confetti: values.confetti,
@ -143,6 +151,7 @@ export class AnnouncementService {
display: values.display,
icon: values.icon,
forExistingUsers: values.forExistingUsers,
forRoles: values.forRoles,
silence: values.silence,
needConfirmationToRead: values.needConfirmationToRead,
confetti: values.confetti,

View file

@ -9,7 +9,7 @@ import { In, IsNull } from 'typeorm';
import type { BlockingsRepository, FollowingsRepository, MutingsRepository, RenoteMutingsRepository, MiUserProfile, UserProfilesRepository, UsersRepository, MiNote, MiFollowing, NoteThreadMutingsRepository } from '@/models/_.js';
import { MemoryKVCache, RedisKVCache } from '@/misc/cache.js';
import { QuantumKVCache } from '@/misc/QuantumKVCache.js';
import type { MiLocalUser, MiUser } from '@/models/User.js';
import type { MiLocalUser, MiRemoteUser, MiUser } from '@/models/User.js';
import { DI } from '@/di-symbols.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { bindThis } from '@/decorators.js';
@ -387,6 +387,22 @@ export class CacheService implements OnApplicationShutdown {
}) ?? null;
}
@bindThis
public async findRemoteUserById(userId: MiUser['id']): Promise<MiRemoteUser | null> {
const user = await this.findUserById(userId);
if (user.host == null) {
return null;
}
return user as MiRemoteUser;
}
@bindThis
public findOptionalUserById(userId: MiUser['id']) {
return this.userByIdCache.fetchMaybe(userId, async () => await this.usersRepository.findOneBy({ id: userId }) ?? undefined);
}
@bindThis
public async getFollowStats(userId: MiUser['id']): Promise<FollowStats> {
return await this.userFollowStatsCache.fetch(userId, async () => {

View file

@ -9,7 +9,10 @@ import { bindThis } from '@/decorators.js';
import { MetaService } from '@/core/MetaService.js';
import { MiMeta } from '@/models/Meta.js';
import Logger from '@/logger.js';
import { LoggerService } from './LoggerService.js';
import { LoggerService } from '@/core/LoggerService.js';
import { CaptchaError } from '@/misc/captcha-error.js';
export { CaptchaError } from '@/misc/captcha-error.js';
export const supportedCaptchaProviders = ['none', 'hcaptcha', 'mcaptcha', 'recaptcha', 'turnstile', 'fc', 'testcaptcha'] as const;
export type CaptchaProvider = typeof supportedCaptchaProviders[number];
@ -49,18 +52,6 @@ export type CaptchaSetting = {
}
};
export class CaptchaError extends Error {
public readonly code: CaptchaErrorCode;
public readonly cause?: unknown;
constructor(code: CaptchaErrorCode, message: string, cause?: unknown) {
super(message, cause ? { cause } : undefined);
this.code = code;
this.cause = cause;
this.name = 'CaptchaError';
}
}
export type CaptchaSaveSuccess = {
success: true;
};

View file

@ -580,11 +580,20 @@ export class ChatService {
public async deleteRoom(room: MiChatRoom, deleter?: MiUser) {
await this.chatRoomsRepository.delete(room.id);
// Erase any message notifications for this room
const redisPipeline = this.redisClient.pipeline();
const memberships = await this.chatRoomMembershipsRepository.findBy({ roomId: room.id });
for (const membership of memberships) {
redisPipeline.del(`newRoomChatMessageExists:${membership.userId}:${room.id}`);
redisPipeline.srem(`newChatMessagesExists:${membership.userId}`, `room:${room.id}`);
}
await redisPipeline.exec();
if (deleter) {
const deleterIsModerator = await this.roleService.isModerator(deleter);
if (deleterIsModerator) {
this.moderationLogService.log(deleter, 'deleteChatRoom', {
await this.moderationLogService.log(deleter, 'deleteChatRoom', {
roomId: room.id,
room: room,
});

View file

@ -19,6 +19,7 @@ import type Logger from '@/logger.js';
import { bindThis } from '@/decorators.js';
import { renderInlineError } from '@/misc/render-inline-error.js';
import { UtilityService } from '@/core/UtilityService.js';
@Injectable()
export class DownloadService {
@ -30,6 +31,7 @@ export class DownloadService {
private httpRequestService: HttpRequestService,
private loggerService: LoggerService,
private readonly utilityService: UtilityService,
) {
this.logger = this.loggerService.getLogger('download');
}
@ -38,6 +40,8 @@ export class DownloadService {
public async downloadUrl(url: string, path: string, options: { timeout?: number, operationTimeout?: number, maxSize?: number } = {} ): Promise<{
filename: string;
}> {
this.utilityService.assertUrl(url);
this.logger.debug(`Downloading ${chalk.cyan(url)} to ${chalk.cyanBright(path)} ...`);
const timeout = options.timeout ?? 30 * 1000;

View file

@ -154,8 +154,8 @@ export class DriveService {
@bindThis
private async save(file: MiDriveFile, path: string, name: string, info: FileInfo): Promise<MiDriveFile> {
const type = info.type.mime;
const hash = info.md5;
const size = info.size;
let hash = info.md5;
let size = info.size;
// thunbnail, webpublic を必要なら生成
const alts = await this.generateAlts(path, type, !file.uri);
@ -163,6 +163,9 @@ export class DriveService {
if (type && type.startsWith('video/')) {
try {
await this.videoProcessingService.webOptimizeVideo(path, type);
const newInfo = await this.fileInfoService.getFileInfo(path);
hash = newInfo.md5;
size = newInfo.size;
} catch (err) {
this.registerLogger.warn(`Video optimization failed: ${renderInlineError(err)}`);
}
@ -738,14 +741,14 @@ export class DriveService {
@bindThis
public async deleteFile(file: MiDriveFile, isExpired = false, deleter?: MiUser) {
if (file.storedInternal) {
this.internalStorageService.del(file.accessKey!);
this.deleteLocalFile(file.accessKey!);
if (file.thumbnailUrl) {
this.internalStorageService.del(file.thumbnailAccessKey!);
this.deleteLocalFile(file.thumbnailAccessKey!);
}
if (file.webpublicUrl) {
this.internalStorageService.del(file.webpublicAccessKey!);
this.deleteLocalFile(file.webpublicAccessKey!);
}
} else if (!file.isLink) {
this.queueService.createDeleteObjectStorageFileJob(file.accessKey!);
@ -767,14 +770,14 @@ export class DriveService {
const promises = [];
if (file.storedInternal) {
promises.push(this.internalStorageService.del(file.accessKey!));
promises.push(this.deleteLocalFile(file.accessKey!));
if (file.thumbnailUrl) {
promises.push(this.internalStorageService.del(file.thumbnailAccessKey!));
promises.push(this.deleteLocalFile(file.thumbnailAccessKey!));
}
if (file.webpublicUrl) {
promises.push(this.internalStorageService.del(file.webpublicAccessKey!));
promises.push(this.deleteLocalFile(file.webpublicAccessKey!));
}
} else if (!file.isLink) {
promises.push(this.deleteObjectStorageFile(file.accessKey!));
@ -861,6 +864,22 @@ export class DriveService {
}
}
@bindThis
public async deleteLocalFile(key: string) {
try {
await this.internalStorageService.del(key);
} catch (err: any) {
if (err.code === 'ENOENT') {
this.deleteLogger.warn(`The file to delete did not exist: ${key}. Skipping this.`);
return;
} else {
throw new Error(`Failed to delete the file: ${key}`, {
cause: err,
});
}
}
}
@bindThis
public async uploadFromUrl({
url,

View file

@ -17,9 +17,9 @@ import { StatusError } from '@/misc/status-error.js';
import { bindThis } from '@/decorators.js';
import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js';
import type { IObject, IObjectWithId } from '@/core/activitypub/type.js';
import { ApUtilityService } from './activitypub/ApUtilityService.js';
import { UtilityService } from '@/core/UtilityService.js';
import { ApUtilityService } from '@/core/activitypub/ApUtilityService.js';
import type { Response } from 'node-fetch';
import type { URL } from 'node:url';
import type { Socket } from 'node:net';
export type HttpRequestSendOptions = {
@ -27,7 +27,27 @@ export type HttpRequestSendOptions = {
validators?: ((res: Response) => void)[];
};
export function isPrivateIp(allowedPrivateNetworks: PrivateNetwork[] | undefined, ip: string, port?: number): boolean {
export async function isPrivateUrl(url: URL, lookup: net.LookupFunction): Promise<boolean> {
const ip = await resolveIp(url, lookup);
return ip.range() !== 'unicast';
}
export async function resolveIp(url: URL, lookup: net.LookupFunction) {
if (ipaddr.isValid(url.hostname)) {
return ipaddr.parse(url.hostname);
}
const resolvedIp = await new Promise<string>((resolve, reject) => {
lookup(url.hostname, {}, (err, address) => {
if (err) reject(err);
else resolve(address as string);
});
});
return ipaddr.parse(resolvedIp);
}
export function isAllowedPrivateIp(allowedPrivateNetworks: PrivateNetwork[] | undefined, ip: string, port?: number): boolean {
const parsedIp = ipaddr.parse(ip);
for (const { cidr, ports } of allowedPrivateNetworks ?? []) {
@ -44,7 +64,7 @@ export function isPrivateIp(allowedPrivateNetworks: PrivateNetwork[] | undefined
export function validateSocketConnect(allowedPrivateNetworks: PrivateNetwork[] | undefined, socket: Socket): void {
const address = socket.remoteAddress;
if (address && ipaddr.isValid(address)) {
if (isPrivateIp(allowedPrivateNetworks, address, socket.remotePort)) {
if (isAllowedPrivateIp(allowedPrivateNetworks, address, socket.remotePort)) {
socket.destroy(new Error(`Blocked address: ${address}`));
}
}
@ -128,10 +148,16 @@ export class HttpRequestService {
*/
public readonly httpsAgent: https.Agent;
/**
* Get shared DNS resolver
*/
public readonly lookup: net.LookupFunction;
constructor(
@Inject(DI.config)
private config: Config,
private readonly apUtilityService: ApUtilityService,
private readonly utilityService: UtilityService,
) {
const cache = new CacheableLookup({
maxTtl: 3600, // 1hours
@ -139,6 +165,8 @@ export class HttpRequestService {
lookup: false, // nativeのdns.lookupにfallbackしない
});
this.lookup = cache.lookup as unknown as net.LookupFunction;
const agentOption = {
keepAlive: true,
keepAliveMsecs: 30 * 1000,
@ -236,8 +264,6 @@ export class HttpRequestService {
@bindThis
public async getActivityJson(url: string, isLocalAddressAllowed = false, allowAnonymous = false): Promise<IObjectWithId> {
this.apUtilityService.assertApUrl(url);
const res = await this.send(url, {
method: 'GET',
headers: {
@ -303,6 +329,7 @@ export class HttpRequestService {
timeout?: number,
size?: number,
isLocalAddressAllowed?: boolean,
allowHttp?: boolean,
} = {},
extra: HttpRequestSendOptions = {
throwErrorWhenResponseNotOk: true,
@ -311,6 +338,10 @@ export class HttpRequestService {
): Promise<Response> {
const timeout = args.timeout ?? 5000;
const parsedUrl = new URL(url);
const allowHttp = args.allowHttp || await isPrivateUrl(parsedUrl, this.lookup);
this.utilityService.assertUrl(parsedUrl, allowHttp);
const controller = new AbortController();
setTimeout(() => {
controller.abort();
@ -318,7 +349,7 @@ export class HttpRequestService {
const isLocalAddressAllowed = args.isLocalAddressAllowed ?? false;
const res = await fetch(url, {
const res = await fetch(parsedUrl, {
method: args.method ?? 'GET',
headers: {
'User-Agent': this.config.userAgent,

View file

@ -377,7 +377,7 @@ export class MfmService {
}
@bindThis
public toHtml(nodes: mfm.MfmNode[] | null, mentionedRemoteUsers: IMentionedRemoteUsers = [], additionalAppenders: Appender[] = []) {
public toHtml(nodes: mfm.MfmNode[] | null, mentionedRemoteUsers: IMentionedRemoteUsers = [], additionalAppenders: Appender[] = [], inline = false) {
if (nodes == null) {
return null;
}
@ -626,9 +626,15 @@ export class MfmService {
additionalAppender(doc, body);
}
return domserializer.render(body, {
let result = domserializer.render(body, {
encodeEntities: 'utf8'
});
if (inline) {
result = result.replace(/^<p>/, '').replace(/<\/p>$/, '');
}
return result;
}
// the toMastoApiHtml function was taken from Iceshrimp and written by zotan and modified by marie to work with the current MK version

View file

@ -17,6 +17,7 @@ import { bindThis } from '@/decorators.js';
import type { Antenna } from '@/server/api/endpoints/i/import-antennas.js';
import { ApRequestCreator } from '@/core/activitypub/ApRequestService.js';
import { type SystemWebhookPayload } from '@/core/SystemWebhookService.js';
import { MiNote } from '@/models/Note.js';
import { type UserWebhookPayload } from './UserWebhookService.js';
import type {
DbJobData,
@ -40,7 +41,6 @@ import type {
} from './QueueModule.js';
import type httpSignature from '@peertube/http-signature';
import type * as Bull from 'bullmq';
import { MiNote } from '@/models/Note.js';
export const QUEUE_TYPES = [
'system',
@ -231,6 +231,9 @@ export class QueueService {
age: 3600 * 24 * 7, // keep up to 7 days
count: 100,
},
deduplication: activity.id ? {
id: activity.id,
} : undefined,
});
}
@ -247,6 +250,9 @@ export class QueueService {
age: 3600 * 24 * 7, // keep up to 7 days
count: 100,
},
deduplication: {
id: user.id,
},
});
}
@ -263,6 +269,9 @@ export class QueueService {
age: 3600 * 24 * 7, // keep up to 7 days
count: 100,
},
deduplication: {
id: user.id,
},
});
}
@ -273,6 +282,9 @@ export class QueueService {
}, {
removeOnComplete: true,
removeOnFail: true,
deduplication: {
id: user.id,
},
});
}
@ -289,6 +301,9 @@ export class QueueService {
age: 3600 * 24 * 7, // keep up to 7 days
count: 100,
},
deduplication: {
id: user.id,
},
});
}
@ -305,6 +320,9 @@ export class QueueService {
age: 3600 * 24 * 7, // keep up to 7 days
count: 100,
},
deduplication: {
id: user.id,
},
});
}
@ -321,6 +339,9 @@ export class QueueService {
age: 3600 * 24 * 7, // keep up to 7 days
count: 100,
},
deduplication: {
id: user.id,
},
});
}
@ -339,6 +360,9 @@ export class QueueService {
age: 3600 * 24 * 7, // keep up to 7 days
count: 100,
},
deduplication: {
id: user.id,
},
});
}
@ -355,6 +379,9 @@ export class QueueService {
age: 3600 * 24 * 7, // keep up to 7 days
count: 100,
},
deduplication: {
id: user.id,
},
});
}
@ -371,6 +398,9 @@ export class QueueService {
age: 3600 * 24 * 7, // keep up to 7 days
count: 100,
},
deduplication: {
id: user.id,
},
});
}
@ -387,6 +417,9 @@ export class QueueService {
age: 3600 * 24 * 7, // keep up to 7 days
count: 100,
},
deduplication: {
id: user.id,
},
});
}
@ -403,6 +436,9 @@ export class QueueService {
age: 3600 * 24 * 7, // keep up to 7 days
count: 100,
},
deduplication: {
id: user.id,
},
});
}
@ -421,6 +457,9 @@ export class QueueService {
age: 3600 * 24 * 7, // keep up to 7 days
count: 100,
},
deduplication: {
id: `${user.id}_${fileId}_${withReplies ?? false}`,
},
});
}
@ -433,6 +472,9 @@ export class QueueService {
}, {
removeOnComplete: true,
removeOnFail: true,
deduplication: {
id: `${user.id}_${fileId}_${type ?? null}`,
},
});
}
@ -492,6 +534,9 @@ export class QueueService {
age: 3600 * 24 * 7, // keep up to 7 days
count: 100,
},
deduplication: {
id: `${user.id}_${fileId}`,
},
});
}
@ -509,6 +554,9 @@ export class QueueService {
age: 3600 * 24 * 7, // keep up to 7 days
count: 100,
},
deduplication: {
id: `${user.id}_${fileId}`,
},
});
}
@ -554,6 +602,9 @@ export class QueueService {
age: 3600 * 24 * 7, // keep up to 7 days
count: 100,
},
deduplication: {
id: `${user.id}_${fileId}`,
},
});
}
@ -571,14 +622,18 @@ export class QueueService {
age: 3600 * 24 * 7, // keep up to 7 days
count: 100,
},
deduplication: {
id: `${user.id}_${fileId}`,
},
});
}
@bindThis
public createImportAntennasJob(user: ThinUser, antenna: Antenna) {
public createImportAntennasJob(user: ThinUser, antenna: Antenna, fileId: MiDriveFile['id']) {
return this.dbQueue.add('importAntennas', {
user: { id: user.id },
antenna,
fileId,
}, {
removeOnComplete: {
age: 3600 * 24 * 7, // keep up to 7 days
@ -588,6 +643,9 @@ export class QueueService {
age: 3600 * 24 * 7, // keep up to 7 days
count: 100,
},
deduplication: {
id: `${user.id}_${fileId}`,
},
});
}
@ -605,6 +663,9 @@ export class QueueService {
age: 3600 * 24 * 7, // keep up to 7 days
count: 100,
},
deduplication: {
id: user.id,
},
});
}
@ -663,6 +724,9 @@ export class QueueService {
count: 100,
},
...opts,
deduplication: {
id: `${data.from.id}_${data.to.id}_${data.requestId ?? ''}_${data.silent ?? false}_${data.withReplies ?? false}`,
},
},
};
}
@ -680,6 +744,9 @@ export class QueueService {
age: 3600 * 24 * 7, // keep up to 7 days
count: 100,
},
deduplication: {
id: key,
},
});
}
@ -697,6 +764,9 @@ export class QueueService {
age: 3600 * 24 * 7, // keep up to 7 days
count: 100,
},
deduplication: {
id: `${olderThanSeconds}_${keepFilesInUse}`,
},
});
}
@ -879,7 +949,7 @@ export class QueueService {
public async queueGetJobs(queueType: typeof QUEUE_TYPES[number], jobTypes: JobType[], search?: string) {
const RETURN_LIMIT = 100;
const queue = this.getQueue(queueType);
let jobs: Bull.Job[];
let jobs: (Bull.Job | null)[];
if (search) {
jobs = await queue.getJobs(jobTypes, 0, 1000);
@ -896,7 +966,9 @@ export class QueueService {
jobs = await queue.getJobs(jobTypes, 0, RETURN_LIMIT);
}
return jobs.map(job => this.packJobData(job));
return jobs
.filter(job => job != null) // not sure how this happens, but it does
.map(job => this.packJobData(job));
}
@bindThis

View file

@ -4,7 +4,7 @@
*/
import { Inject, Injectable } from '@nestjs/common';
import { Not, IsNull } from 'typeorm';
import { Not, IsNull, DataSource } from 'typeorm';
import type { FollowingsRepository, FollowRequestsRepository, UsersRepository } from '@/models/_.js';
import { MiUser } from '@/models/User.js';
import { QueueService } from '@/core/QueueService.js';
@ -21,6 +21,7 @@ import { LoggerService } from '@/core/LoggerService.js';
import type Logger from '@/logger.js';
import { renderInlineError } from '@/misc/render-inline-error.js';
import { trackPromise } from '@/misc/promise-tracker.js';
import { InternalEventService } from '@/core/InternalEventService.js';
@Injectable()
export class UserSuspendService {
@ -36,12 +37,16 @@ export class UserSuspendService {
@Inject(DI.followRequestsRepository)
private followRequestsRepository: FollowRequestsRepository,
@Inject(DI.db)
private db: DataSource,
private userEntityService: UserEntityService,
private queueService: QueueService,
private globalEventService: GlobalEventService,
private apRendererService: ApRendererService,
private moderationLogService: ModerationLogService,
private readonly cacheService: CacheService,
private readonly internalEventService: InternalEventService,
loggerService: LoggerService,
) {
@ -56,6 +61,8 @@ export class UserSuspendService {
isSuspended: true,
});
await this.internalEventService.emit(user.host == null ? 'localUserUpdated' : 'remoteUserUpdated', { id: user.id });
await this.moderationLogService.log(moderator, 'suspend', {
userId: user.id,
userUsername: user.username,
@ -74,6 +81,8 @@ export class UserSuspendService {
isSuspended: false,
});
await this.internalEventService.emit(user.host == null ? 'localUserUpdated' : 'remoteUserUpdated', { id: user.id });
await this.moderationLogService.log(moderator, 'unsuspend', {
userId: user.id,
userUsername: user.username,
@ -178,30 +187,29 @@ export class UserSuspendService {
// Freeze follow relations with all remote users
await this.followingsRepository
.createQueryBuilder('following')
.orWhere({
followeeId: user.id,
followerHost: Not(IsNull()),
})
.update({
isFollowerHibernated: true,
})
.where({
followeeId: user.id,
followerHost: Not(IsNull()),
})
.execute();
}
@bindThis
private async unFreezeAll(user: MiUser): Promise<void> {
// Restore follow relations with all remote users
await this.followingsRepository
.createQueryBuilder('following')
.innerJoin(MiUser, 'follower', 'user.id = following.followerId')
.andWhere('follower.isHibernated = false') // Don't unfreeze if the follower is *actually* frozen
.andWhere({
followeeId: user.id,
followerHost: Not(IsNull()),
})
.update({
isFollowerHibernated: false,
})
.execute();
// TypeORM does not support UPDATE with JOIN: https://github.com/typeorm/typeorm/issues/564#issuecomment-310331468
await this.db.query(`
UPDATE "following"
SET "isFollowerHibernated" = false
FROM "user"
WHERE "user"."id" = "following"."followerId"
AND "user"."isHibernated" = false -- Don't unfreeze if the follower is *actually* frozen
AND "followeeId" = $1
AND "followeeHost" IS NOT NULL
`, [user.id]);
}
}

View file

@ -11,8 +11,10 @@ import semver from 'semver';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import { bindThis } from '@/decorators.js';
import { MiMeta, SoftwareSuspension } from '@/models/Meta.js';
import { MiInstance } from '@/models/Instance.js';
import type { MiMeta, SoftwareSuspension } from '@/models/Meta.js';
import type { MiInstance } from '@/models/Instance.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import { EnvService } from '@/core/EnvService.js';
@Injectable()
export class UtilityService {
@ -22,6 +24,8 @@ export class UtilityService {
@Inject(DI.meta)
private meta: MiMeta,
private readonly envService: EnvService,
) {
}
@ -183,8 +187,8 @@ export class UtilityService {
}
@bindThis
public punyHostPSLDomain(url: string): string {
const urlObj = new URL(url);
public punyHostPSLDomain(url: string | URL): string {
const urlObj = typeof(url) === 'object' ? url : new URL(url);
const hostname = urlObj.hostname;
const domain = this.specialSuffix(hostname) ?? psl.get(hostname) ?? hostname;
const host = `${this.toPuny(domain)}${urlObj.port.length > 0 ? ':' + urlObj.port : ''}`;
@ -218,17 +222,84 @@ export class UtilityService {
@bindThis
public isDeliverSuspendedSoftware(software: Pick<MiInstance, 'softwareName' | 'softwareVersion'>): SoftwareSuspension | undefined {
if (software.softwareName == null) return undefined;
if (software.softwareVersion == null) {
// software version is null; suspend iff versionRange is *
return this.meta.deliverSuspendedSoftware.find(x =>
x.software === software.softwareName
&& x.versionRange.trim() === '*');
} else {
const softwareVersion = software.softwareVersion;
return this.meta.deliverSuspendedSoftware.find(x =>
x.software === software.softwareName
&& semver.satisfies(softwareVersion, x.versionRange, { includePrerelease: true }));
// a missing name or version is treated as the empty string
const softwareName = software.softwareName ?? '';
const softwareVersion = software.softwareVersion ?? '';
function maybeRegexpMatch(test: string, target: string): boolean {
const regexpStrPair = test.trim().match(/^\/(.+)\/(.*)$/);
if (!regexpStrPair) return false; // not a regexp, can't match
try {
return new RE2(regexpStrPair[1], regexpStrPair[2]).test(target);
} catch (err) {
return false; // not a well-formed regexp, can't match
}
}
// each element of `meta.deliverSuspendedSoftware` can have a
// normal string, a `*`, or a `/regexp/` for software or
// versionRange
return this.meta.deliverSuspendedSoftware.find(
x => (
(
x.software.trim() === '*' ||
x.software === softwareName ||
maybeRegexpMatch(x.software, softwareName)
) && (
x.versionRange.trim() === '*' ||
semver.satisfies(softwareVersion, x.versionRange, { includePrerelease: true }) ||
maybeRegexpMatch(x.versionRange, softwareVersion)
)
)
);
}
/**
* Verifies that a provided URL is in a format acceptable for federation.
* @throws {IdentifiableError} If URL cannot be parsed
* @throws {IdentifiableError} If URL is not HTTPS
* @throws {IdentifiableError} If URL contains credentials
*/
@bindThis
public assertUrl(url: string | URL, allowHttp?: boolean): URL | never {
// If string, parse and validate
if (typeof(url) === 'string') {
try {
url = new URL(url);
} catch {
throw new IdentifiableError('0bedd29b-e3bf-4604-af51-d3352e2518af', `invalid url ${url}: not a valid URL`);
}
}
// Must be HTTPS
if (!this.checkHttps(url, allowHttp)) {
throw new IdentifiableError('0bedd29b-e3bf-4604-af51-d3352e2518af', `invalid url ${url}: unsupported protocol ${url.protocol}`);
}
// Must not have credentials
if (url.username || url.password) {
throw new IdentifiableError('0bedd29b-e3bf-4604-af51-d3352e2518af', `invalid url ${url}: contains embedded credentials`);
}
return url;
}
/**
* Checks if the URL contains HTTPS.
* Additionally, allows HTTP in non-production environments.
* Based on check-https.ts.
*/
@bindThis
public checkHttps(url: string | URL, allowHttp = false): boolean {
const isNonProd = this.envService.env.NODE_ENV !== 'production';
try {
const proto = new URL(url).protocol;
return proto === 'https:' || (proto === 'http:' && (isNonProd || allowHttp));
} catch {
// Invalid URLs don't "count" as HTTPS
return false;
}
}
}

View file

@ -102,6 +102,7 @@ export class VideoProcessingService {
.format(outputFormat) // Specify output format
.addOutputOptions('-c copy') // Copy streams without re-encoding
.addOutputOptions('-movflags +faststart')
.addOutputOptions('-map 0')
.on('error', reject)
.on('end', async () => {
try {

View file

@ -443,6 +443,8 @@ export class WebhookTestService {
return {
...user,
createdAt: this.idService.parse(user.id).date.toISOString(),
updatedAt: null,
lastFetchedAt: null,
id: user.id,
name: user.name,
username: user.username,

View file

@ -585,7 +585,7 @@ export class ApRendererService {
const attachment = profile.fields.map(field => ({
type: 'PropertyValue',
name: field.name,
value: this.mfmService.toHtml(mfm.parse(field.value)),
value: this.mfmService.toHtml(mfm.parse(field.value), [], [], true),
}));
const emojis = await this.getEmojis(user.emojis);
@ -750,9 +750,11 @@ export class ApRendererService {
}
@bindThis
public renderUpdate(object: string | IObject, user: { id: MiUser['id'] }): IUpdate {
public renderUpdate(object: IObject, user: { id: MiUser['id'] }): IUpdate {
// Deterministic activity IDs to allow de-duplication by remote instances
const updatedAt = object.updated ? new Date(object.updated).getTime() : Date.now();
return {
id: `${this.config.url}/users/${user.id}#updates/${new Date().getTime()}`,
id: `${this.config.url}/users/${user.id}#updates/${updatedAt}`,
actor: this.userEntityService.genLocalUserUri(user.id),
type: 'Update',
to: ['https://www.w3.org/ns/activitystreams#Public'],

View file

@ -157,8 +157,6 @@ export class ApRequestService {
@bindThis
public async signedPost(user: { id: MiUser['id'] }, url: string, object: unknown, digest?: string): Promise<void> {
this.apUtilityService.assertApUrl(url);
const body = typeof object === 'string' ? object : JSON.stringify(object);
const keypair = await this.userKeypairService.getUserKeypair(user.id);
@ -191,8 +189,6 @@ export class ApRequestService {
*/
@bindThis
public async signedGet(url: string, user: { id: MiUser['id'] }, allowAnonymous = false, followAlternate?: boolean): Promise<IObjectWithId> {
this.apUtilityService.assertApUrl(url);
const _followAlternate = followAlternate ?? true;
const keypair = await this.userKeypairService.getUserKeypair(user.id);

View file

@ -7,20 +7,29 @@ import { Injectable } from '@nestjs/common';
import { UtilityService } from '@/core/UtilityService.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import { toArray } from '@/misc/prelude/array.js';
import { EnvService } from '@/core/EnvService.js';
import { getApId, getOneApHrefNullable, IObject } from './type.js';
import { getApId, getNullableApId, getOneApHrefNullable } from '@/core/activitypub/type.js';
import type { IObject, IObjectWithId } from '@/core/activitypub/type.js';
import { bindThis } from '@/decorators.js';
import { renderInlineError } from '@/misc/render-inline-error.js';
import { LoggerService } from '@/core/LoggerService.js';
import type Logger from '@/logger.js';
@Injectable()
export class ApUtilityService {
private readonly logger: Logger;
constructor(
private readonly utilityService: UtilityService,
private readonly envService: EnvService,
) {}
loggerService: LoggerService,
) {
this.logger = loggerService.getLogger('ap-utility');
}
/**
* Verifies that the object's ID has the same authority as the provided URL.
* Returns on success, throws on any validation error.
*/
@bindThis
public assertIdMatchesUrlAuthority(object: IObject, url: string): void {
// This throws if the ID is missing or invalid, but that's ok.
// Anonymous objects are impossible to verify, so we don't allow fetching them.
@ -36,11 +45,15 @@ export class ApUtilityService {
/**
* Checks if two URLs have the same host authority
*/
@bindThis
public haveSameAuthority(url1: string, url2: string): boolean {
if (url1 === url2) return true;
const authority1 = this.utilityService.punyHostPSLDomain(url1);
const authority2 = this.utilityService.punyHostPSLDomain(url2);
const parsed1 = this.utilityService.assertUrl(url1);
const parsed2 = this.utilityService.assertUrl(url2);
const authority1 = this.utilityService.punyHostPSLDomain(parsed1);
const authority2 = this.utilityService.punyHostPSLDomain(parsed2);
return authority1 === authority2;
}
@ -50,6 +63,7 @@ export class ApUtilityService {
* @throws {IdentifiableError} if object does not have an ID
* @returns the best URL, or null if none were found
*/
@bindThis
public findBestObjectUrl(object: IObject): string | null {
const targetUrl = getApId(object);
const targetAuthority = this.utilityService.punyHostPSLDomain(targetUrl);
@ -63,12 +77,16 @@ export class ApUtilityService {
: undefined,
}))
.filter(({ url, type }) => {
if (!url) return false;
if (!this.checkHttps(url)) return false;
if (!isAcceptableUrlType(type)) return false;
try {
if (!url) return false;
if (!isAcceptableUrlType(type)) return false;
const parsed = this.utilityService.assertUrl(url);
const urlAuthority = this.utilityService.punyHostPSLDomain(url);
return urlAuthority === targetAuthority;
const urlAuthority = this.utilityService.punyHostPSLDomain(parsed);
return urlAuthority === targetAuthority;
} catch {
return false;
}
})
.sort((a, b) => {
return rankUrlType(a.type) - rankUrlType(b.type);
@ -78,41 +96,72 @@ export class ApUtilityService {
}
/**
* Verifies that a provided URL is in a format acceptable for federation.
* @throws {IdentifiableError} If URL cannot be parsed
* @throws {IdentifiableError} If URL is not HTTPS
* Sanitizes an inline / nested Object property within an AP object.
*
* Returns true if the property contains a valid string URL, object w/ valid ID, or an array containing one of those.
* Returns false and erases the property if it doesn't contain a valid value.
*
* Arrays are automatically flattened.
* Falsy values (including null) are collapsed to undefined.
* @param obj Object containing the property to validate
* @param key Key of the property in obj
* @param parentUri URI of the object that contains this inline object.
* @param parentHost PSL host of parentUri
* @param keyPath If obj is *itself* a nested object, set this to the property path from root to obj (including the trailing '.'). This does not affect the logic, but improves the clarity of logs.
*/
public assertApUrl(url: string | URL): void {
// If string, parse and validate
if (typeof(url) === 'string') {
try {
url = new URL(url);
} catch {
throw new IdentifiableError('0bedd29b-e3bf-4604-af51-d3352e2518af', `invalid AP url ${url}: not a valid URL`);
}
@bindThis
public sanitizeInlineObject<Key extends string>(obj: Partial<Record<Key, string | { id?: string } | (string | { id?: string })[]>>, key: Key, parentUri: string | URL, parentHost: string, keyPath = ''): obj is Partial<Record<Key, string | { id: string }>> {
let value: unknown = obj[key];
// Unpack arrays
if (Array.isArray(value)) {
value = value[0];
}
// Must be HTTPS
if (!this.checkHttps(url)) {
throw new IdentifiableError('0bedd29b-e3bf-4604-af51-d3352e2518af', `invalid AP url ${url}: unsupported protocol ${url.protocol}`);
}
}
// Clear the value - we'll add it back once we have a confirmed ID
obj[key] = undefined;
/**
* Checks if the URL contains HTTPS.
* Additionally, allows HTTP in non-production environments.
* Based on check-https.ts.
*/
private checkHttps(url: string | URL): boolean {
const isNonProd = this.envService.env.NODE_ENV !== 'production';
try {
const proto = new URL(url).protocol;
return proto === 'https:' || (proto === 'http:' && isNonProd);
} catch {
// Invalid URLs don't "count" as HTTPS
// Collapse falsy values to undefined
if (!value) {
return false;
}
// Exclude nested arrays
if (Array.isArray(value)) {
this.logger.warn(`Excluding ${keyPath}${key} from object ${parentUri}: nested arrays are prohibited`);
return false;
}
// Exclude incorrect types
if (typeof(value) !== 'string' && typeof(value) !== 'object') {
this.logger.warn(`Excluding ${keyPath}${key} from object ${parentUri}: incorrect type ${typeof(value)}`);
return false;
}
const valueId = getNullableApId(value);
if (!valueId) {
// Exclude missing ID
this.logger.warn(`Excluding ${keyPath}${key} from object ${parentUri}: missing or invalid ID`);
return false;
}
try {
const parsed = this.utilityService.assertUrl(valueId);
const parsedHost = this.utilityService.punyHostPSLDomain(parsed);
if (parsedHost !== parentHost) {
// Exclude wrong host
this.logger.warn(`Excluding ${keyPath}${key} from object ${parentUri}: wrong host in ${valueId} (got ${parsedHost}, expected ${parentHost})`);
return false;
}
} catch (err) {
// Exclude invalid URLs
this.logger.warn(`Excluding ${keyPath}${key} from object ${parentUri}: invalid URL ${valueId}: ${renderInlineError(err)}`);
return false;
}
// Success - store the sanitized value and return
obj[key] = value as string | IObjectWithId;
return true;
}
}

View file

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

View file

@ -153,89 +153,88 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown {
*/
@bindThis
private validateActor(x: IObject, uri: string): IActor {
this.apUtilityService.assertApUrl(uri);
const expectHost = this.utilityService.punyHostPSLDomain(uri);
const parsedUri = this.utilityService.assertUrl(uri);
const expectHost = this.utilityService.punyHostPSLDomain(parsedUri);
// Validate type
if (!isActor(x)) {
throw new UnrecoverableError(`invalid Actor ${uri}: unknown type '${x.type}'`);
}
if (!(typeof x.id === 'string' && x.id.length > 0)) {
throw new UnrecoverableError(`invalid Actor ${uri}: wrong id type`);
// Validate id
if (!x.id) {
throw new UnrecoverableError(`invalid Actor ${uri}: missing id`);
}
if (typeof(x.id) !== 'string') {
throw new UnrecoverableError(`invalid Actor ${uri}: wrong id type ${typeof(x.id)}`);
}
const parsedId = this.utilityService.assertUrl(x.id);
const idHost = this.utilityService.punyHostPSLDomain(parsedId);
if (idHost !== expectHost) {
throw new UnrecoverableError(`invalid Actor ${uri}: wrong host in id ${x.id} (got ${parsedId}, expected ${expectHost})`);
}
if (!(typeof x.inbox === 'string' && x.inbox.length > 0)) {
throw new UnrecoverableError(`invalid Actor ${uri}: wrong inbox type`);
// Validate inbox
this.apUtilityService.sanitizeInlineObject(x, 'inbox', parsedUri, expectHost);
if (!x.inbox || typeof(x.inbox) !== 'string') {
throw new UnrecoverableError(`invalid Actor ${uri}: missing or invalid inbox ${x.inbox}`);
}
this.apUtilityService.assertApUrl(x.inbox);
const inboxHost = this.utilityService.punyHostPSLDomain(x.inbox);
if (inboxHost !== expectHost) {
throw new UnrecoverableError(`invalid Actor ${uri}: wrong inbox host ${inboxHost}`);
// Sanitize sharedInbox
this.apUtilityService.sanitizeInlineObject(x, 'sharedInbox', parsedUri, expectHost);
// Sanitize endpoints object
if (typeof(x.endpoints) === 'object') {
x.endpoints = {
sharedInbox: x.endpoints.sharedInbox,
};
} else {
x.endpoints = undefined;
}
const sharedInboxObject = x.sharedInbox ?? (x.endpoints ? x.endpoints.sharedInbox : undefined);
if (sharedInboxObject != null) {
const sharedInbox = getApId(sharedInboxObject);
this.apUtilityService.assertApUrl(sharedInbox);
if (!(typeof sharedInbox === 'string' && sharedInbox.length > 0 && this.utilityService.punyHostPSLDomain(sharedInbox) === expectHost)) {
throw new UnrecoverableError(`invalid Actor ${uri}: wrong shared inbox ${sharedInbox}`);
// Sanitize endpoints.sharedInbox
if (x.endpoints) {
this.apUtilityService.sanitizeInlineObject(x.endpoints, 'sharedInbox', parsedUri, expectHost, 'endpoints.');
if (!x.endpoints.sharedInbox) {
x.endpoints = undefined;
}
}
for (const collection of ['outbox', 'followers', 'following'] as (keyof IActor)[]) {
const xCollection = (x as IActor)[collection];
if (xCollection != null) {
const collectionUri = getApId(xCollection);
if (typeof collectionUri === 'string' && collectionUri.length > 0) {
this.apUtilityService.assertApUrl(collectionUri);
if (this.utilityService.punyHostPSLDomain(collectionUri) !== expectHost) {
throw new UnrecoverableError(`invalid Actor ${uri}: wrong ${collection} host ${collectionUri}`);
}
} else if (collectionUri != null) {
throw new UnrecoverableError(`invalid Actor ${uri}: wrong ${collection} type`);
}
}
// Sanitize collections
for (const collection of ['outbox', 'followers', 'following', 'featured'] as const) {
this.apUtilityService.sanitizeInlineObject(x, collection, parsedUri, expectHost);
}
// Validate username
if (!(typeof x.preferredUsername === 'string' && x.preferredUsername.length > 0 && x.preferredUsername.length <= 128 && /^\w([\w-.]*\w)?$/.test(x.preferredUsername))) {
throw new UnrecoverableError(`invalid Actor ${uri}: wrong username`);
}
// Sanitize name
// These fields are only informational, and some AP software allows these
// fields to be very long. If they are too long, we cut them off. This way
// we can at least see these users and their activities.
if (x.name) {
if (!(typeof x.name === 'string' && x.name.length > 0)) {
throw new UnrecoverableError(`invalid Actor ${uri}: wrong name`);
}
x.name = truncate(x.name, nameLength);
} else if (x.name === '') {
// Mastodon emits empty string when the name is not set.
if (!x.name) {
x.name = undefined;
} else if (typeof(x.name) !== 'string') {
this.logger.warn(`Excluding name from object ${uri}: incorrect type ${typeof(x)}`);
x.name = undefined;
} else {
x.name = truncate(x.name, nameLength);
}
if (x.summary) {
if (!(typeof x.summary === 'string' && x.summary.length > 0)) {
throw new UnrecoverableError(`invalid Actor ${uri}: wrong summary`);
}
// Sanitize summary
if (!x.summary) {
x.summary = undefined;
} else if (typeof(x.summary) !== 'string') {
this.logger.warn(`Excluding summary from object ${uri}: incorrect type ${typeof(x)}`);
} else {
x.summary = truncate(x.summary, this.config.maxRemoteBioLength);
}
const idHost = this.utilityService.punyHostPSLDomain(x.id);
if (idHost !== expectHost) {
throw new UnrecoverableError(`invalid Actor ${uri}: wrong id ${x.id}`);
}
if (x.publicKey) {
if (typeof x.publicKey.id !== 'string') {
throw new UnrecoverableError(`invalid Actor ${uri}: wrong publicKey.id type`);
}
const publicKeyIdHost = this.utilityService.punyHostPSLDomain(x.publicKey.id);
if (publicKeyIdHost !== expectHost) {
throw new UnrecoverableError(`invalid Actor ${uri}: wrong publicKey.id ${x.publicKey.id}`);
}
}
// Sanitize publicKey
this.apUtilityService.sanitizeInlineObject(x, 'publicKey', parsedUri, expectHost);
return x;
}
@ -375,7 +374,8 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown {
const url = this.apUtilityService.findBestObjectUrl(person);
const verifiedLinks = url ? await verifyFieldLinks(fields, url, this.httpRequestService) : [];
const profileUrls = url ? [url, person.id] : [person.id];
const verifiedLinks = await verifyFieldLinks(fields, profileUrls, this.httpRequestService);
// Create user
let user: MiRemoteUser | null = null;
@ -494,7 +494,7 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown {
user = u as MiRemoteUser;
publicKey = await this.userPublickeysRepository.findOneBy({ userId: user.id });
} else {
this.logger.error('Error creating Person:', e instanceof Error ? e : new Error(e as string));
this.logger.error(`Error creating Person ${uri}: ${renderInlineError(e)}`);
throw e;
}
}
@ -623,7 +623,8 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown {
const url = this.apUtilityService.findBestObjectUrl(person);
const verifiedLinks = url ? await verifyFieldLinks(fields, url, this.httpRequestService) : [];
const profileUrls = url ? [url, person.id] : [person.id];
const verifiedLinks = await verifyFieldLinks(fields, profileUrls, this.httpRequestService);
const updates = {
lastFetchedAt: new Date(),
@ -776,7 +777,7 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown {
return result;
})
.catch(e => {
this.logger.info(`Processing Move Failed @${updated.username}@${updated.host} (${uri})`, { stack: e });
this.logger.info(`Processing Move Failed @${updated.username}@${updated.host} (${uri}): ${renderInlineError(e)}`);
});
}

View file

@ -28,8 +28,9 @@ export interface IObject {
inReplyTo?: any;
replies?: ICollection | IOrderedCollection | string;
content?: string | null;
startTime?: Date;
endTime?: Date;
startTime?: Date; // TODO these are wrong - should be string
endTime?: Date; // TODO these are wrong - should be string
updated?: string;
icon?: any;
image?: any;
mediaType?: string;
@ -86,7 +87,7 @@ export function getOneApId(value: ApObject): string {
/**
* Get ActivityStreams Object id
*/
export function getApId(value: string | IObject | [string | IObject], sourceForLogs?: string): string {
export function getApId(value: unknown | [unknown] | unknown[], sourceForLogs?: string): string {
const id = getNullableApId(value);
if (id == null) {
@ -102,7 +103,7 @@ export function getApId(value: string | IObject | [string | IObject], sourceForL
/**
* Get ActivityStreams Object id, or null if not present
*/
export function getNullableApId(source: string | IObject | [string | IObject]): string | null {
export function getNullableApId(source: unknown | [unknown] | unknown[]): string | null {
const value: unknown = fromTuple(source);
if (value != null) {
@ -216,7 +217,6 @@ export interface IPost extends IObject {
quoteUrl?: string;
quoteUri?: string;
quote?: string;
updated?: string;
}
export interface IQuestion extends IObject {
@ -276,7 +276,7 @@ export interface IActor extends IObject {
followers?: string | ICollection | IOrderedCollection;
following?: string | ICollection | IOrderedCollection;
featured?: string | IOrderedCollection;
outbox: string | IOrderedCollection;
outbox?: string | IOrderedCollection;
endpoints?: {
sharedInbox?: string;
};

View file

@ -75,6 +75,7 @@ export class MetaEntityService {
shortName: instance.shortName,
uri: this.config.url,
description: instance.description,
about: instance.about,
langs: instance.langs,
tosUrl: instance.termsOfServiceUrl,
repositoryUrl: instance.repositoryUrl,

View file

@ -546,6 +546,8 @@ export class UserEntityService implements OnModuleInit {
avatarBlurhash: (user.avatarId == null ? null : user.avatarBlurhash),
description: mastoapi ? mastoapi.description : profile ? profile.description : '',
createdAt: this.idService.parse(user.id).date.toISOString(),
updatedAt: user.updatedAt ? user.updatedAt.toISOString() : null,
lastFetchedAt: user.lastFetchedAt ? user.lastFetchedAt.toISOString() : null,
avatarDecorations: user.avatarDecorations.length > 0 ? this.avatarDecorationService.getAll().then(decorations => user.avatarDecorations.filter(ud => decorations.some(d => d.id === ud.id)).map(ud => ({
id: ud.id,
angle: ud.angle || undefined,
@ -601,8 +603,6 @@ export class UserEntityService implements OnModuleInit {
? Promise.all(user.alsoKnownAs.map(uri => Promise.resolve(opts.userIdsByUri?.get(uri) ?? this.apPersonService.fetchPerson(uri).then(user => user?.id).catch(() => null))))
.then(xs => xs.length === 0 ? null : xs.filter(x => x != null))
: null,
updatedAt: user.updatedAt ? user.updatedAt.toISOString() : null,
lastFetchedAt: user.lastFetchedAt ? user.lastFetchedAt.toISOString() : null,
bannerUrl: user.bannerId == null ? null : user.bannerUrl,
bannerBlurhash: user.bannerId == null ? null : user.bannerBlurhash,
backgroundUrl: user.backgroundId == null ? null : user.backgroundUrl,

View 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';
}
}

View file

@ -5,7 +5,7 @@
import { IdentifiableError } from '@/misc/identifiable-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 {
const parts: string[] = [];

View file

@ -8,17 +8,18 @@ import type { HttpRequestService } from '@/core/HttpRequestService.js';
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 = [];
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 {
// getHtml validates the input URL, so we can safely pass in untrusted values
const html = await httpRequestService.getHtml(field_url.value);
const doc = cheerio(html);
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) {
verified_links.push(field_url.value);
}

View file

@ -6,6 +6,7 @@
import { Entity, Index, Column, PrimaryColumn, ManyToOne, JoinColumn } from 'typeorm';
import { id } from './util/id.js';
import { MiUser } from './User.js';
import { MiRole } from './Role.js';
@Entity('announcement')
export class MiAnnouncement {
@ -66,6 +67,12 @@ export class MiAnnouncement {
})
public forExistingUsers: boolean;
@Column('text', {
array: true,
default: '{}', nullable: false,
})
public forRoles: MiRole['id'][];
@Index()
@Column('boolean', {
default: false,

View file

@ -50,8 +50,8 @@ export class MiChatMessage {
@JoinColumn()
public toRoom: MiChatRoom | null;
@Column('varchar', {
length: 4096, nullable: true,
@Column('text', {
nullable: true,
})
public text: string | null;

View file

@ -43,6 +43,11 @@ export class MiMeta {
})
public description: string | null;
@Column('text', {
nullable: true,
})
public about: string | null;
/**
*
*/
@ -628,8 +633,7 @@ export class MiMeta {
})
public policies: Record<string, any>;
@Column('varchar', {
length: 280,
@Column('text', {
array: true,
default: '{}',
})

View file

@ -37,4 +37,10 @@ export class MiUserPending {
nullable: true,
})
public reason: string;
@Column('varchar', {
length: 128,
nullable: true,
})
public requestOriginIp: string | null;
}

View file

@ -43,6 +43,10 @@ export const packedMetaLiteSchema = {
type: 'string',
optional: false, nullable: true,
},
about: {
type: 'string',
optional: false, nullable: true,
},
langs: {
type: 'array',
optional: false, nullable: false,

View file

@ -69,6 +69,16 @@ export const packedUserLiteSchema = {
nullable: false, optional: false,
format: 'date-time',
},
updatedAt: {
type: 'string',
nullable: true, optional: false,
format: 'date-time',
},
lastFetchedAt: {
type: 'string',
nullable: true, optional: false,
format: 'date-time',
},
approved: {
type: 'boolean',
nullable: false, optional: false,
@ -304,16 +314,6 @@ export const packedUserDetailedNotMeOnlySchema = {
nullable: false, optional: false,
},
},
updatedAt: {
type: 'string',
nullable: true, optional: false,
format: 'date-time',
},
lastFetchedAt: {
type: 'string',
nullable: true, optional: false,
format: 'date-time',
},
bannerUrl: {
type: 'string',
format: 'url',

View file

@ -11,8 +11,8 @@ import Logger from '@/logger.js';
import type { AntennasRepository, UsersRepository } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
import { QueueLoggerService } from '../QueueLoggerService.js';
import { NotificationService } from '@/core/NotificationService.js';
import { QueueLoggerService } from '../QueueLoggerService.js';
import { DBAntennaImportJobData } from '../types.js';
import type * as Bull from 'bullmq';
@ -79,7 +79,7 @@ export class ImportAntennasProcessorService {
return;
}
this.logger.debug(`Importing blocking of ${job.data.user.id} ...`);
this.logger.debug(`Importing antennas of ${job.data.user.id} ...`);
const now = new Date();
try {

View file

@ -68,9 +68,7 @@ export class ImportCustomEmojisProcessorService {
fs.writeFileSync(destPath, '', 'binary');
await this.downloadService.downloadUrl(file.url, destPath, { operationTimeout: this.config.import?.downloadTimeout, maxSize: this.config.import?.maxFileSize });
} catch (e) { // TODO: 何度か再試行
if (e instanceof Error || typeof e === 'string') {
this.logger.error('Error importing custom emojis:', e as Error);
}
this.logger.error(`Error importing custom emojis: ${renderInlineError(e)}`);
throw e;
}

View file

@ -110,6 +110,7 @@ export type DbNoteImportJobData = {
export type DBAntennaImportJobData = {
user: ThinUser,
antenna: Antenna
fileId: MiDriveFile['id'];
};
export type DbUserImportToDbJobData = {

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
# 0 - Calculations
dripRate = ceil(limit.dripRate ?? 1000);
dripRate = ceil((limit.dripRate ?? 1000) * factor);
dripSize = ceil(limit.dripSize ?? 1);
bucketSize = max(ceil(limit.size / factor), 1);
maxExpiration = max(ceil((dripRate * ceil(bucketSize / dripSize)) / 1000), 1);;

View file

@ -206,7 +206,7 @@ export class SkRateLimiterService {
// 0 - Calculate
const now = this.timeService.now;
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 fullResetMs = dripRate * Math.ceil(bucketSize / dripSize);
const fullResetSec = Math.max(Math.ceil(fullResetMs / 1000), 1);

View file

@ -7,7 +7,7 @@ import { Inject, Injectable } from '@nestjs/common';
import * as argon2 from 'argon2';
import { IsNull } from 'typeorm';
import { DI } from '@/di-symbols.js';
import type { RegistrationTicketsRepository, UsedUsernamesRepository, UserPendingsRepository, UserProfilesRepository, UsersRepository, MiRegistrationTicket, MiMeta } from '@/models/_.js';
import type { RegistrationTicketsRepository, UsedUsernamesRepository, UserPendingsRepository, UserProfilesRepository, UsersRepository, MiRegistrationTicket, MiMeta, UserIpsRepository } from '@/models/_.js';
import type { Config } from '@/config.js';
import { CaptchaService } from '@/core/CaptchaService.js';
import { IdService } from '@/core/IdService.js';
@ -19,11 +19,14 @@ import { FastifyReplyError } from '@/misc/fastify-reply-error.js';
import { bindThis } from '@/decorators.js';
import { L_CHARS, secureRndstr } from '@/misc/secure-rndstr.js';
import { RoleService } from '@/core/RoleService.js';
import Logger from '@/logger.js';
import { LoggerService } from '@/core/LoggerService.js';
import { SigninService } from './SigninService.js';
import type { FastifyRequest, FastifyReply } from 'fastify';
@Injectable()
export class SignupApiService {
private logger: Logger;
constructor(
@Inject(DI.config)
private config: Config,
@ -46,6 +49,9 @@ export class SignupApiService {
@Inject(DI.registrationTicketsRepository)
private registrationTicketsRepository: RegistrationTicketsRepository,
@Inject(DI.userIpsRepository)
private userIpsRepository: UserIpsRepository,
private userEntityService: UserEntityService,
private idService: IdService,
private captchaService: CaptchaService,
@ -53,7 +59,9 @@ export class SignupApiService {
private signinService: SigninService,
private emailService: EmailService,
private roleService: RoleService,
private loggerService: LoggerService,
) {
this.logger = this.loggerService.getLogger('Signup');
}
@bindThis
@ -213,6 +221,7 @@ export class SignupApiService {
username: username,
password: hash,
reason: reason,
requestOriginIp: this.meta.enableIpLogging ? request.ip : null,
});
const link = `${this.config.url}/signup-complete/${code}`;
@ -249,6 +258,10 @@ export class SignupApiService {
});
}
if (this.meta.enableIpLogging) {
this.logIp(request.ip, null, account.id);
}
const moderators = await this.roleService.getModerators();
for (const moderator of moderators) {
@ -282,6 +295,10 @@ export class SignupApiService {
});
}
if (this.meta.enableIpLogging) {
this.logIp(request.ip, null, account.id);
}
return {
...res,
token: secret,
@ -332,6 +349,15 @@ export class SignupApiService {
});
}
if (pendingUser.requestOriginIp) {
this.logIp(pendingUser.requestOriginIp, this.idService.parse(pendingUser.id).date, account.id);
}
// The sign-up request and the confirmation may've come from different addresses: log both
if (this.meta.enableIpLogging) {
this.logIp(request.ip, null, account.id);
}
if (this.meta.approvalRequiredForSignup) {
if (pendingUser.email) {
this.emailService.sendEmail(pendingUser.email, 'Approval pending',
@ -359,4 +385,17 @@ export class SignupApiService {
throw new FastifyReplyError(400, String(err), err);
}
}
@bindThis
private logIp(ip: string, ipDate: Date | null, userId: MiLocalUser['id']) {
try {
this.userIpsRepository.createQueryBuilder().insert().values({
createdAt: ipDate ?? new Date(),
userId,
ip,
}).orIgnore(true).execute();
} catch (err) {
this.logger.error(err as Error);
}
}
}

View file

@ -12,6 +12,7 @@ import { DI } from '@/di-symbols.js';
import type { UsersRepository, MiAccessToken, MiUser, NoteReactionsRepository, NotesRepository, NoteFavoritesRepository } from '@/models/_.js';
import type { Config } from '@/config.js';
import type { Keyed, RateLimit } from '@/misc/rate-limit-utils.js';
import { renderInlineError } from '@/misc/render-inline-error.js';
import { NotificationService } from '@/core/NotificationService.js';
import { bindThis } from '@/decorators.js';
import { CacheService } from '@/core/CacheService.js';
@ -20,6 +21,7 @@ import { UserService } from '@/core/UserService.js';
import { ChannelFollowingService } from '@/core/ChannelFollowingService.js';
import { getIpHash } from '@/misc/get-ip-hash.js';
import { LoggerService } from '@/core/LoggerService.js';
import type Logger from '@/logger.js';
import { SkRateLimiterService } from '@/server/SkRateLimiterService.js';
import { QueryService } from '@/core/QueryService.js';
import { AuthenticateService, AuthenticationError } from './AuthenticateService.js';
@ -38,6 +40,7 @@ export class StreamingApiServerService implements OnApplicationShutdown {
#connectionsByClient = new Map<string, Set<WebSocket.WebSocket>>(); // key: IP / user ID -> value: connection
#cleanConnectionsIntervalId: NodeJS.Timeout | null = null;
readonly #globalEv = new EventEmitter();
#logger: Logger;
constructor(
@Inject(DI.redisForSub)
@ -69,6 +72,7 @@ export class StreamingApiServerService implements OnApplicationShutdown {
private config: Config,
) {
this.redisForSub.on('message', this.onRedis);
this.#logger = loggerService.getLogger('streaming', 'coral');
}
@bindThis
@ -112,6 +116,7 @@ export class StreamingApiServerService implements OnApplicationShutdown {
let user: MiLocalUser | null = null;
let app: MiAccessToken | null = null;
let dieInstantly: [number, string] | null = null;
// https://datatracker.ietf.org/doc/html/rfc6750.html#section-2.1
// Note that the standard WHATWG WebSocket API does not support setting any headers,
@ -128,21 +133,16 @@ export class StreamingApiServerService implements OnApplicationShutdown {
}
} catch (e) {
if (e instanceof AuthenticationError) {
socket.write([
'HTTP/1.1 401 Unauthorized',
'WWW-Authenticate: Bearer realm="Misskey", error="invalid_token", error_description="Failed to authenticate"',
].join('\r\n') + '\r\n\r\n');
dieInstantly = [4000, 'Failed to authenticate'];
} else {
socket.write('HTTP/1.1 500 Internal Server Error\r\n\r\n');
socket.destroy();
return;
}
socket.destroy();
return;
}
if (user?.isSuspended) {
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
socket.destroy();
return;
dieInstantly = [4001, 'User suspended'];
}
// ServerServices sets `trustProxy: true`, which inside fastify/request.js ends up calling `proxyAddr` in this way, so we do the same.
@ -220,8 +220,20 @@ export class StreamingApiServerService implements OnApplicationShutdown {
if (connectionsForClient.size < 1) {
this.#connectionsByClient.delete(limitActor);
}
stream.dispose();
});
ws.once('error', (e) => {
this.#logger.error(`Unhandled error in Streaming Api: ${renderInlineError(e)}`);
ws.terminate();
});
if (dieInstantly !== null) {
ws.close(...dieInstantly);
return;
}
this.#wss.emit('connection', ws, request, {
stream, user, app,
});

View file

@ -69,6 +69,7 @@ export const paramDef = {
icon: { type: 'string', enum: ['info', 'warning', 'error', 'success'], default: 'info' },
display: { type: 'string', enum: ['normal', 'banner', 'dialog'], default: 'normal' },
forExistingUsers: { type: 'boolean', default: false },
forRoles: { type: 'array', default: [], items: { type: 'string', nullable: false, format: 'misskey:id' }, },
silence: { type: 'boolean', default: false },
needConfirmationToRead: { type: 'boolean', default: false },
confetti: { type: 'boolean', default: false },
@ -93,11 +94,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
icon: ps.icon,
display: ps.display,
forExistingUsers: ps.forExistingUsers,
forRoles: ps.forRoles,
silence: ps.silence,
needConfirmationToRead: ps.needConfirmationToRead,
confetti: ps.confetti,
userId: ps.userId,
}, me);
return packed;
} catch (e) {
if (e instanceof IdentifiableError) {

View file

@ -57,6 +57,15 @@ export const meta = {
type: 'number',
optional: false, nullable: false,
},
forRoles: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'string',
optional: false, nullable: false,
format: 'misskey:id'
}
},
},
},
},
@ -122,6 +131,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
display: announcement.display,
isActive: announcement.isActive,
forExistingUsers: announcement.forExistingUsers,
forRoles: announcement.forRoles,
silence: announcement.silence,
needConfirmationToRead: announcement.needConfirmationToRead,
confetti: announcement.confetti,

View file

@ -42,6 +42,7 @@ export const paramDef = {
icon: { type: 'string', enum: ['info', 'warning', 'error', 'success'] },
display: { type: 'string', enum: ['normal', 'banner', 'dialog'] },
forExistingUsers: { type: 'boolean' },
forRoles: { type: 'array', default: [], items: { type: 'string', nullable: false, format: 'misskey:id' }, },
silence: { type: 'boolean' },
needConfirmationToRead: { type: 'boolean' },
confetti: { type: 'boolean' },
@ -73,6 +74,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
display: ps.display,
icon: ps.icon,
forExistingUsers: ps.forExistingUsers,
forRoles: ps.forRoles,
silence: ps.silence,
needConfirmationToRead: ps.needConfirmationToRead,
confetti: ps.confetti,

View file

@ -61,7 +61,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
'Your Account has been declined!');
}
await this.usedUsernamesRepository.delete({ username: user.username });
await this.usedUsernamesRepository.delete({ username: user.username.toLowerCase() });
await this.deleteAccountService.deleteAccount(user);

View file

@ -50,7 +50,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
});
for (const file of files) {
this.driveService.deleteFile(file, false, me);
this.driveService.deleteFile(file);
}
});
}

View file

@ -664,6 +664,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
shortName: instance.shortName,
uri: this.config.url,
description: instance.description,
about: instance.about,
langs: instance.langs,
tosUrl: instance.termsOfServiceUrl,
repositoryUrl: instance.repositoryUrl,

View file

@ -35,7 +35,7 @@ export const meta = {
properties: {
id: { type: 'string', format: 'misskey:id' },
createdAt: { type: 'string', format: 'date-time' },
user: { ref: 'UserDetailed' },
user: { ref: 'User' },
expiresAt: { type: 'string', format: 'date-time', nullable: true },
},
required: ['id', 'createdAt', 'user'],
@ -50,6 +50,11 @@ export const paramDef = {
sinceId: { type: 'string', format: 'misskey:id' },
untilId: { type: 'string', format: 'misskey:id' },
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
detail: {
type: 'boolean',
nullable: false,
default: true,
},
},
required: ['roleId'],
} as const;
@ -90,12 +95,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.getMany();
const _users = assigns.map(({ user, userId }) => user ?? userId);
const _userMap = await this.userEntityService.packMany(_users, me, { schema: 'UserDetailed' })
const _userMap = await this.userEntityService.packMany(_users, me, { schema: ps.detail ? 'UserDetailed' : 'UserLite' })
.then(users => new Map(users.map(u => [u.id, u])));
return await Promise.all(assigns.map(async assign => ({
id: assign.id,
createdAt: this.idService.parse(assign.id).date.toISOString(),
user: _userMap.get(assign.userId) ?? await this.userEntityService.pack(assign.user!, me, { schema: 'UserDetailed' }),
user: _userMap.get(assign.userId) ?? await this.userEntityService.pack(assign.user!, me, { schema: ps.detail ? 'UserDetailed' : 'UserLite' }),
expiresAt: assign.expiresAt?.toISOString() ?? null,
})));
});

View file

@ -24,7 +24,7 @@ export const meta = {
items: {
type: 'object',
nullable: false, optional: false,
ref: 'UserDetailed',
ref: 'User',
},
},
} as const;
@ -44,6 +44,11 @@ export const paramDef = {
default: null,
description: 'The local host is represented with `null`.',
},
detail: {
type: 'boolean',
nullable: false,
default: true,
},
},
required: [],
} as const;
@ -115,7 +120,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
const users = await query.getMany();
return await this.userEntityService.packMany(users, me, { schema: 'UserDetailed' });
return await this.userEntityService.packMany(users, me, { schema: ps.detail ? 'UserDetailed' : 'UserLite' });
});
}
}

View file

@ -67,6 +67,7 @@ export const paramDef = {
name: { type: 'string', nullable: true },
shortName: { type: 'string', nullable: true },
description: { type: 'string', nullable: true },
about: { type: 'string', nullable: true },
defaultLightTheme: { type: 'string', nullable: true },
defaultDarkTheme: { type: 'string', nullable: true },
defaultLike: { type: 'string' },
@ -340,6 +341,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
set.description = ps.description;
}
if (ps.about !== undefined) {
set.about = ps.about;
}
if (ps.defaultLightTheme !== undefined) {
set.defaultLightTheme = ps.defaultLightTheme;
}

View file

@ -7,6 +7,7 @@ import { Inject, Injectable } from '@nestjs/common';
import { Brackets } from 'typeorm';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { QueryService } from '@/core/QueryService.js';
import { RoleService } from '@/core/RoleService.js';
import { AnnouncementEntityService } from '@/core/entities/AnnouncementEntityService.js';
import { DI } from '@/di-symbols.js';
import type { AnnouncementsRepository } from '@/models/_.js';
@ -51,14 +52,20 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private announcementsRepository: AnnouncementsRepository,
private queryService: QueryService,
private roleService: RoleService,
private announcementEntityService: AnnouncementEntityService,
) {
super(meta, paramDef, async (ps, me) => {
const roles = me ? await this.roleService.getUserRoles(me) : [];
const query = this.queryService.makePaginationQuery(this.announcementsRepository.createQueryBuilder('announcement'), ps.sinceId, ps.untilId)
.andWhere('announcement.isActive = :isActive', { isActive: ps.isActive })
.andWhere(new Brackets(qb => {
if (me) qb.orWhere('announcement.userId = :meId', { meId: me.id });
qb.orWhere('announcement.userId IS NULL');
}))
.andWhere(new Brackets(qb => {
if (me) qb.orWhere('announcement.forRoles && :roles', { roles: roles.map((r) => r.id) });
qb.orWhere('announcement.forRoles = \'{}\'');
}));
const announcements = await query.limit(ps.limit).getMany();

View file

@ -11,6 +11,7 @@ import { DI } from '@/di-symbols.js';
import { ApiError } from '@/server/api/error.js';
import { ChatService } from '@/core/ChatService.js';
import type { DriveFilesRepository, MiUser } from '@/models/_.js';
import type { Config } from '@/config.js';
export const meta = {
tags: ['chat'],
@ -21,9 +22,11 @@ export const meta = {
kind: 'write:chat',
// Up to 10 message burst, then 2/second
limit: {
duration: ms('1hour'),
max: 500,
type: 'bucket',
size: 10,
dripRate: 500,
},
res: {
@ -50,13 +53,19 @@ export const meta = {
code: 'CONTENT_REQUIRED',
id: '340517b7-6d04-42c0-bac1-37ee804e3594',
},
maxLength: {
message: 'You tried posting a message which is too long.',
code: 'MAX_LENGTH',
id: '3ac74a84-8fd5-4bb0-870f-01804f82ce16',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
text: { type: 'string', nullable: true, maxLength: 2000 },
text: { type: 'string', nullable: true, minLength: 1 },
fileId: { type: 'string', format: 'misskey:id' },
toRoomId: { type: 'string', format: 'misskey:id' },
},
@ -69,12 +78,19 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
@Inject(DI.driveFilesRepository)
private driveFilesRepository: DriveFilesRepository,
@Inject(DI.config)
private config: Config,
private getterService: GetterService,
private chatService: ChatService,
) {
super(meta, paramDef, async (ps, me) => {
await this.chatService.checkChatAvailability(me.id, 'write');
if (ps.text && ps.text.length > this.config.maxNoteLength) {
throw new ApiError(meta.errors.maxLength);
}
const room = await this.chatService.findRoomById(ps.toRoomId);
if (room == null) {
throw new ApiError(meta.errors.noSuchRoom);

View file

@ -11,6 +11,7 @@ import { DI } from '@/di-symbols.js';
import { ApiError } from '@/server/api/error.js';
import { ChatService } from '@/core/ChatService.js';
import type { DriveFilesRepository, MiUser } from '@/models/_.js';
import type { Config } from '@/config.js';
export const meta = {
tags: ['chat'],
@ -21,9 +22,11 @@ export const meta = {
kind: 'write:chat',
// Up to 10 message burst, then 2/second
limit: {
duration: ms('1hour'),
max: 500,
type: 'bucket',
size: 10,
dripRate: 500,
},
res: {
@ -62,13 +65,19 @@ export const meta = {
code: 'YOU_HAVE_BEEN_BLOCKED',
id: 'c15a5199-7422-4968-941a-2a462c478f7d',
},
maxLength: {
message: 'You tried posting a message which is too long.',
code: 'MAX_LENGTH',
id: '3ac74a84-8fd5-4bb0-870f-01804f82ce16',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
text: { type: 'string', nullable: true, maxLength: 2000 },
text: { type: 'string', nullable: true, minLength: 1 },
fileId: { type: 'string', format: 'misskey:id' },
toUserId: { type: 'string', format: 'misskey:id' },
},
@ -81,12 +90,19 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
@Inject(DI.driveFilesRepository)
private driveFilesRepository: DriveFilesRepository,
@Inject(DI.config)
private config: Config,
private getterService: GetterService,
private chatService: ChatService,
) {
super(meta, paramDef, async (ps, me) => {
await this.chatService.checkChatAvailability(me.id, 'write');
if (ps.text && ps.text.length > this.config.maxNoteLength) {
throw new ApiError(meta.errors.maxLength);
}
let file = null;
if (ps.fileId != null) {
file = await this.driveFilesRepository.findOneBy({

View file

@ -7,12 +7,22 @@ import { Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js';
import { GetterService } from '@/server/api/GetterService.js';
import { CacheService } from '@/core/CacheService.js';
import { ApiError } from '@/server/api/error.js';
export const meta = {
tags: ['federation'],
requireCredential: false,
errors: {
noSuchUser: {
message: 'No such user.',
code: 'NO_SUCH_USER',
id: '558ea170-f653-4700-94d0-5a818371d0df',
},
},
// Up to 10 calls, then 4 / second.
// This allows for reliable automation.
limit: {
@ -35,9 +45,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
constructor(
private getterService: GetterService,
private apPersonService: ApPersonService,
private readonly cacheService: CacheService,
) {
super(meta, paramDef, async (ps) => {
const user = await this.getterService.getRemoteUser(ps.userId);
const user = await this.cacheService.findRemoteUserById(ps.userId);
if (!user) {
throw new ApiError(meta.errors.noSuchUser);
}
await this.apPersonService.updatePerson(user.uri!);
});
}

View file

@ -23,7 +23,7 @@ export const meta = {
items: {
type: 'object',
optional: false, nullable: false,
ref: 'UserDetailed',
ref: 'User',
},
},
@ -43,6 +43,11 @@ export const paramDef = {
state: { type: 'string', enum: ['all', 'alive'], default: 'all' },
origin: { type: 'string', enum: ['combined', 'local', 'remote'], default: 'local' },
trending: { type: 'boolean', default: false },
detail: {
type: 'boolean',
nullable: false,
default: true,
},
},
required: ['tag', 'sort'],
} as const;
@ -96,7 +101,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.map(([u]) => u);
}
return await this.userEntityService.packMany(users, me, { schema: 'UserDetailed' });
return await this.userEntityService.packMany(users, me, { schema: ps.detail ? 'UserDetailed' : 'UserLite' });
});
}
}

View file

@ -4,7 +4,6 @@
*/
import { Inject, Injectable } from '@nestjs/common';
import ms from 'ms';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { QueueService } from '@/core/QueueService.js';
import type { AntennasRepository, DriveFilesRepository, UsersRepository, MiAntenna as _Antenna } from '@/models/_.js';
@ -19,9 +18,11 @@ export const meta = {
requiredRolePolicy: 'canImportAntennas',
prohibitMoved: true,
// 1 per minute
limit: {
duration: ms('1hour'),
max: 1,
type: 'bucket',
size: 1,
dripRate: 1000 * 60,
},
errors: {
noSuchFile: {
@ -82,7 +83,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
if (currentAntennasCount + antennas.length >= (await this.roleService.getUserPolicies(me.id)).antennaLimit) {
throw new ApiError(meta.errors.tooManyAntennas);
}
this.queueService.createImportAntennasJob(me, antennas);
await this.queueService.createImportAntennasJob(me, antennas, file.id);
});
}
}

View file

@ -4,7 +4,6 @@
*/
import { Inject, Injectable } from '@nestjs/common';
import ms from 'ms';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { QueueService } from '@/core/QueueService.js';
import { AccountMoveService } from '@/core/AccountMoveService.js';
@ -18,9 +17,11 @@ export const meta = {
requiredRolePolicy: 'canImportBlocking',
prohibitMoved: true,
// 1 per minute
limit: {
duration: ms('1hour'),
max: 1,
type: 'bucket',
size: 1,
dripRate: 1000 * 60,
},
errors: {

View file

@ -17,9 +17,12 @@ export const meta = {
requireCredential: true,
requiredRolePolicy: 'canImportFollowing',
prohibitMoved: true,
// 1 per minute
limit: {
duration: ms('1hour'),
max: 1,
type: 'bucket',
size: 1,
dripRate: 1000 * 60,
},
errors: {

View file

@ -4,7 +4,6 @@
*/
import { Inject, Injectable } from '@nestjs/common';
import ms from 'ms';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { QueueService } from '@/core/QueueService.js';
import { AccountMoveService } from '@/core/AccountMoveService.js';
@ -18,9 +17,11 @@ export const meta = {
requiredRolePolicy: 'canImportMuting',
prohibitMoved: true,
// 1 per minute
limit: {
duration: ms('1hour'),
max: 1,
type: 'bucket',
size: 1,
dripRate: 1000 * 60,
},
errors: {

View file

@ -4,7 +4,6 @@
*/
import { Inject, Injectable } from '@nestjs/common';
import ms from 'ms';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { QueueService } from '@/core/QueueService.js';
import type { DriveFilesRepository } from '@/models/_.js';
@ -16,9 +15,12 @@ export const meta = {
secure: true,
requireCredential: true,
prohibitMoved: true,
// 1 per minute
limit: {
duration: ms('1hour'),
max: 2,
type: 'bucket',
size: 1,
dripRate: 1000 * 60,
},
errors: {

View file

@ -4,7 +4,6 @@
*/
import { Inject, Injectable } from '@nestjs/common';
import ms from 'ms';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { QueueService } from '@/core/QueueService.js';
import { AccountMoveService } from '@/core/AccountMoveService.js';
@ -17,9 +16,12 @@ export const meta = {
requireCredential: true,
requiredRolePolicy: 'canImportUserLists',
prohibitMoved: true,
// 1 per minute
limit: {
duration: ms('1hour'),
max: 1,
type: 'bucket',
size: 1,
dripRate: 1000 * 60,
},
errors: {

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
// 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++) {
const notification = notifications[i];
@ -135,7 +135,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (prevReaction.type !== 'reaction:grouped') {
prevReaction = groupedNotifications[reactionIdx] = {
type: 'reaction:grouped',
id: prevReaction.id, // this will be the newest id in this group
id: '',
createdAt: prevReaction.createdAt,
noteId: prevReaction.noteId!,
reactions: [{
@ -149,6 +149,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
userId: notification.notifierId!,
reaction: notification.reaction!,
});
prevReaction.id = notification.id; // this will be the *oldest* id in this group (newest if sinceId && !untilId)
continue;
}
@ -167,7 +168,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (prevRenote.type !== 'renote:grouped') {
prevRenote = groupedNotifications[renoteIdx] = {
type: 'renote:grouped',
id: prevRenote.id, // this will be the newest id in this group
id: '',
createdAt: prevRenote.createdAt,
noteId: prevRenote.noteId!,
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
(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;
}
@ -182,10 +184,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
groupedNotifications.push(notification);
}
// sort the groups by their id, newest first
// sort the groups by their id
groupedNotifications.sort(
(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);
});

View file

@ -615,11 +615,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
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, {
...profileUpdates,
verifiedLinks: verified_links,
verifiedLinks,
});
const iObj = await this.userEntityService.pack(user.id, user, {

View file

@ -27,10 +27,11 @@ export const meta = {
prohibitMoved: true,
// Up to 10 post burst, then 4/second
limit: {
duration: ms('1hour'),
max: 300,
minInterval: ms('1sec'),
type: 'bucket',
size: 10,
dripRate: 250,
},
kind: 'write:notes',

View file

@ -1,4 +1,8 @@
import ms from 'ms';
/*
* SPDX-FileCopyrightText: marie and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { In } from 'typeorm';
import { Inject, Injectable } from '@nestjs/common';
import type { MiUser } from '@/models/User.js';
@ -22,9 +26,11 @@ export const meta = {
prohibitMoved: true,
// Up to 10 post burst, then 2/second
limit: {
duration: ms('1hour'),
max: 300,
type: 'bucket',
size: 10,
dripRate: 500,
},
kind: 'write:notes',

View file

@ -23,7 +23,7 @@ export const meta = {
items: {
type: 'object',
optional: false, nullable: false,
ref: 'UserDetailed',
ref: 'User',
},
},
@ -36,7 +36,13 @@ export const meta = {
export const paramDef = {
type: 'object',
properties: {},
properties: {
detail: {
type: 'boolean',
nullable: false,
default: true,
},
},
required: [],
} as const;
@ -57,7 +63,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
host: acct.host ?? IsNull(),
})));
return await this.userEntityService.packMany(users.filter(x => x != null), me, { schema: 'UserDetailed' });
return await this.userEntityService.packMany(users.filter(x => x != null), me, { schema: ps.detail ? 'UserDetailed' : 'UserLite' });
});
}
}

View file

@ -37,7 +37,7 @@ export const meta = {
},
user: {
type: 'object',
ref: 'UserDetailed',
ref: 'User',
},
},
required: ['id', 'user'],
@ -58,6 +58,11 @@ export const paramDef = {
sinceId: { type: 'string', format: 'misskey:id' },
untilId: { type: 'string', format: 'misskey:id' },
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
detail: {
type: 'boolean',
nullable: false,
default: true,
},
},
required: ['roleId'],
} as const;
@ -99,11 +104,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.getMany();
const _users = assigns.map(({ user, userId }) => user ?? userId);
const _userMap = await this.userEntityService.packMany(_users, me, { schema: 'UserDetailed' })
const _userMap = await this.userEntityService.packMany(_users, me, { schema: ps.detail ? 'UserDetailed' : 'UserLite' })
.then(users => new Map(users.map(u => [u.id, u])));
return await Promise.all(assigns.map(async assign => ({
id: assign.id,
user: _userMap.get(assign.userId) ?? await this.userEntityService.pack(assign.user!, me, { schema: 'UserDetailed' }),
user: _userMap.get(assign.userId) ?? await this.userEntityService.pack(assign.user!, me, { schema: ps.detail ? 'UserDetailed' : 'UserLite' }),
})));
});
}

View file

@ -24,7 +24,7 @@ export const meta = {
items: {
type: 'object',
optional: false, nullable: false,
ref: 'UserDetailed',
ref: 'User',
},
},
@ -50,6 +50,11 @@ export const paramDef = {
default: null,
description: 'The local host is represented with `null`.',
},
detail: {
type: 'boolean',
nullable: false,
default: true,
},
},
required: [],
} as const;
@ -111,7 +116,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.filter(([,p]) => p.canTrend)
.map(([u]) => u);
return await this.userEntityService.packMany(users, me, { schema: 'UserDetailed' });
return await this.userEntityService.packMany(users, me, { schema: ps.detail ? 'UserDetailed' : 'UserLite' });
});
}

View file

@ -30,7 +30,7 @@ export const meta = {
user: {
type: 'object',
optional: false, nullable: false,
ref: 'UserDetailed',
ref: 'User',
},
weight: {
type: 'number',
@ -60,6 +60,11 @@ export const paramDef = {
properties: {
userId: { type: 'string', format: 'misskey:id' },
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
detail: {
type: 'boolean',
nullable: false,
default: true,
},
},
required: ['userId'],
} as const;
@ -127,10 +132,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
const topRepliedUserIds = repliedUsersSorted.slice(0, ps.limit);
// Make replies object (includes weights)
const _userMap = await this.userEntityService.packMany(topRepliedUserIds, me, { schema: 'UserDetailed' })
const _userMap = await this.userEntityService.packMany(topRepliedUserIds, me, { schema: ps.detail ? 'UserDetailed' : 'UserLite' })
.then(users => new Map(users.map(u => [u.id, u])));
const repliesObj = await Promise.all(topRepliedUserIds.map(async (userId) => ({
user: _userMap.get(userId) ?? await this.userEntityService.pack(userId, me, { schema: 'UserDetailed' }),
user: _userMap.get(userId) ?? await this.userEntityService.pack(userId, me, { schema: ps.detail ? 'UserDetailed' : 'UserLite' }),
weight: repliedUsers[userId] / peak,
})));

View file

@ -26,7 +26,7 @@ export const meta = {
items: {
type: 'object',
optional: false, nullable: false,
ref: 'UserDetailed',
ref: 'User',
},
},
@ -42,6 +42,11 @@ export const paramDef = {
properties: {
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
offset: { type: 'integer', default: 0 },
detail: {
type: 'boolean',
nullable: false,
default: true,
},
},
required: [],
} as const;
@ -83,7 +88,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
const users = await query.limit(ps.limit).offset(ps.offset).getMany();
return await this.userEntityService.packMany(users, me, { schema: 'UserDetailed' });
return await this.userEntityService.packMany(users, me, { schema: ps.detail ? 'UserDetailed' : 'UserLite' });
});
}
}

View file

@ -9,6 +9,7 @@ import { GetterService } from '@/server/api/GetterService.js';
import { RoleService } from '@/core/RoleService.js';
import { AbuseReportService } from '@/core/AbuseReportService.js';
import { ApiError } from '../../error.js';
import { CacheService } from '@/core/CacheService.js';
export const meta = {
tags: ['users'],
@ -60,13 +61,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private getterService: GetterService,
private roleService: RoleService,
private abuseReportService: AbuseReportService,
private readonly cacheService: CacheService,
) {
super(meta, paramDef, async (ps, me) => {
// Lookup user
const targetUser = await this.getterService.getUser(ps.userId).catch(err => {
if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser);
throw err;
});
const targetUser = await this.cacheService.findOptionalUserById(ps.userId);
if (!targetUser) {
throw new ApiError(meta.errors.noSuchUser);
}
if (targetUser.id === me.id) {
throw new ApiError(meta.errors.cannotReportYourself);

View file

@ -30,13 +30,13 @@ export const meta = {
oneOf: [
{
type: 'object',
ref: 'UserDetailed',
ref: 'User',
},
{
type: 'array',
items: {
type: 'object',
ref: 'UserDetailed',
ref: 'User',
},
},
],
@ -79,6 +79,11 @@ export const paramDef = {
nullable: true,
description: 'The local host is represented with `null`.',
},
detail: {
type: 'boolean',
nullable: false,
default: true,
},
},
anyOf: [
{ required: ['userId'] },
@ -125,7 +130,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (user != null) _users.push(user);
}
const _userMap = await this.userEntityService.packMany(_users, me, { schema: 'UserDetailed' })
const _userMap = await this.userEntityService.packMany(_users, me, { schema: ps.detail ? 'UserDetailed' : 'UserLite' })
.then(users => new Map(users.map(u => [u.id, u])));
return _users.map(u => _userMap.get(u.id)!);
} else {
@ -156,7 +161,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
}
return await this.userEntityService.pack(user, me, {
schema: 'UserDetailed',
schema: ps.detail ? 'UserDetailed' : 'UserLite',
});
}
});

View file

@ -59,7 +59,7 @@ export class MastodonDataService {
if (typeof(relations.reply) === 'object') {
if (relations.reply.reply) query.leftJoinAndSelect('reply.reply', 'replyReply');
if (relations.reply.renote) query.leftJoinAndSelect('reply.renote', 'replyRenote');
if (relations.reply.user) query.innerJoinAndSelect('reply.user', 'replyUser');
if (relations.reply.user) query.leftJoinAndSelect('reply.user', 'replyUser');
if (relations.reply.channel) query.leftJoinAndSelect('reply.channel', 'replyChannel');
}
}
@ -68,7 +68,7 @@ export class MastodonDataService {
if (typeof(relations.renote) === 'object') {
if (relations.renote.reply) query.leftJoinAndSelect('renote.reply', 'renoteReply');
if (relations.renote.renote) query.leftJoinAndSelect('renote.renote', 'renoteRenote');
if (relations.renote.user) query.innerJoinAndSelect('renote.user', 'renoteUser');
if (relations.renote.user) query.leftJoinAndSelect('renote.user', 'renoteUser');
if (relations.renote.channel) query.leftJoinAndSelect('renote.channel', 'renoteChannel');
}
}

View file

@ -41,7 +41,8 @@ export class ApiInstanceMastodon {
const response: MastodonEntity.Instance = {
uri: this.config.host,
title: this.meta.name || 'Sharkey',
description: this.meta.description || 'This is a vanilla Sharkey Instance. It doesn\'t seem to have a description.',
shortDescription: this.meta.description || 'This is a vanilla Sharkey Instance. It doesn\'t seem to have a description.',
description: this.meta.about || 'This is a vanilla Sharkey Instance.',
email: instance.email || '',
version: `3.0.0 (compatible; Sharkey ${this.config.version}; like Akkoma)`,
urls: instance.urls,

View file

@ -33,6 +33,7 @@ import { getIpHash } from '@/misc/get-ip-hash.js';
import { isRetryableError } from '@/misc/is-retryable-error.js';
import * as Acct from '@/misc/acct.js';
import { isNote } from '@/core/activitypub/type.js';
import { renderInlineError } from '@/misc/render-inline-error.js';
import type { FastifyRequest, FastifyReply } from 'fastify';
export type LocalSummalyResult = SummalyResult & {
@ -260,7 +261,7 @@ export class UrlPreviewService {
return reply.code(200).send(summary);
} catch (err) {
this.logger.warn(`Failed to get preview of ${url} for ${lang}: ${err}`);
this.logger.warn(`Failed to get preview of ${url} for ${lang}: ${renderInlineError(err)}`);
reply.header('Cache-Control', 'max-age=3600');
return reply.code(422).send({

View file

@ -129,7 +129,12 @@
const fontSize = localStorage.getItem('fontSize');
if (fontSize) {
document.documentElement.classList.add('f-' + fontSize);
if (fontSize === "custom") {
const customFontSize = localStorage.getItem('customFontSize');
document.documentElement.style.setProperty('font-size', `${customFontSize}px`);
} else {
document.documentElement.classList.add('f-' + fontSize);
}
}
const cornerRadius = localStorage.getItem('cornerRadius');

View file

@ -21,6 +21,7 @@ block og
meta(property='og:url' content= url)
if videos.length
each video in videos
meta(property='og:video' content= video.url)
meta(property='og:video:url' content= video.url)
meta(property='og:video:secure_url' content= video.url)
meta(property='og:video:type' content= video.type)

View file

@ -27,6 +27,7 @@ import { CacheService } from '@/core/CacheService.js';
import { IdService } from '@/core/IdService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
import { RoleService } from '@/core/RoleService.js';
import { secureRndstr } from '@/misc/secure-rndstr.js';
import type { TestingModule } from '@nestjs/testing';
import type { MockFunctionMetadata } from 'jest-mock';
@ -41,6 +42,7 @@ describe('AnnouncementService', () => {
let announcementReadsRepository: AnnouncementReadsRepository;
let globalEventService: jest.Mocked<GlobalEventService>;
let moderationLogService: jest.Mocked<ModerationLogService>;
let roleService: jest.Mocked<RoleService>;
function createUser(data: Partial<MiUser> = {}) {
const un = secureRndstr(16);
@ -77,6 +79,7 @@ describe('AnnouncementService', () => {
InternalEventService,
GlobalEventService,
ModerationLogService,
RoleService,
],
})
.useMocker((token) => {
@ -93,6 +96,9 @@ describe('AnnouncementService', () => {
.overrideProvider(ModerationLogService).useValue({
log: jest.fn(),
})
.overrideProvider(RoleService).useValue({
getUserRoles: jest.fn((_) => []),
})
.overrideProvider(InternalEventService).useClass(FakeInternalEventService)
.overrideProvider(CacheService).useClass(NoOpCacheService)
.compile();
@ -105,6 +111,7 @@ describe('AnnouncementService', () => {
announcementReadsRepository = app.get<AnnouncementReadsRepository>(DI.announcementReadsRepository);
globalEventService = app.get<GlobalEventService>(GlobalEventService) as jest.Mocked<GlobalEventService>;
moderationLogService = app.get<ModerationLogService>(ModerationLogService) as jest.Mocked<ModerationLogService>;
roleService = app.get<RoleService>(RoleService) as jest.Mocked<RoleService>;
});
afterEach(async () => {

View file

@ -16,6 +16,7 @@ import { HttpRequestService } from '@/core/HttpRequestService.js';
import { LoggerService } from '@/core/LoggerService.js';
import { UtilityService } from '@/core/UtilityService.js';
import { IdService } from '@/core/IdService.js';
import { EnvService } from '@/core/EnvService.js';
import { DI } from '@/di-symbols.js';
function mockRedis() {
@ -46,6 +47,7 @@ describe('FetchInstanceMetadataService', () => {
LoggerService,
UtilityService,
IdService,
EnvService,
],
})
.useMocker((token) => {

View file

@ -63,6 +63,12 @@ describe('MfmService', () => {
const output = '<p><ruby><span><i>some</i> text</span><rp>(</rp><rt>ignore me</rt><rp>)</rp></ruby></p>';
assert.equal(mfmService.toHtml(mfm.parse(input)), output);
});
test('inline', () => {
const input = 'https://example.com';
const output = '<a href="https://example.com">https://example.com</a>';
assert.equal(mfmService.toHtml(mfm.parse(input), [], [], true), output);
});
});
describe('toMastoApiHtml', () => {

View file

@ -1,18 +1,30 @@
import * as assert from 'assert';
import { Test } from '@nestjs/testing';
import { jest } from '@jest/globals';
import { CoreModule } from '@/core/CoreModule.js';
import { UtilityService } from '@/core/UtilityService.js';
import { MetaService } from '@/core/MetaService.js';
import { GlobalModule } from '@/GlobalModule.js';
import { MiMeta } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
import type { SoftwareSuspension } from '@/models/Meta.js';
import type { MiInstance } from '@/models/Instance.js';
describe('UtilityService', () => {
let utilityService: UtilityService;
let meta: jest.Mocked<MiMeta>;
beforeAll(async () => {
const app = await Test.createTestingModule({
imports: [GlobalModule, CoreModule],
}).compile();
providers: [MetaService],
})
.overrideProvider(MetaService).useValue({ fetch: jest.fn() })
.compile();
utilityService = app.get<UtilityService>(UtilityService);
meta = app.get<MiMeta>(DI.meta) as jest.Mocked<MiMeta>;
});
describe('punyHost', () => {
@ -61,4 +73,99 @@ describe('UtilityService', () => {
assert.equal(utilityService.toPuny('www.foo.com:3000'), 'www.foo.com:3000');
});
});
describe('isDeliverSuspendedSoftware', () => {
function checkThis(rules: SoftwareSuspension[], target: Pick<MiInstance, 'softwareName' | 'softwareVersion'>, expect: boolean, message: string) {
meta.deliverSuspendedSoftware = rules;
const match = !!utilityService.isDeliverSuspendedSoftware(target);
assert.equal(match, expect, message);
}
test('equality', () => {
checkThis(
[{ software: 'Test', versionRange: '1.2.3' }],
{ softwareName: 'Test', softwareVersion: '1.2.3' },
true, 'straight match',
);
});
test('normal version', () => {
checkThis(
[{ software: 'Test', versionRange: '1.2.3-pre' }],
{ softwareName: 'Test', softwareVersion: '1.2.3-pre+g1234' },
true, 'straight match',
);
checkThis(
[{ software: 'Test', versionRange: '1.2.3' }],
{ softwareName: 'Test', softwareVersion: '1.2.3-pre+g1234' },
false, 'pre-release',
);
checkThis(
[{ software: 'Test', versionRange: '>= 1.0.0 < 2.0.0' }],
{ softwareName: 'Test', softwareVersion: '1.2.3-pre+g1234' },
true, 'range',
);
checkThis(
[{ software: 'Test', versionRange: '*' }],
{ softwareName: 'Test', softwareVersion: '1.2.3-pre+g1234' },
true, 'asterisk',
);
checkThis(
[{ software: 'Test', versionRange: '/.*/' }],
{ softwareName: 'Test', softwareVersion: '1.2.3-pre+g1234' },
true, 'regexp matching anything',
);
checkThis(
[{ software: 'Test', versionRange: '/-pre\\b/' }],
{ softwareName: 'Test', softwareVersion: '1.2.3-pre+g1234' },
true, 'regexp matching the version',
);
});
test('no version', () => {
checkThis(
[{ software: 'Test', versionRange: '1.2.3' }],
{ softwareName: 'Test', softwareVersion: null },
false, 'semver',
);
checkThis(
[{ software: 'Test', versionRange: '*' }],
{ softwareName: 'Test', softwareVersion: null },
true, 'asterisk',
);
checkThis(
[{ software: 'Test', versionRange: '/^$/' }],
{ softwareName: 'Test', softwareVersion: null },
true, 'regexp matching empty string',
);
checkThis(
[{ software: 'Test', versionRange: '/.*/' }],
{ softwareName: 'Test', softwareVersion: null },
true, 'regexp matching anything',
);
});
test('bad version', () => {
checkThis(
[{ software: 'Test', versionRange: '1.2.3' }],
{ softwareName: 'Test', softwareVersion: '1-2-3' },
false, "semver can't parse softwareVersion",
);
checkThis(
[{ software: 'Test', versionRange: '*' }],
{ softwareName: 'Test', softwareVersion: '1-2-3' },
true, 'asterisk',
);
checkThis(
[{ software: 'Test', versionRange: '/.*/' }],
{ softwareName: 'Test', softwareVersion: '1-2-3' },
true, 'regexp matching anything',
);
checkThis(
[{ software: 'Test', versionRange: '/^1-2-/' }],
{ softwareName: 'Test', softwareVersion: '1-2-3' },
true, 'regexp matching the version',
);
});
});
});

View file

@ -3,11 +3,11 @@
* 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 { PrivateNetwork } from '@/config.js';
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';
describe(HttpRequestService, () => {
@ -21,38 +21,85 @@ describe(HttpRequestService, () => {
]);
});
describe('isPrivateIp', () => {
describe(isAllowedPrivateIp, () => {
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();
});
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();
});
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();
});
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();
});
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();
});
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();
});
});
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', () => {
let fakeSocket: Socket;
let fakeSocketMutable: {

View file

@ -3,30 +3,52 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { UtilityService } from '@/core/UtilityService.js';
import type { IObject } from '@/core/activitypub/type.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 { UtilityService } from '@/core/UtilityService.js';
describe(ApUtilityService, () => {
let serviceUnderTest: ApUtilityService;
let env: Record<string, string>;
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 = {};
const envService = {
env,
} 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', () => {
@ -351,4 +373,102 @@ describe(ApUtilityService, () => {
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,9 +303,12 @@ describe(SkRateLimiterService, () => {
const i1 = await serviceUnderTest().limit(limit, actor); // 1 + 1 = 2
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(i2.blocked).toBeTruthy();
expect(i3.blocked).toBeFalsy();
});
it('should set counter expiration', async () => {
@ -563,11 +566,15 @@ describe(SkRateLimiterService, () => {
mockDefaultUserPolicies.rateLimitFactor = 0.5;
limitCounter = 1;
limitTimestamp = 0;
const i1 = await serviceUnderTest().limit(limit, actor);
const i2 = await serviceUnderTest().limit(limit, actor);
mockTimeService.now += 500;
const i3 = await serviceUnderTest().limit(limit, actor);
const info = await serviceUnderTest().limit(limit, actor);
expect(info.blocked).toBeFalsy();
expect(i1.blocked).toBeFalsy();
expect(i2.blocked).toBeTruthy();
expect(i3.blocked).toBeFalsy();
});
it('should set counter expiration', async () => {
@ -738,12 +745,17 @@ describe(SkRateLimiterService, () => {
it('should scale limit by factor', async () => {
mockDefaultUserPolicies.rateLimitFactor = 0.5;
limitCounter = 10;
limitCounter = 1;
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 () => {
@ -932,13 +944,17 @@ describe(SkRateLimiterService, () => {
it('should scale limit and interval by factor', async () => {
mockDefaultUserPolicies.rateLimitFactor = 0.5;
limitCounter = 5;
limitCounter = 19;
limitTimestamp = 0;
const i1 = await serviceUnderTest().limit(limit, actor);
const i2 = await serviceUnderTest().limit(limit, actor);
mockTimeService.now += 500;
const i3 = await serviceUnderTest().limit(limit, actor);
const info = await serviceUnderTest().limit(limit, actor);
expect(info.blocked).toBeFalsy();
expect(i1.blocked).toBeFalsy();
expect(i2.blocked).toBeTruthy();
expect(i3.blocked).toBeFalsy();
});
it('should set counter expiration', async () => {

View file

@ -73,6 +73,14 @@
codeBoolean: '#c59eff',
deckBg: '#000',
htmlThemeColor: '@bg',
modPlayerDefault: '#ffffff',
modPlayerQuarter: '#ffff00',
modPlayerInstr: '#80e0ff',
modPlayerVolume: '#80ff80',
modPlayerFx: '#ff80e0',
modPlayerOperant: '#ffe080',
modPlayerShadow: 'rgba(0, 0, 0, 0.5)',
modPlayerSliderKnob: ':darken<10<@indicator',
},
codeHighlighter: {

View file

@ -73,6 +73,14 @@
codeBoolean: '#62b70c',
deckBg: ':darken<3<@bg',
htmlThemeColor: '@bg',
modPlayerDefault: '#ffffff',
modPlayerQuarter: '#ffff00',
modPlayerInstr: '#80e0ff',
modPlayerVolume: '#80ff80',
modPlayerFx: '#ff80e0',
modPlayerOperant: '#ffe080',
modPlayerShadow: 'rgba(0, 0, 0, 0.5)',
modPlayerSliderKnob: ':darken<10<@indicator',
},
codeHighlighter: {

View file

@ -142,7 +142,7 @@ watch(() => props.lang, (to) => {
margin: 0;
border-radius: var(--MI-radius-sm);
border: none;
min-height: 130px;
min-height: 9.29em;
pointer-events: none;
min-width: calc(100% - 24px);
height: 100%;

View file

@ -218,13 +218,15 @@ parseAndMergeCategories('', customEmojiFolderRoot);
watch(q, () => {
if (emojisEl.value) emojisEl.value.scrollTop = 0;
if (q.value === '') {
const query = q.value.trim();
if (query === '') {
searchResultCustom.value = [];
searchResultUnicode.value = [];
return;
}
const newQ = q.value.replace(/:/g, '').normalize('NFC').toLowerCase();
const newQ = query.replace(/:/g, '').normalize('NFC').toLowerCase();
const searchCustom = () => {
const max = 100;
@ -442,7 +444,7 @@ function input(): void {
// Using custom input event instead of v-model to respond immediately on
// Android, where composition happens on all languages
// (v-model does not update during composition)
q.value = searchEl.value?.value.trim() ?? '';
q.value = searchEl.value?.value ?? '';
}
function paste(event: ClipboardEvent): void {
@ -467,7 +469,7 @@ function onKeydown(ev: KeyboardEvent) {
}
function done(query?: string): boolean | void {
if (query == null) query = q.value;
if (query == null) query = q.value.trim();
if (query == null || typeof query !== 'string') return;
const q2 = query.replace(/:/g, '');

View file

@ -18,7 +18,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:moveClass=" $style.transition_x_move"
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"/>
<XNotification v-else :class="$style.item" :notification="notification" :withTime="true" :full="true" :data-scroll-anchor="notification.id"/>
</div>
@ -61,6 +61,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) {
const isMuted = props.excludeTypes ? props.excludeTypes.includes(notification.type) : false;
if (isMuted || window.document.visibilityState === 'visible') {

View file

@ -1497,7 +1497,7 @@ defineExpose({
max-width: 100%;
min-width: 100%;
width: 100%;
min-height: 90px;
min-height: 5.85em;
height: 100%;
}

View file

@ -100,7 +100,7 @@ async function fetchRoles() {
async function addRole() {
const items = roles.value
.filter(r => r.isPublic)
.filter(r => publicOnly.value ? r.isPublic : true)
.filter(r => !selectedRoleIds.value.includes(r.id))
.map(r => ({ text: r.name, value: r }));

View file

@ -174,7 +174,7 @@ onUnmounted(() => {
width: 100%;
min-width: 100%;
max-width: 100%;
min-height: 130px;
min-height: 9.29em;
margin: 0;
padding: 12px;
font: inherit;
@ -211,7 +211,7 @@ onUnmounted(() => {
.tall {
> .textarea {
min-height: 200px;
min-height: 14.29em;
}
}

Some files were not shown because too many files have changed in this diff Show more