diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 256cb08fe9..201fceccc1 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -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 diff --git a/locales/index.d.ts b/locales/index.d.ts index 387f92f456..344c4c51e6 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -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; diff --git a/packages/backend/migration/1752352800438-announcement-forRoles.js b/packages/backend/migration/1752352800438-announcement-forRoles.js new file mode 100644 index 0000000000..a3fcb449c5 --- /dev/null +++ b/packages/backend/migration/1752352800438-announcement-forRoles.js @@ -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"`); + } +} diff --git a/packages/backend/migration/1752377661219-UserPending-ip.js b/packages/backend/migration/1752377661219-UserPending-ip.js new file mode 100644 index 0000000000..12e5d6cc3e --- /dev/null +++ b/packages/backend/migration/1752377661219-UserPending-ip.js @@ -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"`); + } +} diff --git a/packages/backend/migration/1752607599852-split-descriptions.js b/packages/backend/migration/1752607599852-split-descriptions.js new file mode 100644 index 0000000000..7c00db68a4 --- /dev/null +++ b/packages/backend/migration/1752607599852-split-descriptions.js @@ -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"`); + } +} diff --git a/packages/backend/migration/1753574755478-change-chat_message-text-type.js b/packages/backend/migration/1753574755478-change-chat_message-text-type.js new file mode 100644 index 0000000000..d286f8ff3a --- /dev/null +++ b/packages/backend/migration/1753574755478-change-chat_message-text-type.js @@ -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)`); + } +} diff --git a/packages/backend/migration/1754754816000-metaRulesLength.js b/packages/backend/migration/1754754816000-metaRulesLength.js new file mode 100644 index 0000000000..2f010789d2 --- /dev/null +++ b/packages/backend/migration/1754754816000-metaRulesLength.js @@ -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)[]`); + } +} diff --git a/packages/backend/src/boot/entry.ts b/packages/backend/src/boot/entry.ts index afb48e526c..bbe6a57383 100644 --- a/packages/backend/src/boot/entry.ts +++ b/packages/backend/src/boot/entry.ts @@ -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 diff --git a/packages/backend/src/boot/master.ts b/packages/backend/src/boot/master.ts index a90228eabc..607e8de340 100644 --- a/packages/backend/src/boot/master.ts +++ b/packages/backend/src/boot/master.ts @@ -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, }); diff --git a/packages/backend/src/boot/worker.ts b/packages/backend/src/boot/worker.ts index 494e7c8c10..8cf3cadd22 100644 --- a/packages/backend/src/boot/worker.ts +++ b/packages/backend/src/boot/worker.ts @@ -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, }); diff --git a/packages/backend/src/config.ts b/packages/backend/src/config.ts index 9d0736d94e..102cb69dcb 100644 --- a/packages/backend/src/config.ts +++ b/packages/backend/src/config.ts @@ -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(/:$/, ''); diff --git a/packages/backend/src/core/AbuseReportService.ts b/packages/backend/src/core/AbuseReportService.ts index bccb9f86f6..8b3c596f50 100644 --- a/packages/backend/src/core/AbuseReportService.ts +++ b/packages/backend/src/core/AbuseReportService.ts @@ -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), - ]); + ])); } /** diff --git a/packages/backend/src/core/AnnouncementService.ts b/packages/backend/src/core/AnnouncementService.ts index 2eec484326..ddeea1eed6 100644 --- a/packages/backend/src/core/AnnouncementService.ts +++ b/packages/backend/src/core/AnnouncementService.ts @@ -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, diff --git a/packages/backend/src/core/CacheService.ts b/packages/backend/src/core/CacheService.ts index 7ba1aba1bc..a242f06142 100644 --- a/packages/backend/src/core/CacheService.ts +++ b/packages/backend/src/core/CacheService.ts @@ -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 { + 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 { return await this.userFollowStatsCache.fetch(userId, async () => { diff --git a/packages/backend/src/core/CaptchaService.ts b/packages/backend/src/core/CaptchaService.ts index c526a80aeb..020984a37f 100644 --- a/packages/backend/src/core/CaptchaService.ts +++ b/packages/backend/src/core/CaptchaService.ts @@ -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; }; diff --git a/packages/backend/src/core/ChatService.ts b/packages/backend/src/core/ChatService.ts index 9d294a80cb..62cf04e00e 100644 --- a/packages/backend/src/core/ChatService.ts +++ b/packages/backend/src/core/ChatService.ts @@ -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, }); diff --git a/packages/backend/src/core/DownloadService.ts b/packages/backend/src/core/DownloadService.ts index cb5bdb6cb7..d1819f40a3 100644 --- a/packages/backend/src/core/DownloadService.ts +++ b/packages/backend/src/core/DownloadService.ts @@ -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; diff --git a/packages/backend/src/core/DriveService.ts b/packages/backend/src/core/DriveService.ts index b9be4e3039..9de68c597b 100644 --- a/packages/backend/src/core/DriveService.ts +++ b/packages/backend/src/core/DriveService.ts @@ -154,8 +154,8 @@ export class DriveService { @bindThis private async save(file: MiDriveFile, path: string, name: string, info: FileInfo): Promise { 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, diff --git a/packages/backend/src/core/HttpRequestService.ts b/packages/backend/src/core/HttpRequestService.ts index 151097095d..bd72fefe4f 100644 --- a/packages/backend/src/core/HttpRequestService.ts +++ b/packages/backend/src/core/HttpRequestService.ts @@ -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 { + 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((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 { - 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 { 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, diff --git a/packages/backend/src/core/MfmService.ts b/packages/backend/src/core/MfmService.ts index 4f9f553e7e..0d80bfbdca 100644 --- a/packages/backend/src/core/MfmService.ts +++ b/packages/backend/src/core/MfmService.ts @@ -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(/^

/, '').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 diff --git a/packages/backend/src/core/QueueService.ts b/packages/backend/src/core/QueueService.ts index f4f069b64b..6cc6008567 100644 --- a/packages/backend/src/core/QueueService.ts +++ b/packages/backend/src/core/QueueService.ts @@ -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 diff --git a/packages/backend/src/core/UserSuspendService.ts b/packages/backend/src/core/UserSuspendService.ts index ddadab7022..5868ba6678 100644 --- a/packages/backend/src/core/UserSuspendService.ts +++ b/packages/backend/src/core/UserSuspendService.ts @@ -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 { // 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]); } } diff --git a/packages/backend/src/core/UtilityService.ts b/packages/backend/src/core/UtilityService.ts index 5de76e00a6..11606e3184 100644 --- a/packages/backend/src/core/UtilityService.ts +++ b/packages/backend/src/core/UtilityService.ts @@ -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): 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; } } } diff --git a/packages/backend/src/core/VideoProcessingService.ts b/packages/backend/src/core/VideoProcessingService.ts index 3e4fd6a4b0..8f4d6cc189 100644 --- a/packages/backend/src/core/VideoProcessingService.ts +++ b/packages/backend/src/core/VideoProcessingService.ts @@ -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 { diff --git a/packages/backend/src/core/WebhookTestService.ts b/packages/backend/src/core/WebhookTestService.ts index 0b1e8110e5..74a8b79a89 100644 --- a/packages/backend/src/core/WebhookTestService.ts +++ b/packages/backend/src/core/WebhookTestService.ts @@ -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, diff --git a/packages/backend/src/core/activitypub/ApRendererService.ts b/packages/backend/src/core/activitypub/ApRendererService.ts index 9f55be11ac..a13abc6369 100644 --- a/packages/backend/src/core/activitypub/ApRendererService.ts +++ b/packages/backend/src/core/activitypub/ApRendererService.ts @@ -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'], diff --git a/packages/backend/src/core/activitypub/ApRequestService.ts b/packages/backend/src/core/activitypub/ApRequestService.ts index e4db9b237c..7669ce9669 100644 --- a/packages/backend/src/core/activitypub/ApRequestService.ts +++ b/packages/backend/src/core/activitypub/ApRequestService.ts @@ -157,8 +157,6 @@ export class ApRequestService { @bindThis public async signedPost(user: { id: MiUser['id'] }, url: string, object: unknown, digest?: string): Promise { - 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 { - this.apUtilityService.assertApUrl(url); - const _followAlternate = followAlternate ?? true; const keypair = await this.userKeypairService.getUserKeypair(user.id); diff --git a/packages/backend/src/core/activitypub/ApUtilityService.ts b/packages/backend/src/core/activitypub/ApUtilityService.ts index 227dc3b9b3..26ea0cd632 100644 --- a/packages/backend/src/core/activitypub/ApUtilityService.ts +++ b/packages/backend/src/core/activitypub/ApUtilityService.ts @@ -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(obj: Partial>, key: Key, parentUri: string | URL, parentHost: string, keyPath = ''): obj is Partial> { + 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; } } diff --git a/packages/backend/src/core/activitypub/models/ApNoteService.ts b/packages/backend/src/core/activitypub/models/ApNoteService.ts index db26108fa2..32661fb6d9 100644 --- a/packages/backend/src/core/activitypub/models/ApNoteService.ts +++ b/packages/backend/src/core/activitypub/models/ApNoteService.ts @@ -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); diff --git a/packages/backend/src/core/activitypub/models/ApPersonService.ts b/packages/backend/src/core/activitypub/models/ApPersonService.ts index 1ca3a007c3..9d7b7c5074 100644 --- a/packages/backend/src/core/activitypub/models/ApPersonService.ts +++ b/packages/backend/src/core/activitypub/models/ApPersonService.ts @@ -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)}`); }); } diff --git a/packages/backend/src/core/activitypub/type.ts b/packages/backend/src/core/activitypub/type.ts index 5d4b2b01c5..21434f23e0 100644 --- a/packages/backend/src/core/activitypub/type.ts +++ b/packages/backend/src/core/activitypub/type.ts @@ -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; }; diff --git a/packages/backend/src/core/entities/MetaEntityService.ts b/packages/backend/src/core/entities/MetaEntityService.ts index 3e16266d7d..d7ab20db2c 100644 --- a/packages/backend/src/core/entities/MetaEntityService.ts +++ b/packages/backend/src/core/entities/MetaEntityService.ts @@ -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, diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index 638eaac16f..227814454d 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -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, diff --git a/packages/backend/src/misc/captcha-error.ts b/packages/backend/src/misc/captcha-error.ts new file mode 100644 index 0000000000..217018ec68 --- /dev/null +++ b/packages/backend/src/misc/captcha-error.ts @@ -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'; + } +} diff --git a/packages/backend/src/misc/render-inline-error.ts b/packages/backend/src/misc/render-inline-error.ts index 07f9f3068e..886efcb86e 100644 --- a/packages/backend/src/misc/render-inline-error.ts +++ b/packages/backend/src/misc/render-inline-error.ts @@ -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[] = []; diff --git a/packages/backend/src/misc/verify-field-link.ts b/packages/backend/src/misc/verify-field-link.ts index f9fc352806..31a356be37 100644 --- a/packages/backend/src/misc/verify-field-link.ts +++ b/packages/backend/src/misc/verify-field-link.ts @@ -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 { +export async function verifyFieldLinks(fields: Field[], profileUrls: string[], httpRequestService: HttpRequestService): Promise { 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); } diff --git a/packages/backend/src/models/Announcement.ts b/packages/backend/src/models/Announcement.ts index 681d743322..15857bcb92 100644 --- a/packages/backend/src/models/Announcement.ts +++ b/packages/backend/src/models/Announcement.ts @@ -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, diff --git a/packages/backend/src/models/ChatMessage.ts b/packages/backend/src/models/ChatMessage.ts index 3d2b64268e..536947335b 100644 --- a/packages/backend/src/models/ChatMessage.ts +++ b/packages/backend/src/models/ChatMessage.ts @@ -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; diff --git a/packages/backend/src/models/Meta.ts b/packages/backend/src/models/Meta.ts index 182495e95f..fd6cf7a0f3 100644 --- a/packages/backend/src/models/Meta.ts +++ b/packages/backend/src/models/Meta.ts @@ -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; - @Column('varchar', { - length: 280, + @Column('text', { array: true, default: '{}', }) diff --git a/packages/backend/src/models/UserPending.ts b/packages/backend/src/models/UserPending.ts index 972c862a1a..2b4d5ac329 100644 --- a/packages/backend/src/models/UserPending.ts +++ b/packages/backend/src/models/UserPending.ts @@ -37,4 +37,10 @@ export class MiUserPending { nullable: true, }) public reason: string; + + @Column('varchar', { + length: 128, + nullable: true, + }) + public requestOriginIp: string | null; } diff --git a/packages/backend/src/models/json-schema/meta.ts b/packages/backend/src/models/json-schema/meta.ts index 35e470c459..9a81cdea3d 100644 --- a/packages/backend/src/models/json-schema/meta.ts +++ b/packages/backend/src/models/json-schema/meta.ts @@ -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, diff --git a/packages/backend/src/models/json-schema/user.ts b/packages/backend/src/models/json-schema/user.ts index 23c8086fa6..4a2d5780d1 100644 --- a/packages/backend/src/models/json-schema/user.ts +++ b/packages/backend/src/models/json-schema/user.ts @@ -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', diff --git a/packages/backend/src/queue/processors/ImportAntennasProcessorService.ts b/packages/backend/src/queue/processors/ImportAntennasProcessorService.ts index 35e31b9533..0fca613f29 100644 --- a/packages/backend/src/queue/processors/ImportAntennasProcessorService.ts +++ b/packages/backend/src/queue/processors/ImportAntennasProcessorService.ts @@ -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 { diff --git a/packages/backend/src/queue/processors/ImportCustomEmojisProcessorService.ts b/packages/backend/src/queue/processors/ImportCustomEmojisProcessorService.ts index 3be5b8401b..d35f4ac6d9 100644 --- a/packages/backend/src/queue/processors/ImportCustomEmojisProcessorService.ts +++ b/packages/backend/src/queue/processors/ImportCustomEmojisProcessorService.ts @@ -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; } diff --git a/packages/backend/src/queue/types.ts b/packages/backend/src/queue/types.ts index 79ab68ab1d..6dc9f88034 100644 --- a/packages/backend/src/queue/types.ts +++ b/packages/backend/src/queue/types.ts @@ -110,6 +110,7 @@ export type DbNoteImportJobData = { export type DBAntennaImportJobData = { user: ThinUser, antenna: Antenna + fileId: MiDriveFile['id']; }; export type DbUserImportToDbJobData = { diff --git a/packages/backend/src/server/SkRateLimiterService.md b/packages/backend/src/server/SkRateLimiterService.md index 55786664e1..4ec097ea8e 100644 --- a/packages/backend/src/server/SkRateLimiterService.md +++ b/packages/backend/src/server/SkRateLimiterService.md @@ -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);; diff --git a/packages/backend/src/server/SkRateLimiterService.ts b/packages/backend/src/server/SkRateLimiterService.ts index 35e87b0fe8..a53c58ba5a 100644 --- a/packages/backend/src/server/SkRateLimiterService.ts +++ b/packages/backend/src/server/SkRateLimiterService.ts @@ -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); diff --git a/packages/backend/src/server/api/SignupApiService.ts b/packages/backend/src/server/api/SignupApiService.ts index c1864ce959..86896264dd 100644 --- a/packages/backend/src/server/api/SignupApiService.ts +++ b/packages/backend/src/server/api/SignupApiService.ts @@ -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); + } + } } diff --git a/packages/backend/src/server/api/StreamingApiServerService.ts b/packages/backend/src/server/api/StreamingApiServerService.ts index a3ac8f5447..783f6af34e 100644 --- a/packages/backend/src/server/api/StreamingApiServerService.ts +++ b/packages/backend/src/server/api/StreamingApiServerService.ts @@ -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>(); // 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, }); diff --git a/packages/backend/src/server/api/endpoints/admin/announcements/create.ts b/packages/backend/src/server/api/endpoints/admin/announcements/create.ts index f17330a58f..0794fd9c2d 100644 --- a/packages/backend/src/server/api/endpoints/admin/announcements/create.ts +++ b/packages/backend/src/server/api/endpoints/admin/announcements/create.ts @@ -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 { // 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) { diff --git a/packages/backend/src/server/api/endpoints/admin/announcements/list.ts b/packages/backend/src/server/api/endpoints/admin/announcements/list.ts index 2423807518..6bd3989df5 100644 --- a/packages/backend/src/server/api/endpoints/admin/announcements/list.ts +++ b/packages/backend/src/server/api/endpoints/admin/announcements/list.ts @@ -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 { // eslint- display: announcement.display, isActive: announcement.isActive, forExistingUsers: announcement.forExistingUsers, + forRoles: announcement.forRoles, silence: announcement.silence, needConfirmationToRead: announcement.needConfirmationToRead, confetti: announcement.confetti, diff --git a/packages/backend/src/server/api/endpoints/admin/announcements/update.ts b/packages/backend/src/server/api/endpoints/admin/announcements/update.ts index 8c3475d977..0553ef0426 100644 --- a/packages/backend/src/server/api/endpoints/admin/announcements/update.ts +++ b/packages/backend/src/server/api/endpoints/admin/announcements/update.ts @@ -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 { // eslint- display: ps.display, icon: ps.icon, forExistingUsers: ps.forExistingUsers, + forRoles: ps.forRoles, silence: ps.silence, needConfirmationToRead: ps.needConfirmationToRead, confetti: ps.confetti, diff --git a/packages/backend/src/server/api/endpoints/admin/decline-user.ts b/packages/backend/src/server/api/endpoints/admin/decline-user.ts index 0a75dd977d..cefedb5bed 100644 --- a/packages/backend/src/server/api/endpoints/admin/decline-user.ts +++ b/packages/backend/src/server/api/endpoints/admin/decline-user.ts @@ -61,7 +61,7 @@ export default class extends Endpoint { // 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); diff --git a/packages/backend/src/server/api/endpoints/admin/delete-all-files-of-a-user.ts b/packages/backend/src/server/api/endpoints/admin/delete-all-files-of-a-user.ts index 8b4a450ccb..2ace85062a 100644 --- a/packages/backend/src/server/api/endpoints/admin/delete-all-files-of-a-user.ts +++ b/packages/backend/src/server/api/endpoints/admin/delete-all-files-of-a-user.ts @@ -50,7 +50,7 @@ export default class extends Endpoint { // eslint- }); for (const file of files) { - this.driveService.deleteFile(file, false, me); + this.driveService.deleteFile(file); } }); } diff --git a/packages/backend/src/server/api/endpoints/admin/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts index 4bcdc06aa3..22eab974a8 100644 --- a/packages/backend/src/server/api/endpoints/admin/meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/meta.ts @@ -664,6 +664,7 @@ export default class extends Endpoint { // eslint- shortName: instance.shortName, uri: this.config.url, description: instance.description, + about: instance.about, langs: instance.langs, tosUrl: instance.termsOfServiceUrl, repositoryUrl: instance.repositoryUrl, diff --git a/packages/backend/src/server/api/endpoints/admin/roles/users.ts b/packages/backend/src/server/api/endpoints/admin/roles/users.ts index 198166bec2..0a62845087 100644 --- a/packages/backend/src/server/api/endpoints/admin/roles/users.ts +++ b/packages/backend/src/server/api/endpoints/admin/roles/users.ts @@ -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 { // 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, }))); }); diff --git a/packages/backend/src/server/api/endpoints/admin/show-users.ts b/packages/backend/src/server/api/endpoints/admin/show-users.ts index cc65ed2cf0..02117d4b6b 100644 --- a/packages/backend/src/server/api/endpoints/admin/show-users.ts +++ b/packages/backend/src/server/api/endpoints/admin/show-users.ts @@ -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 { // 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' }); }); } } diff --git a/packages/backend/src/server/api/endpoints/admin/update-meta.ts b/packages/backend/src/server/api/endpoints/admin/update-meta.ts index 39ce8fde15..338ce8cac1 100644 --- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts @@ -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 { // eslint- set.description = ps.description; } + if (ps.about !== undefined) { + set.about = ps.about; + } + if (ps.defaultLightTheme !== undefined) { set.defaultLightTheme = ps.defaultLightTheme; } diff --git a/packages/backend/src/server/api/endpoints/announcements.ts b/packages/backend/src/server/api/endpoints/announcements.ts index b2faf675b0..08528ce826 100644 --- a/packages/backend/src/server/api/endpoints/announcements.ts +++ b/packages/backend/src/server/api/endpoints/announcements.ts @@ -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 { // 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(); diff --git a/packages/backend/src/server/api/endpoints/chat/messages/create-to-room.ts b/packages/backend/src/server/api/endpoints/chat/messages/create-to-room.ts index ad2b82e219..0afd3b1ccb 100644 --- a/packages/backend/src/server/api/endpoints/chat/messages/create-to-room.ts +++ b/packages/backend/src/server/api/endpoints/chat/messages/create-to-room.ts @@ -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 { // 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); diff --git a/packages/backend/src/server/api/endpoints/chat/messages/create-to-user.ts b/packages/backend/src/server/api/endpoints/chat/messages/create-to-user.ts index fa34a7d558..83a83e0d1f 100644 --- a/packages/backend/src/server/api/endpoints/chat/messages/create-to-user.ts +++ b/packages/backend/src/server/api/endpoints/chat/messages/create-to-user.ts @@ -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 { // 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({ diff --git a/packages/backend/src/server/api/endpoints/federation/update-remote-user.ts b/packages/backend/src/server/api/endpoints/federation/update-remote-user.ts index 67fa5ed343..27c3e63ade 100644 --- a/packages/backend/src/server/api/endpoints/federation/update-remote-user.ts +++ b/packages/backend/src/server/api/endpoints/federation/update-remote-user.ts @@ -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 { // 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!); }); } diff --git a/packages/backend/src/server/api/endpoints/hashtags/users.ts b/packages/backend/src/server/api/endpoints/hashtags/users.ts index 68c795de73..c43e750cf3 100644 --- a/packages/backend/src/server/api/endpoints/hashtags/users.ts +++ b/packages/backend/src/server/api/endpoints/hashtags/users.ts @@ -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 { // 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' }); }); } } diff --git a/packages/backend/src/server/api/endpoints/i/import-antennas.ts b/packages/backend/src/server/api/endpoints/i/import-antennas.ts index ccec96ffbb..8db4367ad9 100644 --- a/packages/backend/src/server/api/endpoints/i/import-antennas.ts +++ b/packages/backend/src/server/api/endpoints/i/import-antennas.ts @@ -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 { 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); }); } } diff --git a/packages/backend/src/server/api/endpoints/i/import-blocking.ts b/packages/backend/src/server/api/endpoints/i/import-blocking.ts index 2fa450558b..78678f33e5 100644 --- a/packages/backend/src/server/api/endpoints/i/import-blocking.ts +++ b/packages/backend/src/server/api/endpoints/i/import-blocking.ts @@ -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: { diff --git a/packages/backend/src/server/api/endpoints/i/import-following.ts b/packages/backend/src/server/api/endpoints/i/import-following.ts index 9186fca162..2d45ff2c8a 100644 --- a/packages/backend/src/server/api/endpoints/i/import-following.ts +++ b/packages/backend/src/server/api/endpoints/i/import-following.ts @@ -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: { diff --git a/packages/backend/src/server/api/endpoints/i/import-muting.ts b/packages/backend/src/server/api/endpoints/i/import-muting.ts index b6dbacd371..1af9cc6263 100644 --- a/packages/backend/src/server/api/endpoints/i/import-muting.ts +++ b/packages/backend/src/server/api/endpoints/i/import-muting.ts @@ -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: { diff --git a/packages/backend/src/server/api/endpoints/i/import-notes.ts b/packages/backend/src/server/api/endpoints/i/import-notes.ts index 91ef12c3e3..b46e038b50 100644 --- a/packages/backend/src/server/api/endpoints/i/import-notes.ts +++ b/packages/backend/src/server/api/endpoints/i/import-notes.ts @@ -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: { diff --git a/packages/backend/src/server/api/endpoints/i/import-user-lists.ts b/packages/backend/src/server/api/endpoints/i/import-user-lists.ts index 5de0a70bbb..509483be3f 100644 --- a/packages/backend/src/server/api/endpoints/i/import-user-lists.ts +++ b/packages/backend/src/server/api/endpoints/i/import-user-lists.ts @@ -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: { diff --git a/packages/backend/src/server/api/endpoints/i/notifications-grouped.ts b/packages/backend/src/server/api/endpoints/i/notifications-grouped.ts index 444734070f..3821b5a20e 100644 --- a/packages/backend/src/server/api/endpoints/i/notifications-grouped.ts +++ b/packages/backend/src/server/api/endpoints/i/notifications-grouped.ts @@ -116,7 +116,7 @@ export default class extends Endpoint { // 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 { // 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 { // 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 { // 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 { // eslint- } // add this new renote to the existing group (prevRenote as FilterUnionByProperty).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 { // 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); }); diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts index 9e76572300..ba61d19300 100644 --- a/packages/backend/src/server/api/endpoints/i/update.ts +++ b/packages/backend/src/server/api/endpoints/i/update.ts @@ -615,11 +615,15 @@ export default class extends Endpoint { // 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, { diff --git a/packages/backend/src/server/api/endpoints/notes/create.ts b/packages/backend/src/server/api/endpoints/notes/create.ts index 97ff5a2ea3..42dbf33d0d 100644 --- a/packages/backend/src/server/api/endpoints/notes/create.ts +++ b/packages/backend/src/server/api/endpoints/notes/create.ts @@ -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', diff --git a/packages/backend/src/server/api/endpoints/notes/edit.ts b/packages/backend/src/server/api/endpoints/notes/edit.ts index bd70cb7835..2689451a73 100644 --- a/packages/backend/src/server/api/endpoints/notes/edit.ts +++ b/packages/backend/src/server/api/endpoints/notes/edit.ts @@ -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', diff --git a/packages/backend/src/server/api/endpoints/pinned-users.ts b/packages/backend/src/server/api/endpoints/pinned-users.ts index 2e89d81404..ab13ca3497 100644 --- a/packages/backend/src/server/api/endpoints/pinned-users.ts +++ b/packages/backend/src/server/api/endpoints/pinned-users.ts @@ -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 { // 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' }); }); } } diff --git a/packages/backend/src/server/api/endpoints/roles/users.ts b/packages/backend/src/server/api/endpoints/roles/users.ts index 774a6f889b..a9adac8b1c 100644 --- a/packages/backend/src/server/api/endpoints/roles/users.ts +++ b/packages/backend/src/server/api/endpoints/roles/users.ts @@ -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 { // 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' }), }))); }); } diff --git a/packages/backend/src/server/api/endpoints/users.ts b/packages/backend/src/server/api/endpoints/users.ts index defd38fe96..48942db002 100644 --- a/packages/backend/src/server/api/endpoints/users.ts +++ b/packages/backend/src/server/api/endpoints/users.ts @@ -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 { // 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' }); }); } diff --git a/packages/backend/src/server/api/endpoints/users/get-frequently-replied-users.ts b/packages/backend/src/server/api/endpoints/users/get-frequently-replied-users.ts index 99568cfa12..bb27bf32c5 100644 --- a/packages/backend/src/server/api/endpoints/users/get-frequently-replied-users.ts +++ b/packages/backend/src/server/api/endpoints/users/get-frequently-replied-users.ts @@ -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 { // 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, }))); diff --git a/packages/backend/src/server/api/endpoints/users/recommendation.ts b/packages/backend/src/server/api/endpoints/users/recommendation.ts index 52dd2197b2..2585efdc11 100644 --- a/packages/backend/src/server/api/endpoints/users/recommendation.ts +++ b/packages/backend/src/server/api/endpoints/users/recommendation.ts @@ -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 { // 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' }); }); } } diff --git a/packages/backend/src/server/api/endpoints/users/report-abuse.ts b/packages/backend/src/server/api/endpoints/users/report-abuse.ts index 81c0c526f0..fc2b57c4a5 100644 --- a/packages/backend/src/server/api/endpoints/users/report-abuse.ts +++ b/packages/backend/src/server/api/endpoints/users/report-abuse.ts @@ -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 { // 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); diff --git a/packages/backend/src/server/api/endpoints/users/show.ts b/packages/backend/src/server/api/endpoints/users/show.ts index 84eb661742..30b4719e09 100644 --- a/packages/backend/src/server/api/endpoints/users/show.ts +++ b/packages/backend/src/server/api/endpoints/users/show.ts @@ -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 { // 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 { // eslint- } return await this.userEntityService.pack(user, me, { - schema: 'UserDetailed', + schema: ps.detail ? 'UserDetailed' : 'UserLite', }); } }); diff --git a/packages/backend/src/server/api/mastodon/MastodonDataService.ts b/packages/backend/src/server/api/mastodon/MastodonDataService.ts index e080cb10bd..f76483a978 100644 --- a/packages/backend/src/server/api/mastodon/MastodonDataService.ts +++ b/packages/backend/src/server/api/mastodon/MastodonDataService.ts @@ -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'); } } diff --git a/packages/backend/src/server/api/mastodon/endpoints/instance.ts b/packages/backend/src/server/api/mastodon/endpoints/instance.ts index cfca5b1350..f502b5e191 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/instance.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/instance.ts @@ -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, diff --git a/packages/backend/src/server/web/UrlPreviewService.ts b/packages/backend/src/server/web/UrlPreviewService.ts index 71a142fc6f..438aa5905d 100644 --- a/packages/backend/src/server/web/UrlPreviewService.ts +++ b/packages/backend/src/server/web/UrlPreviewService.ts @@ -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({ diff --git a/packages/backend/src/server/web/boot.js b/packages/backend/src/server/web/boot.js index 0488161513..c10f6a54ae 100644 --- a/packages/backend/src/server/web/boot.js +++ b/packages/backend/src/server/web/boot.js @@ -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'); diff --git a/packages/backend/src/server/web/views/note.pug b/packages/backend/src/server/web/views/note.pug index 53cff6bcd3..9428b5435a 100644 --- a/packages/backend/src/server/web/views/note.pug +++ b/packages/backend/src/server/web/views/note.pug @@ -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) diff --git a/packages/backend/test/unit/AnnouncementService.ts b/packages/backend/test/unit/AnnouncementService.ts index 32d7df05bf..ab3b6961c0 100644 --- a/packages/backend/test/unit/AnnouncementService.ts +++ b/packages/backend/test/unit/AnnouncementService.ts @@ -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; let moderationLogService: jest.Mocked; + let roleService: jest.Mocked; function createUser(data: Partial = {}) { 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(DI.announcementReadsRepository); globalEventService = app.get(GlobalEventService) as jest.Mocked; moderationLogService = app.get(ModerationLogService) as jest.Mocked; + roleService = app.get(RoleService) as jest.Mocked; }); afterEach(async () => { diff --git a/packages/backend/test/unit/FetchInstanceMetadataService.ts b/packages/backend/test/unit/FetchInstanceMetadataService.ts index 1e3605aafc..812ee38703 100644 --- a/packages/backend/test/unit/FetchInstanceMetadataService.ts +++ b/packages/backend/test/unit/FetchInstanceMetadataService.ts @@ -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) => { diff --git a/packages/backend/test/unit/MfmService.ts b/packages/backend/test/unit/MfmService.ts index af1fc4e132..ace67caf33 100644 --- a/packages/backend/test/unit/MfmService.ts +++ b/packages/backend/test/unit/MfmService.ts @@ -63,6 +63,12 @@ describe('MfmService', () => { const output = '

some text(ignore me)

'; assert.equal(mfmService.toHtml(mfm.parse(input)), output); }); + + test('inline', () => { + const input = 'https://example.com'; + const output = 'https://example.com'; + assert.equal(mfmService.toHtml(mfm.parse(input), [], [], true), output); + }); }); describe('toMastoApiHtml', () => { diff --git a/packages/backend/test/unit/UtilityService.ts b/packages/backend/test/unit/UtilityService.ts index cb010ff1f9..f4e92b85a7 100644 --- a/packages/backend/test/unit/UtilityService.ts +++ b/packages/backend/test/unit/UtilityService.ts @@ -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; beforeAll(async () => { const app = await Test.createTestingModule({ imports: [GlobalModule, CoreModule], - }).compile(); + providers: [MetaService], + }) + .overrideProvider(MetaService).useValue({ fetch: jest.fn() }) + .compile(); + utilityService = app.get(UtilityService); + meta = app.get(DI.meta) as jest.Mocked; }); 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, 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', + ); + }); + }); }); diff --git a/packages/backend/test/unit/core/HttpRequestService.ts b/packages/backend/test/unit/core/HttpRequestService.ts index a2f4604e7b..ccce32ffee 100644 --- a/packages/backend/test/unit/core/HttpRequestService.ts +++ b/packages/backend/test/unit/core/HttpRequestService.ts @@ -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: { diff --git a/packages/backend/test/unit/core/activitypub/ApUtilityService.ts b/packages/backend/test/unit/core/activitypub/ApUtilityService.ts index 325a94dc5a..7b564b1fdd 100644 --- a/packages/backend/test/unit/core/activitypub/ApUtilityService.ts +++ b/packages/backend/test/unit/core/activitypub/ApUtilityService.ts @@ -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; 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); + }); + }); }); diff --git a/packages/backend/test/unit/server/api/SkRateLimiterServiceTests.ts b/packages/backend/test/unit/server/api/SkRateLimiterServiceTests.ts index b1f100698b..f7250600e3 100644 --- a/packages/backend/test/unit/server/api/SkRateLimiterServiceTests.ts +++ b/packages/backend/test/unit/server/api/SkRateLimiterServiceTests.ts @@ -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 () => { diff --git a/packages/frontend-shared/themes/_dark.json5 b/packages/frontend-shared/themes/_dark.json5 index 1c52dac9d5..2987765976 100644 --- a/packages/frontend-shared/themes/_dark.json5 +++ b/packages/frontend-shared/themes/_dark.json5 @@ -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: { diff --git a/packages/frontend-shared/themes/_light.json5 b/packages/frontend-shared/themes/_light.json5 index c12f5be9db..b4475d8d54 100644 --- a/packages/frontend-shared/themes/_light.json5 +++ b/packages/frontend-shared/themes/_light.json5 @@ -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: { diff --git a/packages/frontend/src/components/MkCode.core.vue b/packages/frontend/src/components/MkCode.core.vue index 36c08a8c64..76ff6001de 100644 --- a/packages/frontend/src/components/MkCode.core.vue +++ b/packages/frontend/src/components/MkCode.core.vue @@ -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%; diff --git a/packages/frontend/src/components/MkEmojiPicker.vue b/packages/frontend/src/components/MkEmojiPicker.vue index 6b1add81bc..cf7087d798 100644 --- a/packages/frontend/src/components/MkEmojiPicker.vue +++ b/packages/frontend/src/components/MkEmojiPicker.vue @@ -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, ''); diff --git a/packages/frontend/src/components/MkNotifications.vue b/packages/frontend/src/components/MkNotifications.vue index d9faf2b06e..c43601fa49 100644 --- a/packages/frontend/src/components/MkNotifications.vue +++ b/packages/frontend/src/components/MkNotifications.vue @@ -18,7 +18,7 @@ SPDX-License-Identifier: AGPL-3.0-only :moveClass=" $style.transition_x_move" tag="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') { diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue index a650365a28..3df78712d5 100644 --- a/packages/frontend/src/components/MkPostForm.vue +++ b/packages/frontend/src/components/MkPostForm.vue @@ -1497,7 +1497,7 @@ defineExpose({ max-width: 100%; min-width: 100%; width: 100%; - min-height: 90px; + min-height: 5.85em; height: 100%; } diff --git a/packages/frontend/src/components/MkRoleSelectDialog.vue b/packages/frontend/src/components/MkRoleSelectDialog.vue index 6888824437..4141839fe3 100644 --- a/packages/frontend/src/components/MkRoleSelectDialog.vue +++ b/packages/frontend/src/components/MkRoleSelectDialog.vue @@ -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 })); diff --git a/packages/frontend/src/components/MkTextarea.vue b/packages/frontend/src/components/MkTextarea.vue index e36069c50e..1e21d89b83 100644 --- a/packages/frontend/src/components/MkTextarea.vue +++ b/packages/frontend/src/components/MkTextarea.vue @@ -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; } } diff --git a/packages/frontend/src/components/MkUrlWarningDialog.vue b/packages/frontend/src/components/MkUrlWarningDialog.vue index 01ecba1817..8b18942719 100644 --- a/packages/frontend/src/components/MkUrlWarningDialog.vue +++ b/packages/frontend/src/components/MkUrlWarningDialog.vue @@ -64,7 +64,7 @@ function done(canceled: boolean, result?: Result): void { // eslint-disable-line async function ok() { const result = true; if (!prefer.s.trustedDomains.includes(domain.value) && trustThisDomain.value) { - prefer.r.trustedDomains.value = prefer.s.trustedDomains.concat(domain.value); + prefer.commit('trustedDomains', prefer.s.trustedDomains.concat(domain.value)); } done(false, result); } diff --git a/packages/frontend/src/components/MkVisitorDashboard.vue b/packages/frontend/src/components/MkVisitorDashboard.vue index 942085f7bb..ed63f43574 100644 --- a/packages/frontend/src/components/MkVisitorDashboard.vue +++ b/packages/frontend/src/components/MkVisitorDashboard.vue @@ -18,6 +18,9 @@ SPDX-License-Identifier: AGPL-3.0-only
+
{{ i18n.ts.invitationRequiredToRegister }} {{ i18n.ts.federationSpecified }} @@ -216,4 +219,9 @@ function showMenu(ev: MouseEvent) { height: 350px; overflow: auto; } + +.showMore { + font-size: 0.8m; + color: var(--MI_THEME-accent); +} diff --git a/packages/frontend/src/components/SkModPlayer.vue b/packages/frontend/src/components/SkModPlayer.vue index ab1a03ed51..b787b33f3d 100644 --- a/packages/frontend/src/components/SkModPlayer.vue +++ b/packages/frontend/src/components/SkModPlayer.vue @@ -14,17 +14,34 @@ Media player for module files. Displays the pattern in real time as it plays.
-
+
Pattern Hidden {{ i18n.ts.clickToShow }}
- +
+ + + + + + + + + + + + + + + + +
- + - + @@ -45,6 +62,7 @@ Media player for module files. Displays the pattern in real time as it plays. diff --git a/packages/frontend/src/pages/admin/settings.vue b/packages/frontend/src/pages/admin/settings.vue index 50f62512f5..fd51269181 100644 --- a/packages/frontend/src/pages/admin/settings.vue +++ b/packages/frontend/src/pages/admin/settings.vue @@ -28,6 +28,12 @@ SPDX-License-Identifier: AGPL-3.0-only + + + + + + @@ -343,6 +349,7 @@ const infoForm = useForm({ name: meta.name ?? '', shortName: meta.shortName ?? '', description: meta.description ?? '', + about: meta.about ?? '', maintainerName: meta.maintainerName ?? '', maintainerEmail: meta.maintainerEmail ?? '', tosUrl: meta.tosUrl ?? '', @@ -356,6 +363,7 @@ const infoForm = useForm({ name: state.name, shortName: state.shortName === '' ? null : state.shortName, description: state.description, + about: state.about, maintainerName: state.maintainerName, maintainerEmail: state.maintainerEmail, tosUrl: state.tosUrl, diff --git a/packages/frontend/src/pages/chat/room.form.vue b/packages/frontend/src/pages/chat/room.form.vue index 9389b16ce7..8b1fed5156 100644 --- a/packages/frontend/src/pages/chat/room.form.vue +++ b/packages/frontend/src/pages/chat/room.form.vue @@ -43,7 +43,7 @@ import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; import { uploadFile } from '@/utility/upload.js'; import { miLocalStorage } from '@/local-storage.js'; -import { misskeyApi } from '@/utility/misskey-api.js'; +import { misskeyApi, printError } from '@/utility/misskey-api.js'; import { prefer } from '@/preferences.js'; import { Autocomplete } from '@/utility/autocomplete.js'; import { emojiPicker } from '@/utility/emoji-picker.js'; @@ -194,8 +194,13 @@ function send() { }).then(message => { clear(); }).catch(err => { - console.error(err); - }).then(() => { + console.error('Error in chat:', err); + return os.alert({ + type: 'error', + title: i18n.ts.error, + text: printError(err), + }); + }).finally(() => { sending.value = false; }); } else if (props.room) { @@ -206,8 +211,13 @@ function send() { }).then(message => { clear(); }).catch(err => { - console.error(err); - }).then(() => { + console.error('Error in chat:', err); + return os.alert({ + type: 'error', + title: i18n.ts.error, + text: printError(err), + }); + }).finally(() => { sending.value = false; }); } diff --git a/packages/frontend/src/pages/settings/preferences.vue b/packages/frontend/src/pages/settings/preferences.vue index 54b5b6be1c..1c8e418182 100644 --- a/packages/frontend/src/pages/settings/preferences.vue +++ b/packages/frontend/src/pages/settings/preferences.vue @@ -700,9 +700,16 @@ SPDX-License-Identifier: AGPL-3.0-only + + + + + + + @@ -829,6 +836,15 @@ SPDX-License-Identifier: AGPL-3.0-only + + + + + + + + + @@ -967,6 +983,7 @@ import FormLink from '@/components/form/link.vue'; import MkLink from '@/components/MkLink.vue'; import MkInfo from '@/components/MkInfo.vue'; import MkInput from '@/components/MkInput.vue'; +import MkTextarea from '@/components/MkTextarea.vue'; import { store } from '@/store.js'; import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; @@ -1071,8 +1088,14 @@ const defaultCW = ref($i.defaultCW); const defaultCWPriority = ref($i.defaultCWPriority); const lang = prefer.model('lang'); const fontSize = prefer.model('fontSize'); +const customFontSize = prefer.model('customFontSize'); const useSystemFont = prefer.model('useSystemFont'); const cornerRadius = prefer.model('cornerRadius'); +const trustedDomains = prefer.model( + 'trustedDomains', + (domainsList) => domainsList.join('\n'), + (domainsString) => domainsString.split('\n').map( d => d.trim() ).filter( x => x.length > 0), +); watch([ hemisphere, diff --git a/packages/frontend/src/preferences/def.ts b/packages/frontend/src/preferences/def.ts index c712e96fb9..b651bdd31b 100644 --- a/packages/frontend/src/preferences/def.ts +++ b/packages/frontend/src/preferences/def.ts @@ -497,7 +497,18 @@ export const PREF_DEF = { miLocalStorage.removeItem('fontSize'); } }, - } as Pref<'0' | '1' | '2' | '3'>, + } as Pref<'0' | '1' | '2' | '3' | 'custom'>, + customFontSize: { + default: 14, + needsReload: true, + onSet: customFontSize => { + if (customFontSize) { + miLocalStorage.setItem('customFontSize', customFontSize.toString()); + } else { + miLocalStorage.removeItem('customFontSize'); + } + }, + } as Pref, useSystemFont: { default: false, needsReload: true, diff --git a/packages/frontend/src/utility/misskey-api.ts b/packages/frontend/src/utility/misskey-api.ts index f8c4657655..a5736e11ed 100644 --- a/packages/frontend/src/utility/misskey-api.ts +++ b/packages/frontend/src/utility/misskey-api.ts @@ -144,3 +144,21 @@ export function misskeyApiGet< return promise; } + +export function printError(error: unknown): string { + if (error != null && typeof(error) === 'object') { + if ('info' in error && typeof (error.info) === 'object' && error.info) { + if ('e' in error.info && typeof (error.info.e) === 'object' && error.info.e) { + if ('message' in error.info.e && typeof (error.info.e.message) === 'string') return error.info.e.message; + if ('code' in error.info.e && typeof (error.info.e.code) === 'string') return error.info.e.code; + if ('id' in error.info.e && typeof (error.info.e.id) === 'string') return error.info.e.id; + } + } + + if ('message' in error && typeof (error.message) === 'string') return error.message; + if ('code' in error && typeof (error.code) === 'string') return error.code; + if ('id' in error && typeof (error.id) === 'string') return error.id; + } + + return String(error); +} diff --git a/packages/frontend/src/utility/settings-search-index.ts b/packages/frontend/src/utility/settings-search-index.ts index 7ed97ed34f..8506e4fe2f 100644 --- a/packages/frontend/src/utility/settings-search-index.ts +++ b/packages/frontend/src/utility/settings-search-index.ts @@ -24,6 +24,7 @@ for (const item of generated) { const inline = rootMods.get(id); if (inline) { inline.parentId = item.id; + inline.path = item.path; } else { console.log('[Settings Search Index] Failed to inline', id); } diff --git a/packages/frontend/src/utility/warning-external-website.ts b/packages/frontend/src/utility/warning-external-website.ts index 33cf379b50..28c03c419d 100644 --- a/packages/frontend/src/utility/warning-external-website.ts +++ b/packages/frontend/src/utility/warning-external-website.ts @@ -36,8 +36,8 @@ export async function warningExternalWebsite(url: string) { } }); - const isTrustedByUser = prefer.r.trustedDomains.value.includes(hostname); - const isDisabledByUser = !prefer.r.warnExternalUrl.value; + const isTrustedByUser = prefer.s.trustedDomains.includes(hostname); + const isDisabledByUser = !prefer.s.warnExternalUrl; if (!isTrustedByInstance && !isTrustedByUser && !isDisabledByUser) { const confirm = await new Promise<{ canceled: boolean }>(resolve => { diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md index 94accf852e..a553353d2a 100644 --- a/packages/misskey-js/etc/misskey-js.api.md +++ b/packages/misskey-js/etc/misskey-js.api.md @@ -2067,6 +2067,7 @@ declare namespace entities { PagesUnlikeRequest, PagesUpdateRequest, PingResponse, + PinnedUsersRequest, PinnedUsersResponse, PromoReadRequest, RenoteMuteCreateRequest, @@ -3411,6 +3412,9 @@ export const permissions: readonly ["read:account", "write:account", "read:block // @public (undocumented) type PingResponse = operations['ping']['responses']['200']['content']['application/json']; +// @public (undocumented) +type PinnedUsersRequest = operations['pinned-users']['requestBody']['content']['application/json']; + // @public (undocumented) type PinnedUsersResponse = operations['pinned-users']['responses']['200']['content']['application/json']; diff --git a/packages/misskey-js/src/autogen/endpoint.ts b/packages/misskey-js/src/autogen/endpoint.ts index 5a40b74b8f..f08323d783 100644 --- a/packages/misskey-js/src/autogen/endpoint.ts +++ b/packages/misskey-js/src/autogen/endpoint.ts @@ -563,6 +563,7 @@ import type { PagesUnlikeRequest, PagesUpdateRequest, PingResponse, + PinnedUsersRequest, PinnedUsersResponse, PromoReadRequest, RenoteMuteCreateRequest, @@ -1044,7 +1045,7 @@ export type Endpoints = { 'pages/unlike': { req: PagesUnlikeRequest; res: EmptyResponse }; 'pages/update': { req: PagesUpdateRequest; res: EmptyResponse }; 'ping': { req: EmptyRequest; res: PingResponse }; - 'pinned-users': { req: EmptyRequest; res: PinnedUsersResponse }; + 'pinned-users': { req: PinnedUsersRequest; res: PinnedUsersResponse }; 'promo/read': { req: PromoReadRequest; res: EmptyResponse }; 'renote-mute/create': { req: RenoteMuteCreateRequest; res: EmptyResponse }; 'renote-mute/delete': { req: RenoteMuteDeleteRequest; res: EmptyResponse }; diff --git a/packages/misskey-js/src/autogen/entities.ts b/packages/misskey-js/src/autogen/entities.ts index 12f9b4c5ca..cf40793792 100644 --- a/packages/misskey-js/src/autogen/entities.ts +++ b/packages/misskey-js/src/autogen/entities.ts @@ -566,6 +566,7 @@ export type PagesShowResponse = operations['pages___show']['responses']['200'][' export type PagesUnlikeRequest = operations['pages___unlike']['requestBody']['content']['application/json']; export type PagesUpdateRequest = operations['pages___update']['requestBody']['content']['application/json']; export type PingResponse = operations['ping']['responses']['200']['content']['application/json']; +export type PinnedUsersRequest = operations['pinned-users']['requestBody']['content']['application/json']; export type PinnedUsersResponse = operations['pinned-users']['responses']['200']['content']['application/json']; export type PromoReadRequest = operations['promo___read']['requestBody']['content']['application/json']; export type RenoteMuteCreateRequest = operations['renote-mute___create']['requestBody']['content']['application/json']; diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index 4c6cb44a52..7a1f2daa58 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -4254,6 +4254,10 @@ export type components = { host: string | null; /** Format: date-time */ createdAt: string; + /** Format: date-time */ + updatedAt: string | null; + /** Format: date-time */ + lastFetchedAt: string | null; approved: boolean; /** @example Hi masters, I am Ai! */ description: string | null; @@ -4320,10 +4324,6 @@ export type components = { /** Format: uri */ movedTo: string | null; alsoKnownAs: string[] | null; - /** Format: date-time */ - updatedAt: string | null; - /** Format: date-time */ - lastFetchedAt: string | null; /** Format: url */ bannerUrl: string | null; bannerBlurhash: string | null; @@ -4640,6 +4640,7 @@ export type components = { display: 'dialog' | 'normal' | 'banner'; needConfirmationToRead: boolean; silence: boolean; + confetti: boolean; forYou: boolean; isRead?: boolean; }; @@ -6693,10 +6694,14 @@ export type operations = { display?: 'normal' | 'banner' | 'dialog'; /** @default false */ forExistingUsers?: boolean; + /** @default [] */ + forRoles?: string[]; /** @default false */ silence?: boolean; /** @default false */ needConfirmationToRead?: boolean; + /** @default false */ + confetti?: boolean; /** * Format: misskey:id * @default null @@ -6853,6 +6858,7 @@ export type operations = { title: string; imageUrl: string | null; reads: number; + forRoles: string[]; })[]; }; }; @@ -6908,8 +6914,11 @@ export type operations = { /** @enum {string} */ display?: 'normal' | 'banner' | 'dialog'; forExistingUsers?: boolean; + /** @default [] */ + forRoles?: string[]; silence?: boolean; needConfirmationToRead?: boolean; + confetti?: boolean; isActive?: boolean; }; }; @@ -10898,6 +10907,8 @@ export type operations = { untilId?: string; /** @default 10 */ limit?: number; + /** @default true */ + detail?: boolean; }; }; }; @@ -10910,7 +10921,7 @@ export type operations = { id: string; /** Format: date-time */ createdAt: string; - user: components['schemas']['UserDetailed']; + user: components['schemas']['User']; /** Format: date-time */ expiresAt: string | null; })[]; @@ -11406,6 +11417,8 @@ export type operations = { * @default null */ hostname?: string | null; + /** @default true */ + detail?: boolean; }; }; }; @@ -11413,7 +11426,7 @@ export type operations = { /** @description OK (with results) */ 200: { content: { - 'application/json': components['schemas']['UserDetailed'][]; + 'application/json': components['schemas']['User'][]; }; }; /** @description Client error */ @@ -21853,6 +21866,8 @@ export type operations = { origin?: 'combined' | 'local' | 'remote'; /** @default false */ trending?: boolean; + /** @default true */ + detail?: boolean; }; }; }; @@ -21860,7 +21875,7 @@ export type operations = { /** @description OK (with results) */ 200: { content: { - 'application/json': components['schemas']['UserDetailed'][]; + 'application/json': components['schemas']['User'][]; }; }; /** @description Client error */ @@ -29830,11 +29845,19 @@ export type operations = { * **Credential required**: *No* */ 'pinned-users': { + requestBody: { + content: { + 'application/json': { + /** @default true */ + detail?: boolean; + }; + }; + }; responses: { /** @description OK (with results) */ 200: { content: { - 'application/json': components['schemas']['UserDetailed'][]; + 'application/json': components['schemas']['User'][]; }; }; /** @description Client error */ @@ -30962,6 +30985,8 @@ export type operations = { untilId?: string; /** @default 10 */ limit?: number; + /** @default true */ + detail?: boolean; }; }; }; @@ -30972,7 +30997,7 @@ export type operations = { 'application/json': { /** Format: misskey:id */ id: string; - user: components['schemas']['UserDetailed']; + user: components['schemas']['User']; }[]; }; }; @@ -31635,6 +31660,8 @@ export type operations = { * @default null */ hostname?: string | null; + /** @default true */ + detail?: boolean; }; }; }; @@ -31642,7 +31669,7 @@ export type operations = { /** @description OK (with results) */ 200: { content: { - 'application/json': components['schemas']['UserDetailed'][]; + 'application/json': components['schemas']['User'][]; }; }; /** @description Client error */ @@ -32158,6 +32185,8 @@ export type operations = { userId: string; /** @default 10 */ limit?: number; + /** @default true */ + detail?: boolean; }; }; }; @@ -32166,7 +32195,7 @@ export type operations = { 200: { content: { 'application/json': { - user: components['schemas']['UserDetailed']; + user: components['schemas']['User']; weight: number; }[]; }; @@ -33179,6 +33208,8 @@ export type operations = { limit?: number; /** @default 0 */ offset?: number; + /** @default true */ + detail?: boolean; }; }; }; @@ -33186,7 +33217,7 @@ export type operations = { /** @description OK (with results) */ 200: { content: { - 'application/json': components['schemas']['UserDetailed'][]; + 'application/json': components['schemas']['User'][]; }; }; /** @description Client error */ @@ -33521,6 +33552,8 @@ export type operations = { username?: string; /** @description The local host is represented with `null`. */ host?: string | null; + /** @default true */ + detail?: boolean; }; }; }; @@ -33528,7 +33561,7 @@ export type operations = { /** @description OK (with results) */ 200: { content: { - 'application/json': components['schemas']['UserDetailed'] | components['schemas']['UserDetailed'][]; + 'application/json': components['schemas']['User'] | components['schemas']['User'][]; }; }; /** @description Client error */ diff --git a/scripts/build-pre.js b/scripts/build-pre.js index a90d53c75d..4b34f6d414 100644 --- a/scripts/build-pre.js +++ b/scripts/build-pre.js @@ -5,13 +5,39 @@ const fs = require('fs'); const packageJsonPath = __dirname + '/../package.json' +const { execFileSync } = require('node:child_process'); + +function callGit(args) { + return execFileSync('git', args, { + encoding: 'utf-8', + }).trim(); +} + +function getGitVersion(versionFromPackageJson) { + const thisTag = callGit(['tag', '--points-at', 'HEAD']); + if (thisTag) { + // we're building from a tag, we don't care about extra details + return null; + } + + const commitId = callGit(['rev-parse', '--short', 'HEAD']); + return `${versionFromPackageJson}+g${commitId}`; +} function build() { try { const json = fs.readFileSync(packageJsonPath, 'utf-8') const meta = JSON.parse(json); + + let gitVersion; + try { + gitVersion = getGitVersion(meta.version); + } catch (e) { + console.warn("couldn't get git commit details, ignoring",e); + } + fs.mkdirSync(__dirname + '/../built', { recursive: true }); - fs.writeFileSync(__dirname + '/../built/meta.json', JSON.stringify({ version: meta.version }), 'utf-8'); + fs.writeFileSync(__dirname + '/../built/meta.json', JSON.stringify({ version: meta.version, gitVersion }), 'utf-8'); } catch (e) { console.error(e) } diff --git a/sharkey-locales/en-US.yml b/sharkey-locales/en-US.yml index c49b342f97..37b87637bf 100644 --- a/sharkey-locales/en-US.yml +++ b/sharkey-locales/en-US.yml @@ -8,6 +8,8 @@ deleteAndEditConfirm: "Are you sure you want to redraft this note? This means yo openRemoteProfile: "Open remote profile" trustedLinkUrlPatterns: "Link to external site warning exclusion list" trustedLinkUrlPatternsDescription: "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." +trustedDomainsList: "Link to external site warning exclusion list" +trustedDomainsListDescription: "Following links to these domains will not show a warning. Write one domain per line." mutuals: "Mutuals" isLocked: "Private account" isAdmin: "Administrator" @@ -216,6 +218,9 @@ _serverSettings: sidebarLogoUsageExample: "E.g. In the sidebar, to visitors and in the \"About\" page." inquiryUrl: "Contact URL" inquiryUrlDescription: "Specify the URL of a web page that contains a contact form or the instance operators' contact information." + aboutInstance: "About instance" + aboutInstanceDescription: "A longer description that will be displayed in the 'Instance Information' page, going in place of the regular instance description. Supports HTML." + deliverSuspendedSoftwareDescription: "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-/" _accountMigration: moveAccountDescription: "This will migrate your account to a different one.\n ・Followers from this account will automatically be migrated to the new account\n ・This account will unfollow all users it is currently following\n ・You will be unable to create new notes etc. on this account\n\nWhile migration of followers is automatic, you must manually prepare some steps to migrate the list of users you are following. To do so, carry out a follows export that you will later import on the new account in the settings menu. The same procedure applies to your lists as well as your muted and blocked users.\n\n(This explanation applies to Sharkey v13.12.0 and later. Other ActivityPub software, such as Mastodon, might function differently.)" _achievements: @@ -474,6 +479,10 @@ _auth: allowed: "Allowed" _announcement: new: "New" + onlyForRoles: "Restrict to roles" + onlyForRolesChange: "Change role restrictions" + onlyForRolesUnrestricted: "Shown to everyone" + onlyForRolesRestricted: "Shown to members of {roles} roles" confetti: "Throw confetti" confettiDescription: "If enabled, the announcement will display a confetti effect when viewed." _deck: @@ -649,3 +658,5 @@ clearCachedFilesOptions: oneYear: "one year" keepFilesInUse: "Don't delete files used as avatars&c" keepFilesInUseDescription: "this option requires more complicated database queries, you may need to increase the value of db.extra.statement_timeout in the configuration file" + +customFontSize: "Custom font size" diff --git a/sharkey-locales/pt-PT.yml b/sharkey-locales/pt-PT.yml index 7220cd2b59..02d2caf6a4 100644 --- a/sharkey-locales/pt-PT.yml +++ b/sharkey-locales/pt-PT.yml @@ -10,3 +10,6 @@ blockingYou: "Bloqueando você" attributionDomains: "Domínios de Atribuição" attributionDomainsDescription: "Uma lista de domínios cujo conteúdo pode ser atribuído a você em prévias de link, separadas por linha. Qualquer subdomínio também será válido. O código seguinte precisa estar presente na página:" writtenBy: "Escrito por {user}" +_serverSettings: + aboutInstance: "Sobre a instância" + aboutInstanceDescription: "Uma descrição maior que irá aparecer na página 'Informações da Instância', substituindo a descrição da instância. Aceita HTML."