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