diff --git a/.github/workflows/docker-develop.yml b/.github/workflows/docker-develop.yml index ac2b1b4d358a..f2160e9968fb 100644 --- a/.github/workflows/docker-develop.yml +++ b/.github/workflows/docker-develop.yml @@ -7,7 +7,7 @@ on: workflow_dispatch: env: - REGISTRY_IMAGE: misskey/misskey + REGISTRY_IMAGE: ghcr.io/${{ github.repository }} jobs: # see https://docs.docker.com/build/ci/github-actions/multi-platform/#distribute-build-across-multiple-runners @@ -20,7 +20,7 @@ jobs: platform: - linux/amd64 - linux/arm64 - if: github.repository == 'misskey-dev/misskey' + if: github.repository == '4ster1sk/misskey' steps: - name: Prepare run: | @@ -30,11 +30,16 @@ jobs: uses: actions/checkout@v4.1.1 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - - name: Log in to Docker Hub + - name: Log in to GitHub Container Registry uses: docker/login-action@v3 with: - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_PASSWORD }} + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Update version + run : | + commit_id=$(git rev-parse --short HEAD) + jq --arg commit_id "$commit_id" '.version += "-build-" + $commit_id' package.json > tmp.json && mv tmp.json package.json - name: Build and push by digest id: build uses: docker/build-push-action@v6 @@ -73,11 +78,13 @@ jobs: merge-multiple: true - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - - name: Login to Docker Hub + - name: Login to GitHub Container Registry uses: docker/login-action@v3 with: - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_PASSWORD }} + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + tags: ${{ env.REGISTRY_IMAGE }}:develop - name: Create manifest list and push working-directory: /tmp/digests run: | diff --git a/locales/en-US.yml b/locales/en-US.yml index 69e6da1a6f44..428d7dfd8d0e 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -326,6 +326,8 @@ lightThemes: "Light themes" darkThemes: "Dark themes" syncDeviceDarkMode: "Sync Dark Mode with your device settings" drive: "Drive" +driveSearchbarPlaceholder: "Search drive" +driveSearchNotFound: "{query} not found" fileName: "Filename" selectFile: "Select a file" selectFiles: "Select files" @@ -1776,6 +1778,7 @@ _role: canUpdateBioMedia: "Can edit an icon or a banner image" pinMax: "Maximum number of pinned notes" antennaMax: "Maximum number of antennas" + antennaNotesMax: "Maximum number of notes stored in antennas" wordMuteMax: "Maximum number of characters allowed in word mutes" webhookMax: "Maximum number of Webhooks" clipMax: "Maximum number of Clips" @@ -2732,6 +2735,11 @@ _embedCodeGen: generateCode: "Generate embed code" codeGenerated: "The code has been generated" codeGeneratedDescription: "Paste the generated code into your website to embed the content." +_searchSite: + google: "Google" + bing: "Bing" + duckduckgo: "DuckDuckGo" + yahoo: "Yahoo! Japan" _selfXssPrevention: warning: "WARNING" title: "\"Paste something on this screen\" is all a scam." diff --git a/locales/index.d.ts b/locales/index.d.ts index 2817242132b8..dddc21ba8a84 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -1322,6 +1322,14 @@ export interface Locale extends ILocale { * ドライブ */ "drive": string; + /** + * 検索ドライブ + */ + "driveSearchbarPlaceholder": string; + /** + * {query}は見つかりません + */ + "driveSearchNotFound": ParameterizedString<"query">; /** * ファイル名 */ @@ -6917,6 +6925,10 @@ export interface Locale extends ILocale { * ドライブ容量 */ "driveCapacity": string; + /** + * ファイルサイズ上限 + */ + "fileSizeLimit": string; /** * ファイルにNSFWを常に付与 */ @@ -6933,6 +6945,10 @@ export interface Locale extends ILocale { * アンテナの作成可能数 */ "antennaMax": string; + /** + * アンテナに保持する最大ノート数 + */ + "antennaNotesMax": string; /** * ワードミュートの最大文字数 */ @@ -10569,6 +10585,24 @@ export interface Locale extends ILocale { */ "codeGeneratedDescription": string; }; + "_searchSite": { + /** + * Google + */ + "google": string; + /** + * Bing + */ + "bing": string; + /** + * DuckDuckGo + */ + "duckduckgo": string; + /** + * Yahoo! Japan + */ + "yahoo": string; + }; "_selfXssPrevention": { /** * 警告 @@ -10601,6 +10635,16 @@ export interface Locale extends ILocale { */ "sent": string; }; + "_customizeFeature": { + /** + * 独自機能 + */ + "title": string; + /** + * Signupの無効化 + */ + "disableSignup": string; + }; "_remoteLookupErrors": { "_federationNotAllowed": { /** diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 68a634541b92..a348ef54db16 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -326,6 +326,8 @@ lightThemes: "明るいテーマ" darkThemes: "暗いテーマ" syncDeviceDarkMode: "デバイスのダークモードと同期する" drive: "ドライブ" +driveSearchbarPlaceholder: "検索ドライブ" +driveSearchNotFound: "{query}は見つかりません" fileName: "ファイル名" selectFile: "ファイルを選択" selectFiles: "ファイルを選択" @@ -1788,10 +1790,12 @@ _role: canManageCustomEmojis: "カスタム絵文字の管理" canManageAvatarDecorations: "アバターデコレーションの管理" driveCapacity: "ドライブ容量" + fileSizeLimit: "ファイルサイズ上限" alwaysMarkNsfw: "ファイルにNSFWを常に付与" canUpdateBioMedia: "アイコンとバナーの更新を許可" pinMax: "ノートのピン留めの最大数" antennaMax: "アンテナの作成可能数" + antennaNotesMax: "アンテナに保持する最大ノート数" wordMuteMax: "ワードミュートの最大文字数" webhookMax: "Webhookの作成可能数" clipMax: "クリップの作成可能数" @@ -2815,6 +2819,11 @@ _embedCodeGen: generateCode: "埋め込みコードを作成" codeGenerated: "コードが生成されました" codeGeneratedDescription: "生成されたコードをウェブサイトに貼り付けてご利用ください。" +_searchSite: + google: "Google" + bing: "Bing" + duckduckgo: "DuckDuckGo" + yahoo: "Yahoo! Japan" _selfXssPrevention: warning: "警告" @@ -2827,6 +2836,10 @@ _followRequest: recieved: "受け取った申請" sent: "送った申請" +_customizeFeature: + title: "独自機能" + disableSignup: "Signupの無効化" + _remoteLookupErrors: _federationNotAllowed: title: "このサーバーとは通信できません" diff --git a/packages/backend/assets/robots.txt b/packages/backend/assets/robots.txt index dc17e04e3f31..622e5d7e7e7c 100644 --- a/packages/backend/assets/robots.txt +++ b/packages/backend/assets/robots.txt @@ -1,4 +1,4 @@ user-agent: * -allow: / +disallow: / # todo: sitemap diff --git a/packages/backend/migration/1733996957482-signinButtonToToggle.js b/packages/backend/migration/1733996957482-signinButtonToToggle.js new file mode 100644 index 000000000000..8c4cd1ebfb43 --- /dev/null +++ b/packages/backend/migration/1733996957482-signinButtonToToggle.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: ruru + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class SigninButtonToToggle1733996957482 { + name = 'SigninButtonToToggle1733996957482' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "disableSignup" boolean NOT NULL DEFAULT false`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "disableSignup"`); + } +} diff --git a/packages/backend/src/core/AntennaService.ts b/packages/backend/src/core/AntennaService.ts index e827ffa68c0f..e3e23fd8a1d4 100644 --- a/packages/backend/src/core/AntennaService.ts +++ b/packages/backend/src/core/AntennaService.ts @@ -17,6 +17,7 @@ import { UtilityService } from '@/core/UtilityService.js'; import { bindThis } from '@/decorators.js'; import type { GlobalEvents } from '@/core/GlobalEventService.js'; import { FanoutTimelineService } from '@/core/FanoutTimelineService.js'; +import { RolePolicies, RoleService } from '@/core/RoleService.js'; import type { OnApplicationShutdown } from '@nestjs/common'; @Injectable() @@ -40,6 +41,7 @@ export class AntennaService implements OnApplicationShutdown { private utilityService: UtilityService, private globalEventService: GlobalEventService, private fanoutTimelineService: FanoutTimelineService, + private roleService: RoleService, ) { this.antennasFetched = false; this.antennas = []; @@ -99,8 +101,14 @@ export class AntennaService implements OnApplicationShutdown { const redisPipeline = this.redisForTimelines.pipeline(); + const policies = new Map((await Promise.allSettled(Array.from(new Set(matchedAntennas.map(antenna => antenna.userId))).map(async userId => [userId, await this.roleService.getUserPolicies(userId)] as const))) + .filter((result): result is PromiseFulfilledResult<[string, RolePolicies]> => result.status === 'fulfilled') + .map(result => result.value)); + for (const antenna of matchedAntennas) { - this.fanoutTimelineService.push(`antennaTimeline:${antenna.id}`, note.id, 200, redisPipeline); + const { antennaNotesLimit } = policies.get(antenna.userId) ?? await this.roleService.getUserPolicies(antenna.userId); + + this.fanoutTimelineService.push(`antennaTimeline:${antenna.id}`, note.id, antennaNotesLimit, redisPipeline); this.globalEventService.publishAntennaStream(antenna.id, 'note', note); } diff --git a/packages/backend/src/core/CustomEmojiService.ts b/packages/backend/src/core/CustomEmojiService.ts index 45661134491e..defb7cc92cee 100644 --- a/packages/backend/src/core/CustomEmojiService.ts +++ b/packages/backend/src/core/CustomEmojiService.ts @@ -88,7 +88,7 @@ export class CustomEmojiService implements OnApplicationShutdown { this.localEmojisCache.refresh(); this.globalEventService.publishBroadcastStream('emojiAdded', { - emoji: await this.emojiEntityService.packDetailed(emoji.id), + emoji: await this.emojiEntityService.packDetailed(emoji), }); if (moderator) { diff --git a/packages/backend/src/core/DriveService.ts b/packages/backend/src/core/DriveService.ts index c332e5a0a8f3..0ed43868e54e 100644 --- a/packages/backend/src/core/DriveService.ts +++ b/packages/backend/src/core/DriveService.ts @@ -173,7 +173,8 @@ export class DriveService { ?? `${ this.meta.objectStorageUseSSL ? 'https' : 'http' }://${ this.meta.objectStorageEndpoint }${ this.meta.objectStoragePort ? `:${this.meta.objectStoragePort}` : '' }/${ this.meta.objectStorageBucket }`; // for original - const key = `${this.meta.objectStoragePrefix}/${randomUUID()}${ext}`; + const p = `${this.meta.objectStoragePrefix ? this.meta.objectStoragePrefix + '/' : ''}`; + const key = `${p}${randomUUID()}${ext}`; const url = `${ baseUrl }/${ key }`; // for alts @@ -190,7 +191,7 @@ export class DriveService { ]; if (alts.webpublic) { - webpublicKey = `${this.meta.objectStoragePrefix}/webpublic-${randomUUID()}.${alts.webpublic.ext}`; + webpublicKey = `${p}webpublic-${randomUUID()}.${alts.webpublic.ext}`; webpublicUrl = `${ baseUrl }/${ webpublicKey }`; this.registerLogger.info(`uploading webpublic: ${webpublicKey}`); @@ -198,7 +199,7 @@ export class DriveService { } if (alts.thumbnail) { - thumbnailKey = `${this.meta.objectStoragePrefix}/thumbnail-${randomUUID()}.${alts.thumbnail.ext}`; + thumbnailKey = `${p}thumbnail-${randomUUID()}.${alts.thumbnail.ext}`; thumbnailUrl = `${ baseUrl }/${ thumbnailKey }`; this.registerLogger.info(`uploading thumbnail: ${thumbnailKey}`); @@ -478,6 +479,12 @@ export class DriveService { sensitiveThresholdForPorn: 0.75, enableSensitiveMediaDetectionForVideos: this.meta.enableSensitiveMediaDetectionForVideos, }); + //ファイル単位の容量制限チェック + if (user == null) { + //system user skip + } else if (info.size > (await this.roleService.getUserPolicies(user.id)).fileSizeLimit * 1024 * 1024) { + throw new IdentifiableError('e5989b6d-ae66-49ed-88af-516ded10ca0c', 'File size limit over'); + } this.registerLogger.info(`${JSON.stringify(info)}`); // 現状 false positive が多すぎて実用に耐えない diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 56ddcefd7c46..a299ec8051c5 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -56,6 +56,8 @@ import { isReply } from '@/misc/is-reply.js'; import { trackPromise } from '@/misc/promise-tracker.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; import { CollapsedQueue } from '@/misc/collapsed-queue.js'; +import type Logger from '@/logger.js'; +import { LoggerService } from '@/core/LoggerService.js'; import { CacheService } from '@/core/CacheService.js'; type NotificationType = 'reply' | 'renote' | 'quote' | 'mention'; @@ -149,6 +151,7 @@ type Option = { export class NoteCreateService implements OnApplicationShutdown { #shutdownController = new AbortController(); private updateNotesCountQueue: CollapsedQueue; + private logger: Logger; constructor( @Inject(DI.config) @@ -218,9 +221,11 @@ export class NoteCreateService implements OnApplicationShutdown { private instanceChart: InstanceChart, private utilityService: UtilityService, private userBlockingService: UserBlockingService, + private loggerService: LoggerService, private cacheService: CacheService, ) { this.updateNotesCountQueue = new CollapsedQueue(process.env.NODE_ENV !== 'test' ? 60 * 1000 * 5 : 0, this.collapseNotesCount, this.performUpdateNotesCount); + this.logger = this.loggerService.getLogger('noteCreateService'); } @bindThis @@ -368,6 +373,18 @@ export class NoteCreateService implements OnApplicationShutdown { // if the host is media-silenced, custom emojis are not allowed if (this.utilityService.isMediaSilencedHost(this.meta.mediaSilencedHosts, user.host)) emojis = []; + const willCauseNotification = mentionedUsers.some(u => u.host === null) + || (data.visibility === 'specified' && data.visibleUsers?.some(u => u.host === null)) + || data.reply?.userHost === null || (this.isQuote(data) && data.renote?.userHost === null) || false; + + if (process.env.MISSKEY_BLOCK_MENTIONS_FROM_UNFAMILIAR_REMOTE_USERS === 'true' && user.host !== null && willCauseNotification) { + const userEntity = await this.usersRepository.findOneBy({ id: user.id }); + if ((userEntity?.followersCount ?? 0) === 0) { + this.logger.error('Request rejected because user has no local followers', { user: user.id, note: data }); + throw new IdentifiableError('e11b3a16-f543-4885-8eb1-66cad131dbfd', 'Notes including mentions, replies, or renotes from remote users are not allowed until user has at least one local follower.'); + } + } + tags = tags.filter(tag => Array.from(tag).length <= 128).splice(0, 32); if (data.reply && (user.id !== data.reply.userId) && !mentionedUsers.some(u => u.id === data.reply!.userId)) { @@ -742,7 +759,7 @@ export class NoteCreateService implements OnApplicationShutdown { } @bindThis - private isQuote(note: Option & { renote: MiNote }): note is Option & { renote: MiNote } & ( + private isQuote(note: Option): note is Option & { renote: MiNote } & ( { text: string } | { cw: string } | { reply: MiNote } | { poll: IPoll } | { files: MiDriveFile[] } ) { // NOTE: SYNC WITH misc/is-quote.ts diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts index 5af6b0594253..d6aeb2b00eb2 100644 --- a/packages/backend/src/core/RoleService.ts +++ b/packages/backend/src/core/RoleService.ts @@ -50,6 +50,7 @@ export type RolePolicies = { canUpdateBioMedia: boolean; pinLimit: number; antennaLimit: number; + antennaNotesLimit: number; wordMuteLimit: number; webhookLimit: number; clipLimit: number; @@ -58,6 +59,7 @@ export type RolePolicies = { userEachUserListsLimit: number; rateLimitFactor: number; avatarDecorationLimit: number; + fileSizeLimit: number; canImportAntennas: boolean; canImportBlocking: boolean; canImportFollowing: boolean; @@ -84,6 +86,7 @@ export const DEFAULT_POLICIES: RolePolicies = { canUpdateBioMedia: true, pinLimit: 5, antennaLimit: 5, + antennaNotesLimit: 200, wordMuteLimit: 200, webhookLimit: 3, clipLimit: 10, @@ -92,6 +95,7 @@ export const DEFAULT_POLICIES: RolePolicies = { userEachUserListsLimit: 50, rateLimitFactor: 1, avatarDecorationLimit: 1, + fileSizeLimit: 50, canImportAntennas: true, canImportBlocking: true, canImportFollowing: true, @@ -389,6 +393,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { canUpdateBioMedia: calc('canUpdateBioMedia', vs => vs.some(v => v === true)), pinLimit: calc('pinLimit', vs => Math.max(...vs)), antennaLimit: calc('antennaLimit', vs => Math.max(...vs)), + antennaNotesLimit: calc('antennaNotesLimit', vs => Math.max(...vs)), wordMuteLimit: calc('wordMuteLimit', vs => Math.max(...vs)), webhookLimit: calc('webhookLimit', vs => Math.max(...vs)), clipLimit: calc('clipLimit', vs => Math.max(...vs)), @@ -397,6 +402,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { userEachUserListsLimit: calc('userEachUserListsLimit', vs => Math.max(...vs)), rateLimitFactor: calc('rateLimitFactor', vs => Math.max(...vs)), avatarDecorationLimit: calc('avatarDecorationLimit', vs => Math.max(...vs)), + fileSizeLimit: calc('fileSizeLimit', vs => Math.max(...vs)), canImportAntennas: calc('canImportAntennas', vs => vs.some(v => v === true)), canImportBlocking: calc('canImportBlocking', vs => vs.some(v => v === true)), canImportFollowing: calc('canImportFollowing', vs => vs.some(v => v === true)), diff --git a/packages/backend/src/core/activitypub/ApInboxService.ts b/packages/backend/src/core/activitypub/ApInboxService.ts index 21c7adf7b269..5ca31f06450a 100644 --- a/packages/backend/src/core/activitypub/ApInboxService.ts +++ b/packages/backend/src/core/activitypub/ApInboxService.ts @@ -29,7 +29,7 @@ import type { MiRemoteUser } from '@/models/User.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { AbuseReportService } from '@/core/AbuseReportService.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; -import { getApHrefNullable, getApId, getApIds, getApType, isAccept, isActor, isAdd, isAnnounce, isBlock, isCollection, isCollectionOrOrderedCollection, isCreate, isDelete, isFlag, isFollow, isLike, isMove, isPost, isReject, isRemove, isTombstone, isUndo, isUpdate, validActor, validPost } from './type.js'; +import { getApHrefNullable, getApId, getApIds, getApType, getApUrl, isAccept, isActor, isAdd, isAnnounce, isBlock, isCollection, isCollectionOrOrderedCollection, isCreate, isDelete, isFlag, isFollow, isLike, isMove, isPost, isReject, isRemove, isTombstone, isUndo, isUpdate, validActor, validPost } from './type.js'; import { ApNoteService } from './models/ApNoteService.js'; import { ApLoggerService } from './ApLoggerService.js'; import { ApDbResolverService } from './ApDbResolverService.js'; @@ -280,8 +280,9 @@ export class ApInboxService { @bindThis private async announce(actor: MiRemoteUser, activity: IAnnounce, resolver?: Resolver): Promise { const uri = getApId(activity); + const url = getApUrl(activity); - this.logger.info(`Announce: ${uri}`); + this.logger.info(`Announce: ${uri} / ${url}`); // eslint-disable-next-line no-param-reassign resolver ??= this.apResolverService.createResolver(); @@ -303,6 +304,7 @@ export class ApInboxService { @bindThis private async announceNote(actor: MiRemoteUser, activity: IAnnounce, target: IPost, resolver?: Resolver): Promise { const uri = getApId(activity); + const url = getApUrl(activity); if (actor.isSuspended) { return; @@ -355,6 +357,7 @@ export class ApInboxService { visibility: activityAudience.visibility, visibleUsers: activityAudience.visibleUsers, uri, + url: url, }); } finally { unlock(); @@ -444,7 +447,7 @@ export class ApInboxService { const exist = await this.apNoteService.fetchNote(note); if (exist) return 'skip: note exists'; - await this.apNoteService.createNote(note, actor, resolver, silent); + await this.apNoteService.createNote(note, actor, resolver, silent, true); return 'ok'; } catch (err) { if (err instanceof StatusError && !err.isRetryable) { diff --git a/packages/backend/src/core/activitypub/models/ApNoteService.ts b/packages/backend/src/core/activitypub/models/ApNoteService.ts index 7758da8b4596..680b6317a895 100644 --- a/packages/backend/src/core/activitypub/models/ApNoteService.ts +++ b/packages/backend/src/core/activitypub/models/ApNoteService.ts @@ -123,7 +123,7 @@ export class ApNoteService { * Noteを作成します。 */ @bindThis - public async createNote(value: string | IObject, actor?: MiRemoteUser, resolver?: Resolver, silent = false): Promise { + public async createNote(value: string | IObject, actor?: MiRemoteUser, resolver?: Resolver, silent = false, checkDelay = false): Promise { // eslint-disable-next-line no-param-reassign if (resolver == null) resolver = this.apResolverService.createResolver(); @@ -318,6 +318,11 @@ export class ApNoteService { const apEmojis = emojis.map(emoji => emoji.name); + if (checkDelay && note.published) { + const delay = Date.now() - new Date(note.published).getTime(); + this.logger.info(`Note Received host: ${actor.host}, delay: ${delay}ms`); + } + try { return await this.noteCreateService.create(actor, { createdAt: note.published ? new Date(note.published) : null, diff --git a/packages/backend/src/core/activitypub/type.ts b/packages/backend/src/core/activitypub/type.ts index 7496315f0970..582f2451d7c9 100644 --- a/packages/backend/src/core/activitypub/type.ts +++ b/packages/backend/src/core/activitypub/type.ts @@ -62,6 +62,12 @@ export function getApId(value: string | IObject): string { throw new Error('cannot detemine id'); } +export function getApUrl(value: string | IActivity): string | null { + if (typeof value === 'string') return value; + if (typeof value.object === 'string') return value.object; + return null; +} + /** * Get ActivityStreams Object type * diff --git a/packages/backend/src/core/entities/MetaEntityService.ts b/packages/backend/src/core/entities/MetaEntityService.ts index 409dca34263b..6eeea9050595 100644 --- a/packages/backend/src/core/entities/MetaEntityService.ts +++ b/packages/backend/src/core/entities/MetaEntityService.ts @@ -99,6 +99,7 @@ export class MetaEntityService { enableTestcaptcha: instance.enableTestcaptcha, swPublickey: instance.swPublicKey, themeColor: instance.themeColor, + disableSignup: instance.disableSignup, mascotImageUrl: instance.mascotImageUrl ?? '/assets/ai.png', bannerUrl: instance.bannerUrl, infoImageUrl: instance.infoImageUrl, @@ -131,7 +132,6 @@ export class MetaEntityService { mediaProxy: this.config.mediaProxy, enableUrlPreview: instance.urlPreviewEnabled, noteSearchableScope: (this.config.meilisearch == null || this.config.meilisearch.scope !== 'local') ? 'global' : 'local', - maxFileSize: this.config.maxFileSize, }; return packed; diff --git a/packages/backend/src/daemons/QueueStatsService.ts b/packages/backend/src/daemons/QueueStatsService.ts index ede104b9fe54..b656caf4b5dc 100644 --- a/packages/backend/src/daemons/QueueStatsService.ts +++ b/packages/backend/src/daemons/QueueStatsService.ts @@ -10,8 +10,10 @@ import { QueueService } from '@/core/QueueService.js'; import { bindThis } from '@/decorators.js'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; +import type Logger from '@/logger.js'; import { QUEUE, baseQueueOptions } from '@/queue/const.js'; import type { OnApplicationShutdown } from '@nestjs/common'; +import { LoggerService } from '@/core/LoggerService.js'; const ev = new Xev(); @@ -19,14 +21,17 @@ const interval = 10000; @Injectable() export class QueueStatsService implements OnApplicationShutdown { + private logger: Logger; private intervalId: NodeJS.Timeout; constructor( @Inject(DI.config) private config: Config, + private loggerService: LoggerService, private queueService: QueueService, ) { + this.logger = this.loggerService.getLogger('queue status'); } /** @@ -73,6 +78,9 @@ export class QueueStatsService implements OnApplicationShutdown { }, }; + this.logger.info(`Deliver active: ${stats.deliver.active}, delayed: ${stats.deliver.delayed}, waiting: ${stats.deliver.waiting}`); + this.logger.info(`Inbox active: ${stats.inbox.active}, delayed: ${stats.inbox.delayed}, waiting: ${stats.inbox.waiting}`); + ev.emit('queueStats', stats); log.unshift(stats); diff --git a/packages/backend/src/models/Meta.ts b/packages/backend/src/models/Meta.ts index 3acd8bea4a1c..a8882c391e0f 100644 --- a/packages/backend/src/models/Meta.ts +++ b/packages/backend/src/models/Meta.ts @@ -663,4 +663,9 @@ export class MiMeta { default: '{}', }) public federationHosts: string[]; + + @Column('boolean', { + default: false, + }) + public disableSignup: boolean; } diff --git a/packages/backend/src/models/_.ts b/packages/backend/src/models/_.ts index c72bdaa72726..458c9b9e2034 100644 --- a/packages/backend/src/models/_.ts +++ b/packages/backend/src/models/_.ts @@ -3,13 +3,12 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { FindOneOptions, InsertQueryBuilder, ObjectLiteral, Repository, SelectQueryBuilder, TypeORMError } from 'typeorm'; -import { DriverUtils } from 'typeorm/driver/DriverUtils.js'; +import { FindOneOptions, InsertQueryBuilder, ObjectLiteral, Repository, SelectQueryBuilder } from 'typeorm'; import { RelationCountLoader } from 'typeorm/query-builder/relation-count/RelationCountLoader.js'; import { RelationIdLoader } from 'typeorm/query-builder/relation-id/RelationIdLoader.js'; -import { RawSqlResultsToEntityTransformer } from 'typeorm/query-builder/transformer/RawSqlResultsToEntityTransformer.js'; -import { ObjectUtils } from 'typeorm/util/ObjectUtils.js'; -import { OrmUtils } from 'typeorm/util/OrmUtils.js'; +import { + RawSqlResultsToEntityTransformer, +} from 'typeorm/query-builder/transformer/RawSqlResultsToEntityTransformer.js'; import { MiAbuseUserReport } from '@/models/AbuseUserReport.js'; import { MiAbuseReportNotificationRecipient } from '@/models/AbuseReportNotificationRecipient.js'; import { MiAccessToken } from '@/models/AccessToken.js'; @@ -99,19 +98,25 @@ export const miRepository = { mainAlias.name = 't'; const columnNames = this.createTableColumnNames(); queryBuilder.returning(columnNames.reduce((a, c) => `${a}, ${queryBuilder.escape(c)}`, '').slice(2)); - const builder = this.createQueryBuilder().addCommonTableExpression(queryBuilder, 'cte', { columnNames }); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - builder.expressionMap.mainAlias!.tablePath = 'cte'; - this.selectAliasColumnNames(queryBuilder, builder); - if (findOptions) { - builder.setFindOptions(findOptions); + + const queryRunner = this.manager.connection.createQueryRunner('master'); + try { + const builder = this.createQueryBuilder(undefined, queryRunner).addCommonTableExpression(queryBuilder, 'cte', { columnNames }); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + builder.expressionMap.mainAlias!.tablePath = 'cte'; + this.selectAliasColumnNames(queryBuilder, builder); + if (findOptions) { + builder.setFindOptions(findOptions); + } + const raw = await builder.execute(); + mainAlias.name = name; + const relationId = await new RelationIdLoader(builder.connection, this.queryRunner, builder.expressionMap.relationIdAttributes).load(raw); + const relationCount = await new RelationCountLoader(builder.connection, this.queryRunner, builder.expressionMap.relationCountAttributes).load(raw); + const result = new RawSqlResultsToEntityTransformer(builder.expressionMap, builder.connection.driver, relationId, relationCount, this.queryRunner).transform(raw, mainAlias); + return result[0]; + } finally { + await queryRunner.release(); } - const raw = await builder.execute(); - mainAlias.name = name; - const relationId = await new RelationIdLoader(builder.connection, this.queryRunner, builder.expressionMap.relationIdAttributes).load(raw); - const relationCount = await new RelationCountLoader(builder.connection, this.queryRunner, builder.expressionMap.relationCountAttributes).load(raw); - const result = new RawSqlResultsToEntityTransformer(builder.expressionMap, builder.connection.driver, relationId, relationCount, this.queryRunner).transform(raw, mainAlias); - return result[0]; }, selectAliasColumnNames(queryBuilder, builder) { let selectOrAddSelect = (selection: string, selectionAliasName?: string) => { diff --git a/packages/backend/src/models/json-schema/meta.ts b/packages/backend/src/models/json-schema/meta.ts index e3fd63464a81..f63f70736cf3 100644 --- a/packages/backend/src/models/json-schema/meta.ts +++ b/packages/backend/src/models/json-schema/meta.ts @@ -75,6 +75,10 @@ export const packedMetaLiteSchema = { type: 'boolean', optional: false, nullable: false, }, + disableSignup: { + type: 'boolean', + optional: false, nullable: false, + }, emailRequiredForSignup: { type: 'boolean', optional: false, nullable: false, @@ -257,10 +261,6 @@ export const packedMetaLiteSchema = { optional: false, nullable: false, default: 'local', }, - maxFileSize: { - type: 'number', - optional: false, nullable: false, - }, }, } as const; diff --git a/packages/backend/src/models/json-schema/role.ts b/packages/backend/src/models/json-schema/role.ts index 3537de94c891..fff3fdf05318 100644 --- a/packages/backend/src/models/json-schema/role.ts +++ b/packages/backend/src/models/json-schema/role.ts @@ -240,6 +240,10 @@ export const packedRolePoliciesSchema = { type: 'integer', optional: false, nullable: false, }, + antennaNotesLimit: { + type: 'integer', + optional: false, nullable: false, + }, wordMuteLimit: { type: 'integer', optional: false, nullable: false, @@ -272,6 +276,9 @@ export const packedRolePoliciesSchema = { type: 'integer', optional: false, nullable: false, }, + fileSizeLimit: { + type: 'integer', + }, canImportAntennas: { type: 'boolean', optional: false, nullable: false, diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts index 251a03c303a7..05f2340adf73 100644 --- a/packages/backend/src/postgres.ts +++ b/packages/backend/src/postgres.ts @@ -7,6 +7,7 @@ import pg from 'pg'; import { DataSource, Logger } from 'typeorm'; import * as highlight from 'cli-highlight'; +import { type QueryRunner } from 'typeorm'; import { entities as charts } from '@/core/chart/entities.js'; import { MiAbuseUserReport } from '@/models/AbuseUserReport.js'; @@ -98,18 +99,24 @@ class MyCustomLogger implements Logger { } @bindThis - public logQuery(query: string, parameters?: any[]) { - sqlLogger.info(this.highlight(query).substring(0, 100)); + private replicationMode(runner?: QueryRunner) { + const mode = runner?.getReplicationMode(); + return mode ? `[${mode}]` : '[default]'; } @bindThis - public logQueryError(error: string, query: string, parameters?: any[]) { - sqlLogger.error(this.highlight(query)); + public logQuery(query: string, parameters?: any[], queryRunner?: QueryRunner) { + sqlLogger.info(this.replicationMode(queryRunner) + ' ' + this.highlight(query).substring(0, 100)); } @bindThis - public logQuerySlow(time: number, query: string, parameters?: any[]) { - sqlLogger.warn(this.highlight(query)); + public logQueryError(error: string, query: string, parameters?: any[], queryRunner?: QueryRunner) { + sqlLogger.error(this.replicationMode(queryRunner) + ' ' + this.highlight(query)); + } + + @bindThis + public logQuerySlow(time: number, query: string, parameters?: any[], queryRunner?: QueryRunner) { + sqlLogger.warn(this.replicationMode(queryRunner) + ' ' + this.highlight(query)); } @bindThis diff --git a/packages/backend/src/server/ActivityPubServerService.ts b/packages/backend/src/server/ActivityPubServerService.ts index f34f6583d359..5fefb875cc4c 100644 --- a/packages/backend/src/server/ActivityPubServerService.ts +++ b/packages/backend/src/server/ActivityPubServerService.ts @@ -29,6 +29,8 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { bindThis } from '@/decorators.js'; import { IActivity } from '@/core/activitypub/type.js'; import { isQuote, isRenote } from '@/misc/is-renote.js'; +import type Logger from '@/logger.js'; +import { LoggerService } from '@/core/LoggerService.js'; import * as Acct from '@/misc/acct.js'; import type { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions, FastifyBodyParser } from 'fastify'; import type { FindOptionsWhere } from 'typeorm'; @@ -38,6 +40,8 @@ const LD_JSON = 'application/ld+json; profile="https://www.w3.org/ns/activitystr @Injectable() export class ActivityPubServerService { + private logger: Logger; + constructor( @Inject(DI.config) private config: Config, @@ -72,8 +76,10 @@ export class ActivityPubServerService { private queueService: QueueService, private userKeypairService: UserKeypairService, private queryService: QueryService, + private loggerService: LoggerService, ) { //this.createServer = this.createServer.bind(this); + this.logger = this.loggerService.getLogger('activityPubServerService'); } @bindThis @@ -163,6 +169,12 @@ export class ActivityPubServerService { } } + if (request.headers.date) { + const delay = Date.now() - new Date(request.headers.date).getTime(); + const host = this.utilityService.toPuny(new URL(signature.keyId).hostname); + this.logger.info(`Inbox host: ${host}, delay: ${delay}ms`); + } + this.queueService.inbox(request.body as IActivity, signature); reply.code(202); diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index 5bb194313d2c..9d0a6ffb15ef 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -376,6 +376,7 @@ import * as ep___users_reportAbuse from './endpoints/users/report-abuse.js'; import * as ep___users_searchByUsernameAndHost from './endpoints/users/search-by-username-and-host.js'; import * as ep___users_search from './endpoints/users/search.js'; import * as ep___users_show from './endpoints/users/show.js'; +import * as ep___users_stats from './endpoints/users/stats.js'; import * as ep___users_achievements from './endpoints/users/achievements.js'; import * as ep___users_updateMemo from './endpoints/users/update-memo.js'; import * as ep___fetchRss from './endpoints/fetch-rss.js'; @@ -764,6 +765,7 @@ const $users_reportAbuse: Provider = { provide: 'ep:users/report-abuse', useClas const $users_searchByUsernameAndHost: Provider = { provide: 'ep:users/search-by-username-and-host', useClass: ep___users_searchByUsernameAndHost.default }; const $users_search: Provider = { provide: 'ep:users/search', useClass: ep___users_search.default }; const $users_show: Provider = { provide: 'ep:users/show', useClass: ep___users_show.default }; +const $users_stats: Provider = { provide: 'ep:users/stats', useClass: ep___users_stats.default }; const $users_achievements: Provider = { provide: 'ep:users/achievements', useClass: ep___users_achievements.default }; const $users_updateMemo: Provider = { provide: 'ep:users/update-memo', useClass: ep___users_updateMemo.default }; const $fetchRss: Provider = { provide: 'ep:fetch-rss', useClass: ep___fetchRss.default }; @@ -1156,6 +1158,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ $users_searchByUsernameAndHost, $users_search, $users_show, + $users_stats, $users_achievements, $users_updateMemo, $fetchRss, @@ -1539,6 +1542,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ $users_searchByUsernameAndHost, $users_search, $users_show, + $users_stats, $users_achievements, $users_updateMemo, $fetchRss, diff --git a/packages/backend/src/server/api/SignupApiService.ts b/packages/backend/src/server/api/SignupApiService.ts index 3ec5e5d3e6ce..3137d6574ac3 100644 --- a/packages/backend/src/server/api/SignupApiService.ts +++ b/packages/backend/src/server/api/SignupApiService.ts @@ -129,6 +129,11 @@ export class SignupApiService { let ticket: MiRegistrationTicket | null = null; + if (this.meta.disableSignup) { + reply.code(400); + return; + } + if (this.meta.disableRegistration) { if (invitationCode == null || typeof invitationCode !== 'string') { reply.code(400); diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index 15809b2678ad..fc1294bdc607 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -382,6 +382,7 @@ import * as ep___users_reportAbuse from './endpoints/users/report-abuse.js'; import * as ep___users_searchByUsernameAndHost from './endpoints/users/search-by-username-and-host.js'; import * as ep___users_search from './endpoints/users/search.js'; import * as ep___users_show from './endpoints/users/show.js'; +import * as ep___users_stats from './endpoints/users/stats.js'; import * as ep___users_achievements from './endpoints/users/achievements.js'; import * as ep___users_updateMemo from './endpoints/users/update-memo.js'; import * as ep___fetchRss from './endpoints/fetch-rss.js'; @@ -768,6 +769,7 @@ const eps = [ ['users/search-by-username-and-host', ep___users_searchByUsernameAndHost], ['users/search', ep___users_search], ['users/show', ep___users_show], + ['users/stats', ep___users_stats], ['users/achievements', ep___users_achievements], ['users/update-memo', ep___users_updateMemo], ['fetch-rss', ep___fetchRss], diff --git a/packages/backend/src/server/api/endpoints/admin/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts index 1bd8914cfb3c..f135c01614c1 100644 --- a/packages/backend/src/server/api/endpoints/admin/meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/meta.ts @@ -21,6 +21,10 @@ export const meta = { type: 'object', optional: false, nullable: false, properties: { + disableSignup: { + type: 'boolean', + optional: false, nullable: false, + }, cacheRemoteFiles: { type: 'boolean', optional: false, nullable: false, @@ -567,6 +571,7 @@ export default class extends Endpoint { // eslint- impressumUrl: instance.impressumUrl, privacyPolicyUrl: instance.privacyPolicyUrl, inquiryUrl: instance.inquiryUrl, + disableSignup: instance.disableSignup, disableRegistration: instance.disableRegistration, emailRequiredForSignup: instance.emailRequiredForSignup, enableHcaptcha: instance.enableHcaptcha, 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 a450059650c0..0519c228edc7 100644 --- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts @@ -191,6 +191,7 @@ export const paramDef = { type: 'string', }, }, + disableSignup: { type: 'boolean' }, }, required: [], } as const; @@ -313,6 +314,10 @@ export default class extends Endpoint { // eslint- set.cacheRemoteFiles = ps.cacheRemoteFiles; } + if (ps.disableSignup !== undefined) { + set.disableSignup = ps.disableSignup; + } + if (ps.cacheRemoteSensitiveFiles !== undefined) { set.cacheRemoteSensitiveFiles = ps.cacheRemoteSensitiveFiles; } diff --git a/packages/backend/src/server/api/endpoints/drive/files.ts b/packages/backend/src/server/api/endpoints/drive/files.ts index 10c521332d1a..6d286409961e 100644 --- a/packages/backend/src/server/api/endpoints/drive/files.ts +++ b/packages/backend/src/server/api/endpoints/drive/files.ts @@ -4,11 +4,13 @@ */ import { Inject, Injectable } from '@nestjs/common'; +import { Brackets } from 'typeorm'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { DriveFilesRepository } from '@/models/_.js'; import { QueryService } from '@/core/QueryService.js'; import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; import { DI } from '@/di-symbols.js'; +import { sqlLikeEscape } from '@/misc/sql-like-escape.js'; export const meta = { tags: ['drive'], @@ -37,6 +39,7 @@ export const paramDef = { folderId: { type: 'string', format: 'misskey:id', nullable: true, default: null }, type: { type: 'string', nullable: true, pattern: /^[a-zA-Z\/\-*]+$/.toString().slice(1, -1) }, sort: { type: 'string', nullable: true, enum: ['+createdAt', '-createdAt', '+name', '-name', '+size', '-size', null] }, + searchQuery: { type: 'string', default: '' }, }, required: [], } as const; @@ -60,6 +63,15 @@ export default class extends Endpoint { // eslint- query.andWhere('file.folderId IS NULL'); } + if (ps.searchQuery.length > 0) { + const args = { searchQuery: `%${sqlLikeEscape(ps.searchQuery.slice(0, 512))}%` }; + query.andWhere(new Brackets((qb) => { + qb + .where('file.name ILIKE :searchQuery', args) + .orWhere('file.comment ILIKE :searchQuery', args); + })); + } + if (ps.type) { if (ps.type.endsWith('/*')) { query.andWhere('file.type like :type', { type: ps.type.replace('/*', '/') + '%' }); diff --git a/packages/backend/src/server/api/endpoints/drive/files/create.ts b/packages/backend/src/server/api/endpoints/drive/files/create.ts index 74eb4dded7ce..9563502b6271 100644 --- a/packages/backend/src/server/api/endpoints/drive/files/create.ts +++ b/packages/backend/src/server/api/endpoints/drive/files/create.ts @@ -56,6 +56,11 @@ export const meta = { code: 'NO_FREE_SPACE', id: 'd08dbc37-a6a9-463a-8c47-96c32ab5f064', }, + invalidFileSize: { + message: 'File size exceeds limit.', + code: 'INVALID_FILE_SIZE', + id: '9068668f-0465-4c0e-8341-1c52fd6f5ab3', + }, }, } as const; @@ -115,6 +120,7 @@ export default class extends Endpoint { // eslint- if (err instanceof IdentifiableError) { if (err.id === '282f77bf-5816-4f72-9264-aa14d8261a21') throw new ApiError(meta.errors.inappropriate); if (err.id === 'c6244ed2-a39a-4e1c-bf93-f0fbd7764fa6') throw new ApiError(meta.errors.noFreeSpace); + if (err.id === 'e5989b6d-ae66-49ed-88af-516ded10ca0c') throw new ApiError(meta.errors.invalidFileSize); } throw new ApiError(); } finally { diff --git a/packages/backend/src/server/api/endpoints/drive/folders.ts b/packages/backend/src/server/api/endpoints/drive/folders.ts index 8c4848f8e15e..b4650da9a3a0 100644 --- a/packages/backend/src/server/api/endpoints/drive/folders.ts +++ b/packages/backend/src/server/api/endpoints/drive/folders.ts @@ -9,6 +9,7 @@ import type { DriveFoldersRepository } from '@/models/_.js'; import { QueryService } from '@/core/QueryService.js'; import { DriveFolderEntityService } from '@/core/entities/DriveFolderEntityService.js'; import { DI } from '@/di-symbols.js'; +import { sqlLikeEscape } from '@/misc/sql-like-escape.js'; export const meta = { tags: ['drive'], @@ -35,6 +36,7 @@ export const paramDef = { sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, folderId: { type: 'string', format: 'misskey:id', nullable: true, default: null }, + searchQuery: { type: 'string', default: '' }, }, required: [], } as const; @@ -58,6 +60,9 @@ export default class extends Endpoint { // eslint- query.andWhere('folder.parentId IS NULL'); } + if (ps.searchQuery.length > 0) { + query.andWhere('folder.name ILIKE :searchQuery', { searchQuery: `%${sqlLikeEscape(ps.searchQuery.slice(0, 512))}%` }); + } const folders = await query.limit(ps.limit).getMany(); return await Promise.all(folders.map(folder => this.driveFolderEntityService.pack(folder))); diff --git a/packages/backend/src/server/api/endpoints/i/apps.ts b/packages/backend/src/server/api/endpoints/i/apps.ts index 91c8597b1bd2..055b5cc061d1 100644 --- a/packages/backend/src/server/api/endpoints/i/apps.ts +++ b/packages/backend/src/server/api/endpoints/i/apps.ts @@ -87,7 +87,7 @@ export default class extends Endpoint { // eslint- name: token.name ?? token.app?.name, createdAt: this.idService.parse(token.id).date.toISOString(), lastUsedAt: token.lastUsedAt?.toISOString(), - permission: token.permission, + permission: token.app ? token.app.permission : token.permission, }))); }); } diff --git a/packages/backend/src/server/api/endpoints/stats.ts b/packages/backend/src/server/api/endpoints/stats.ts index 1e6983177f64..a5078552e5e8 100644 --- a/packages/backend/src/server/api/endpoints/stats.ts +++ b/packages/backend/src/server/api/endpoints/stats.ts @@ -4,7 +4,7 @@ */ import { Inject, Injectable } from '@nestjs/common'; -import type { InstancesRepository, NoteReactionsRepository } from '@/models/_.js'; +import type { InstancesRepository } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { DI } from '@/di-symbols.js'; import NotesChart from '@/core/chart/charts/notes.js'; @@ -63,9 +63,6 @@ export default class extends Endpoint { // eslint- @Inject(DI.instancesRepository) private instancesRepository: InstancesRepository, - @Inject(DI.noteReactionsRepository) - private noteReactionsRepository: NoteReactionsRepository, - private notesChart: NotesChart, private usersChart: UsersChart, ) { @@ -79,11 +76,9 @@ export default class extends Endpoint { // eslint- const originalUsersCount = usersChart.local.total[0]; const [ - reactionsCount, //originalReactionsCount, instances, ] = await Promise.all([ - this.noteReactionsRepository.count({ cache: 3600000 }), // 1 hour //this.noteReactionsRepository.count({ where: { userHost: IsNull() }, cache: 3600000 }), this.instancesRepository.count({ cache: 3600000 }), ]); @@ -93,7 +88,7 @@ export default class extends Endpoint { // eslint- originalNotesCount, usersCount, originalUsersCount, - reactionsCount, + reactionsCount: 0, //originalReactionsCount, instances, driveUsageLocal: 0, diff --git a/packages/backend/src/server/api/endpoints/users/show.ts b/packages/backend/src/server/api/endpoints/users/show.ts index 062326e28d1e..29fdf65e35d1 100644 --- a/packages/backend/src/server/api/endpoints/users/show.ts +++ b/packages/backend/src/server/api/endpoints/users/show.ts @@ -27,10 +27,21 @@ export const meta = { res: { optional: false, nullable: false, oneOf: [ + { + type: 'object', + ref: 'UserLite', + }, { type: 'object', ref: 'UserDetailed', }, + { + type: 'array', + items: { + type: 'object', + ref: 'UserLite', + }, + }, { type: 'array', items: { @@ -71,6 +82,7 @@ export const paramDef = { nullable: true, description: 'The local host is represented with `null`.', }, + detailed: { type: 'boolean', default: true }, }, anyOf: [ { required: ['userId'] }, @@ -117,7 +129,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.detailed ? 'UserDetailed' : 'UserLite', }) .then(users => new Map(users.map(u => [u.id, u]))); return _users.map(u => _userMap.get(u.id)!); } else { @@ -148,7 +160,7 @@ export default class extends Endpoint { // eslint- } return await this.userEntityService.pack(user, me, { - schema: 'UserDetailed', + schema: ps.detailed ? 'UserDetailed' : 'UserLite', }); } }); diff --git a/packages/backend/src/server/api/endpoints/users/stats.ts b/packages/backend/src/server/api/endpoints/users/stats.ts new file mode 100644 index 000000000000..e4175cf7efef --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/stats.ts @@ -0,0 +1,233 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { awaitAll } from '@/misc/prelude/await-all.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; +import { DI } from '@/di-symbols.js'; +import type { UsersRepository, NotesRepository, FollowingsRepository, DriveFilesRepository, NoteReactionsRepository, PageLikesRepository, NoteFavoritesRepository, PollVotesRepository } from '@/models/_.js'; +import { ApiError } from '../../error.js'; + +export const meta = { + tags: ['users'], + + requireCredential: false, + + description: 'Show statistics about a user.', + + errors: { + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: '9e638e45-3b25-4ef7-8f95-07e8498f1819', + }, + }, + + res: { + type: 'object', + optional: false, nullable: false, + properties: { + notesCount: { + type: 'integer', + optional: false, nullable: false, + }, + repliesCount: { + type: 'integer', + optional: false, nullable: false, + }, + renotesCount: { + type: 'integer', + optional: false, nullable: false, + }, + repliedCount: { + type: 'integer', + optional: false, nullable: false, + }, + renotedCount: { + type: 'integer', + optional: false, nullable: false, + }, + pollVotesCount: { + type: 'integer', + optional: false, nullable: false, + }, + pollVotedCount: { + type: 'integer', + optional: false, nullable: false, + }, + localFollowingCount: { + type: 'integer', + optional: false, nullable: false, + }, + remoteFollowingCount: { + type: 'integer', + optional: false, nullable: false, + }, + localFollowersCount: { + type: 'integer', + optional: false, nullable: false, + }, + remoteFollowersCount: { + type: 'integer', + optional: false, nullable: false, + }, + followingCount: { + type: 'integer', + optional: false, nullable: false, + }, + followersCount: { + type: 'integer', + optional: false, nullable: false, + }, + sentReactionsCount: { + type: 'integer', + optional: false, nullable: false, + }, + receivedReactionsCount: { + type: 'integer', + optional: false, nullable: false, + }, + noteFavoritesCount: { + type: 'integer', + optional: false, nullable: false, + }, + pageLikesCount: { + type: 'integer', + optional: false, nullable: false, + }, + pageLikedCount: { + type: 'integer', + optional: false, nullable: false, + }, + driveFilesCount: { + type: 'integer', + optional: false, nullable: false, + }, + driveUsage: { + type: 'integer', + optional: false, nullable: false, + description: 'Drive usage in bytes', + }, + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + userId: { type: 'string', format: 'misskey:id' }, + }, + required: ['userId'], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + @Inject(DI.followingsRepository) + private followingsRepository: FollowingsRepository, + + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, + + @Inject(DI.noteReactionsRepository) + private noteReactionsRepository: NoteReactionsRepository, + + @Inject(DI.pageLikesRepository) + private pageLikesRepository: PageLikesRepository, + + @Inject(DI.noteFavoritesRepository) + private noteFavoritesRepository: NoteFavoritesRepository, + + @Inject(DI.pollVotesRepository) + private pollVotesRepository: PollVotesRepository, + + private driveFileEntityService: DriveFileEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const user = await this.usersRepository.findOneBy({ id: ps.userId }); + if (user == null) { + throw new ApiError(meta.errors.noSuchUser); + } + + const result = await awaitAll({ + notesCount: this.notesRepository.createQueryBuilder('note') + .where('note.userId = :userId', { userId: user.id }) + .getCount(), + repliesCount: this.notesRepository.createQueryBuilder('note') + .where('note.userId = :userId', { userId: user.id }) + .andWhere('note.replyId IS NOT NULL') + .getCount(), + renotesCount: this.notesRepository.createQueryBuilder('note') + .where('note.userId = :userId', { userId: user.id }) + .andWhere('note.renoteId IS NOT NULL') + .getCount(), + repliedCount: this.notesRepository.createQueryBuilder('note') + .where('note.replyUserId = :userId', { userId: user.id }) + .getCount(), + renotedCount: this.notesRepository.createQueryBuilder('note') + .where('note.renoteUserId = :userId', { userId: user.id }) + .getCount(), + pollVotesCount: this.pollVotesRepository.createQueryBuilder('vote') + .where('vote.userId = :userId', { userId: user.id }) + .getCount(), + pollVotedCount: this.pollVotesRepository.createQueryBuilder('vote') + .innerJoin('vote.note', 'note') + .where('note.userId = :userId', { userId: user.id }) + .getCount(), + localFollowingCount: this.followingsRepository.createQueryBuilder('following') + .where('following.followerId = :userId', { userId: user.id }) + .andWhere('following.followeeHost IS NULL') + .getCount(), + remoteFollowingCount: this.followingsRepository.createQueryBuilder('following') + .where('following.followerId = :userId', { userId: user.id }) + .andWhere('following.followeeHost IS NOT NULL') + .getCount(), + localFollowersCount: this.followingsRepository.createQueryBuilder('following') + .where('following.followeeId = :userId', { userId: user.id }) + .andWhere('following.followerHost IS NULL') + .getCount(), + remoteFollowersCount: this.followingsRepository.createQueryBuilder('following') + .where('following.followeeId = :userId', { userId: user.id }) + .andWhere('following.followerHost IS NOT NULL') + .getCount(), + sentReactionsCount: this.noteReactionsRepository.createQueryBuilder('reaction') + .where('reaction.userId = :userId', { userId: user.id }) + .getCount(), + receivedReactionsCount: this.noteReactionsRepository.createQueryBuilder('reaction') + .innerJoin('reaction.note', 'note') + .where('note.userId = :userId', { userId: user.id }) + .getCount(), + noteFavoritesCount: this.noteFavoritesRepository.createQueryBuilder('favorite') + .where('favorite.userId = :userId', { userId: user.id }) + .getCount(), + pageLikesCount: this.pageLikesRepository.createQueryBuilder('like') + .where('like.userId = :userId', { userId: user.id }) + .getCount(), + pageLikedCount: this.pageLikesRepository.createQueryBuilder('like') + .innerJoin('like.page', 'page') + .where('page.userId = :userId', { userId: user.id }) + .getCount(), + driveFilesCount: this.driveFilesRepository.createQueryBuilder('file') + .where('file.userId = :userId', { userId: user.id }) + .getCount(), + driveUsage: this.driveFileEntityService.calcDriveUsageOf(user), + }); + + return { + ...result, + followingCount: result.localFollowingCount + result.remoteFollowingCount, + followersCount: result.localFollowersCount + result.remoteFollowersCount, + }; + }); + } +} diff --git a/packages/frontend-shared/js/const.ts b/packages/frontend-shared/js/const.ts index 4fe5cbb205a7..3fddb5786cdd 100644 --- a/packages/frontend-shared/js/const.ts +++ b/packages/frontend-shared/js/const.ts @@ -93,6 +93,7 @@ export const ROLE_POLICIES = [ 'canUpdateBioMedia', 'pinLimit', 'antennaLimit', + 'antennaNotesLimit', 'wordMuteLimit', 'webhookLimit', 'clipLimit', @@ -101,6 +102,7 @@ export const ROLE_POLICIES = [ 'userEachUserListsLimit', 'rateLimitFactor', 'avatarDecorationLimit', + 'fileSizeLimit', 'canImportAntennas', 'canImportBlocking', 'canImportFollowing', diff --git a/packages/frontend/src/components/MkAvatars.vue b/packages/frontend/src/components/MkAvatars.vue index 8236d0ddb94d..4e8992b85c96 100644 --- a/packages/frontend/src/components/MkAvatars.vue +++ b/packages/frontend/src/components/MkAvatars.vue @@ -5,10 +5,10 @@ SPDX-License-Identifier: AGPL-3.0-only @@ -20,15 +20,18 @@ import { misskeyApi } from '@/scripts/misskey-api.js'; const props = withDefaults(defineProps<{ userIds: string[]; limit?: number; + enableLink?: boolean; }>(), { limit: Infinity, + enableLink: true, }); const users = ref([]); onMounted(async () => { users.value = await misskeyApi('users/show', { - userIds: props.userIds, + userIds: props.userIds.slice(0, props.limit), + detailed: false, }) as unknown as Misskey.entities.UserLite[]; }); diff --git a/packages/frontend/src/components/MkCustomEmojiDetailedDialog.vue b/packages/frontend/src/components/MkCustomEmojiDetailedDialog.vue index ecbee864dc21..354d81c02d54 100644 --- a/packages/frontend/src/components/MkCustomEmojiDetailedDialog.vue +++ b/packages/frontend/src/components/MkCustomEmojiDetailedDialog.vue @@ -4,7 +4,13 @@ SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/MkVisitorDashboard.vue b/packages/frontend/src/components/MkVisitorDashboard.vue index 97c765d81c73..9bccee2f4b22 100644 --- a/packages/frontend/src/components/MkVisitorDashboard.vue +++ b/packages/frontend/src/components/MkVisitorDashboard.vue @@ -18,16 +18,17 @@ SPDX-License-Identifier: AGPL-3.0-only
-
+
{{ i18n.ts.invitationRequiredToRegister }}
- {{ i18n.ts.joinThisServer }} + {{ i18n.ts.joinThisServer }} {{ i18n.ts.exploreOtherServers }} {{ i18n.ts.login }}
+ diff --git a/packages/frontend/src/pages/settings/general.vue b/packages/frontend/src/pages/settings/general.vue index 1bfdfd0e7665..e874d6c1f0c2 100644 --- a/packages/frontend/src/pages/settings/general.vue +++ b/packages/frontend/src/pages/settings/general.vue @@ -231,6 +231,13 @@ SPDX-License-Identifier: AGPL-3.0-only + + + + + + +
@@ -320,6 +327,7 @@ const useNativeUIForVideoAudioPlayer = computed(defaultStore.makeGetterSetter('u const alwaysConfirmFollow = computed(defaultStore.makeGetterSetter('alwaysConfirmFollow')); const confirmWhenRevealingSensitiveMedia = computed(defaultStore.makeGetterSetter('confirmWhenRevealingSensitiveMedia')); const contextMenu = computed(defaultStore.makeGetterSetter('contextMenu')); +const searchSite = computed(defaultStore.makeGetterSetter('searchSite')); watch(lang, () => { miLocalStorage.setItem('lang', lang.value as string); @@ -364,6 +372,7 @@ watch([ alwaysConfirmFollow, confirmWhenRevealingSensitiveMedia, contextMenu, + searchSite, ], async () => { await reloadAsk({ reason: i18n.ts.reloadToApplySetting, unison: true }); }); diff --git a/packages/frontend/src/pages/settings/other.vue b/packages/frontend/src/pages/settings/other.vue index 4a52e59d022e..4d24976614ee 100644 --- a/packages/frontend/src/pages/settings/other.vue +++ b/packages/frontend/src/pages/settings/other.vue @@ -31,6 +31,8 @@ SPDX-License-Identifier: AGPL-3.0-only + + {{ i18n.ts.statistics }}
diff --git a/packages/frontend/src/pages/user-list-timeline.vue b/packages/frontend/src/pages/user-list-timeline.vue index 3efeb46c0a86..1427e425f20e 100644 --- a/packages/frontend/src/pages/user-list-timeline.vue +++ b/packages/frontend/src/pages/user-list-timeline.vue @@ -11,9 +11,12 @@ SPDX-License-Identifier: AGPL-3.0-only
@@ -32,6 +35,7 @@ import { misskeyApi } from '@/scripts/misskey-api.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import { i18n } from '@/i18n.js'; import { useRouter } from '@/router/supplier.js'; +import * as os from '@/os.js'; const router = useRouter(); @@ -44,11 +48,20 @@ const queue = ref(0); const tlEl = shallowRef>(); const rootEl = shallowRef(); -watch(() => props.listId, async () => { +const withRenotes = ref(true); +const withReplies = ref(false); +const onlyFiles = ref(false); + +watch(withRenotes, fetch, { immediate: true }); +watch(withReplies, fetch, { immediate: true }); +watch(onlyFiles, fetch, { immediate: true }); +watch(() => props.listId, fetch, { immediate: true }); + +async function fetch() { list.value = await misskeyApi('users/lists/show', { listId: props.listId, }); -}, { immediate: true }); +} function queueUpdated(q) { queue.value = q; @@ -62,11 +75,39 @@ function settings() { router.push(`/my/lists/${props.listId}`); } -const headerActions = computed(() => list.value ? [{ - icon: 'ti ti-settings', - text: i18n.ts.settings, - handler: settings, -}] : []); +const headerActions = computed(() => list.value ? [ + { + icon: 'ti ti-refresh', + text: i18n.ts.reload, + handler: (ev: Event) => { + tlEl.value?.reloadTimeline(); + }, + }, { + icon: 'ti ti-dots', + text: i18n.ts.options, + handler: (ev) => { + os.popupMenu([ + { + icon: 'ti ti-settings', + text: i18n.ts.editList, + action: settings, + }, { + type: 'switch', + text: i18n.ts.showRenotes, + ref: withRenotes, + }, { + type: 'switch', + text: i18n.ts.showRepliesToOthersInTimeline, + ref: withReplies, + disabled: onlyFiles, + }, { + type: 'switch', + text: i18n.ts.fileAttachedOnly, + ref: onlyFiles, + disabled: withReplies, + }], ev.currentTarget ?? ev.target); + }, + }] : []); const headerTabs = computed(() => []); diff --git a/packages/frontend/src/pages/welcome.entrance.a.vue b/packages/frontend/src/pages/welcome.entrance.a.vue index f0e4a852c971..9dbbf9508a04 100644 --- a/packages/frontend/src/pages/welcome.entrance.a.vue +++ b/packages/frontend/src/pages/welcome.entrance.a.vue @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only