diff --git a/.config/docker_example.yml b/.config/docker_example.yml index 3aaa56e333..9b57eec88b 100644 --- a/.config/docker_example.yml +++ b/.config/docker_example.yml @@ -341,6 +341,10 @@ id: 'aidx' #maxAltTextLength: 20000 # Amount of characters that will be saved for remote media descriptions (alt text). Longer descriptions will be truncated to this length. (minimum: 1) #maxRemoteAltTextLength: 100000 +# Amount of characters that can be used when writing user bios. Longer descriptions will be rejected. (minimum: 1) +#maxBioLength: 1500 +# Amount of characters that will be saved for remote user bios. Longer descriptions will be truncated to this length. (minimum: 1) +#maxRemoteBioLength: 15000 # Proxy for HTTP/HTTPS #proxy: http://127.0.0.1:3128 diff --git a/.config/example.yml b/.config/example.yml index 8cac42c050..8afaaa124d 100644 --- a/.config/example.yml +++ b/.config/example.yml @@ -344,6 +344,10 @@ id: 'aidx' #maxAltTextLength: 20000 # Amount of characters that will be saved for remote media descriptions (alt text). Longer descriptions will be truncated to this length. (minimum: 1) #maxRemoteAltTextLength: 100000 +# Amount of characters that can be used when writing user bios. Longer descriptions will be truncated to this length. (minimum: 1) +#maxBioLength: 1500 +# Amount of characters that will be saved for remote user bios. Longer descriptions will be truncated to this length. (minimum: 1) +#maxRemoteBioLength: 15000 # Proxy for HTTP/HTTPS #proxy: http://127.0.0.1:3128 diff --git a/packages/backend/migration/1750541176036000-userDescriptionText.js b/packages/backend/migration/1750541176036000-userDescriptionText.js new file mode 100644 index 0000000000..5189e90637 --- /dev/null +++ b/packages/backend/migration/1750541176036000-userDescriptionText.js @@ -0,0 +1,15 @@ +/* + * SPDX-FileCopyrightText: Lillychan and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class UserDescriptionText1750541176036000 { + name = 'UserDescriptionText1750541176036000' + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "description" TYPE TEXT USING NULL`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "description" TYPE character varying(2048)`); + } +} diff --git a/packages/backend/src/config.ts b/packages/backend/src/config.ts index c2e7efd456..21396a1f7f 100644 --- a/packages/backend/src/config.ts +++ b/packages/backend/src/config.ts @@ -96,6 +96,8 @@ type Source = { maxRemoteNoteLength?: number; maxAltTextLength?: number; maxRemoteAltTextLength?: number; + maxBioLength?: number; + maxRemoteBioLength?: number; clusterLimit?: number; @@ -261,6 +263,8 @@ export type Config = { maxRemoteCwLength: number; maxAltTextLength: number; maxRemoteAltTextLength: number; + maxBioLength: number; + maxRemoteBioLength: number; clusterLimit: number | undefined; id: string; outgoingAddress: string | undefined; @@ -461,6 +465,8 @@ export function loadConfig(): Config { maxRemoteCwLength: config.maxRemoteCwLength ?? 5000, maxAltTextLength: config.maxAltTextLength ?? 20000, maxRemoteAltTextLength: config.maxRemoteAltTextLength ?? 100000, + maxBioLength: config.maxBioLength ?? 1500, + maxRemoteBioLength: config.maxRemoteBioLength ?? 15000, clusterLimit: config.clusterLimit, outgoingAddress: config.outgoingAddress, outgoingAddressFamily: config.outgoingAddressFamily, @@ -658,7 +664,7 @@ function applyEnvOverrides(config: Source) { _apply_top(['sentryForFrontend', 'browserTracingIntegration', 'routeLabel']); _apply_top([['clusterLimit', 'deliverJobConcurrency', 'inboxJobConcurrency', 'relashionshipJobConcurrency', 'deliverJobPerSec', 'inboxJobPerSec', 'relashionshipJobPerSec', 'deliverJobMaxAttempts', 'inboxJobMaxAttempts']]); _apply_top([['outgoingAddress', 'outgoingAddressFamily', 'proxy', 'proxySmtp', 'mediaDirectory', 'mediaProxy', 'proxyRemoteFiles', 'videoThumbnailGenerator']]); - _apply_top([['maxFileSize', 'maxNoteLength', 'maxRemoteNoteLength', 'maxAltTextLength', 'maxRemoteAltTextLength', 'pidFile', 'filePermissionBits']]); + _apply_top([['maxFileSize', 'maxNoteLength', 'maxRemoteNoteLength', 'maxAltTextLength', 'maxRemoteAltTextLength', 'maxBioLength', 'maxRemoteBioLength', 'pidFile', 'filePermissionBits']]); _apply_top(['import', ['downloadTimeout', 'maxFileSize']]); _apply_top([['signToActivityPubGet', 'checkActivityPubGetSignature', 'setupPassword', 'disallowExternalApRedirect']]); _apply_top(['logging', 'sql', ['disableQueryTruncation', 'enableQueryParamLogging']]); diff --git a/packages/backend/src/core/activitypub/models/ApPersonService.ts b/packages/backend/src/core/activitypub/models/ApPersonService.ts index 29f7459219..1ca3a007c3 100644 --- a/packages/backend/src/core/activitypub/models/ApPersonService.ts +++ b/packages/backend/src/core/activitypub/models/ApPersonService.ts @@ -31,7 +31,6 @@ import type UsersChart from '@/core/chart/charts/users.js'; import type InstanceChart from '@/core/chart/charts/instance.js'; import type { HashtagService } from '@/core/HashtagService.js'; import { MiUserNotePining } from '@/models/UserNotePining.js'; -import { StatusError } from '@/misc/status-error.js'; import type { UtilityService } from '@/core/UtilityService.js'; import type { UserEntityService } from '@/core/entities/UserEntityService.js'; import { bindThis } from '@/decorators.js'; @@ -45,6 +44,7 @@ import { HttpRequestService } from '@/core/HttpRequestService.js'; import { verifyFieldLinks } from '@/misc/verify-field-link.js'; import { isRetryableError } from '@/misc/is-retryable-error.js'; import { renderInlineError } from '@/misc/render-inline-error.js'; +import { IdentifiableError } from '@/misc/identifiable-error.js'; import { getApId, getApType, isActor, isCollection, isCollectionOrOrderedCollection, isPropertyValue } from '../type.js'; import { extractApHashtags } from './tag.js'; import type { OnModuleInit } from '@nestjs/common'; @@ -55,10 +55,8 @@ import type { ApLoggerService } from '../ApLoggerService.js'; import type { ApImageService } from './ApImageService.js'; import type { IActor, ICollection, IObject, IOrderedCollection } from '../type.js'; -import { IdentifiableError } from '@/misc/identifiable-error.js'; const nameLength = 128; -const summaryLength = 2048; type Field = Record<'name' | 'value', string>; @@ -220,7 +218,7 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown { if (!(typeof x.summary === 'string' && x.summary.length > 0)) { throw new UnrecoverableError(`invalid Actor ${uri}: wrong summary`); } - x.summary = truncate(x.summary, summaryLength); + x.summary = truncate(x.summary, this.config.maxRemoteBioLength); } const idHost = this.utilityService.punyHostPSLDomain(x.id); @@ -458,9 +456,9 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown { let _description: string | null = null; if (person._misskey_summary) { - _description = truncate(person._misskey_summary, summaryLength); + _description = truncate(person._misskey_summary, this.config.maxRemoteBioLength); } else if (person.summary) { - _description = this.apMfmService.htmlToMfm(truncate(person.summary, summaryLength), person.tag); + _description = this.apMfmService.htmlToMfm(truncate(person.summary, this.config.maxRemoteBioLength), person.tag); } await transactionalEntityManager.save(new MiUserProfile({ @@ -575,7 +573,6 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown { if (exist === null) return; //#endregion - // eslint-disable-next-line no-param-reassign if (resolver == null) resolver = this.apResolverService.createResolver(); const object = hint ?? await resolver.resolve(uri); @@ -717,9 +714,9 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown { let _description: string | null = null; if (person._misskey_summary) { - _description = truncate(person._misskey_summary, summaryLength); + _description = truncate(person._misskey_summary, this.config.maxRemoteBioLength); } else if (person.summary) { - _description = this.apMfmService.htmlToMfm(truncate(person.summary, summaryLength), person.tag); + _description = this.apMfmService.htmlToMfm(truncate(person.summary, this.config.maxRemoteBioLength), person.tag); } await this.userProfilesRepository.update({ userId: exist.id }, { diff --git a/packages/backend/src/core/entities/MetaEntityService.ts b/packages/backend/src/core/entities/MetaEntityService.ts index 294187feba..3e16266d7d 100644 --- a/packages/backend/src/core/entities/MetaEntityService.ts +++ b/packages/backend/src/core/entities/MetaEntityService.ts @@ -117,6 +117,8 @@ export class MetaEntityService { maxRemoteCwLength: this.config.maxRemoteCwLength, maxAltTextLength: this.config.maxAltTextLength, maxRemoteAltTextLength: this.config.maxRemoteAltTextLength, + maxBioLength: this.config.maxBioLength, + maxRemoteBioLength: this.config.maxRemoteBioLength, defaultLightTheme, defaultDarkTheme, defaultLike: instance.defaultLike, diff --git a/packages/backend/src/models/User.ts b/packages/backend/src/models/User.ts index f40bb41a22..c282fa327f 100644 --- a/packages/backend/src/models/User.ts +++ b/packages/backend/src/models/User.ts @@ -433,7 +433,7 @@ export type MiPartialRemoteUser = Partial & { export const localUsernameSchema = { type: 'string', pattern: /^\w{1,20}$/.toString().slice(1, -1) } as const; export const passwordSchema = { type: 'string', minLength: 1 } as const; export const nameSchema = { type: 'string', minLength: 1, maxLength: 50 } as const; -export const descriptionSchema = { type: 'string', minLength: 1, maxLength: 1500 } as const; +export const descriptionSchema = { type: 'string', minLength: 1 } as const; export const followedMessageSchema = { type: 'string', minLength: 1, maxLength: 256 } as const; export const locationSchema = { type: 'string', minLength: 1, maxLength: 50 } as const; export const listenbrainzSchema = { type: 'string', minLength: 1, maxLength: 128 } as const; diff --git a/packages/backend/src/models/UserProfile.ts b/packages/backend/src/models/UserProfile.ts index 6ee72e6ddd..7b35cd5961 100644 --- a/packages/backend/src/models/UserProfile.ts +++ b/packages/backend/src/models/UserProfile.ts @@ -43,8 +43,8 @@ export class MiUserProfile { }) public listenbrainz: string | null; - @Column('varchar', { - length: 2048, nullable: true, + @Column('text', { + nullable: true, comment: 'The description (bio) of the User.', }) public description: string | null; diff --git a/packages/backend/src/models/json-schema/meta.ts b/packages/backend/src/models/json-schema/meta.ts index 8cc1686ac6..35e470c459 100644 --- a/packages/backend/src/models/json-schema/meta.ts +++ b/packages/backend/src/models/json-schema/meta.ts @@ -206,6 +206,14 @@ export const packedMetaLiteSchema = { type: 'number', optional: false, nullable: false, }, + maxBioLength: { + type: 'number', + optional: false, nullable: false, + }, + maxRemoteBioLength: { + type: 'number', + optional: false, nullable: false, + }, ads: { type: 'array', optional: false, nullable: false, diff --git a/packages/backend/src/server/NodeinfoServerService.ts b/packages/backend/src/server/NodeinfoServerService.ts index 55e8827696..99441cbe01 100644 --- a/packages/backend/src/server/NodeinfoServerService.ts +++ b/packages/backend/src/server/NodeinfoServerService.ts @@ -128,6 +128,8 @@ export class NodeinfoServerService { maxRemoteCwLength: this.config.maxRemoteCwLength, maxAltTextLength: this.config.maxAltTextLength, maxRemoteAltTextLength: this.config.maxRemoteAltTextLength, + maxBioLength: this.config.maxBioLength, + maxRemoteBioLength: this.config.maxRemoteBioLength, enableEmail: meta.enableEmail, enableServiceWorker: meta.enableServiceWorker, proxyAccountName: proxyAccount.username, diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts index 5767880531..9e76572300 100644 --- a/packages/backend/src/server/api/endpoints/i/update.ts +++ b/packages/backend/src/server/api/endpoints/i/update.ts @@ -141,6 +141,13 @@ export const meta = { code: 'MAX_CW_LENGTH', id: '7004c478-bda3-4b4f-acb2-4316398c9d52', }, + + maxBioLength: { + message: 'You tried setting a bio which is too long.', + code: 'MAX_BIO_LENGTH', + id: 'f3bb3543-8bd1-4e6d-9375-55efaf2b4102', + httpStatusCode: 422, + }, }, res: { @@ -329,7 +336,12 @@ export default class extends Endpoint { // eslint- updates.name = trimmedName === '' ? null : trimmedName; } } - if (ps.description !== undefined) profileUpdates.description = ps.description; + if (ps.description !== undefined) { + if (ps.description && ps.description.length > this.config.maxBioLength) { + throw new ApiError(meta.errors.maxBioLength); + } + profileUpdates.description = ps.description; + }; if (ps.followedMessage !== undefined) profileUpdates.followedMessage = ps.followedMessage; if (ps.lang !== undefined) profileUpdates.lang = ps.lang; if (ps.location !== undefined) profileUpdates.location = ps.location; diff --git a/packages/backend/test/e2e/users.ts b/packages/backend/test/e2e/users.ts index 4b3ec856f1..c3c9b1a850 100644 --- a/packages/backend/test/e2e/users.ts +++ b/packages/backend/test/e2e/users.ts @@ -7,7 +7,7 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; import { inspect } from 'node:util'; -import { api, post, role, signup, successfulApiCall, uploadFile } from '../utils.js'; +import { api, failedApiCall, post, role, signup, successfulApiCall, uploadFile } from '../utils.js'; import type * as misskey from 'misskey-js'; import { DEFAULT_POLICIES } from '@/core/RoleService.js'; @@ -920,6 +920,12 @@ describe('ユーザー', () => { //#endregion + test('user with to long bio', async () => { + await failedApiCall({ endpoint: 'i/update', user: alice, parameters: { + description: 'x'.repeat(10000), + } }, { status: 422, code: 'MAX_BIO_LENGTH', id: 'f3bb3543-8bd1-4e6d-9375-55efaf2b4102' }); + }); + test.todo('を管理人として確認することができる(admin/show-user)'); test.todo('を管理人として確認することができる(admin/show-users)'); test.todo('をサーバー向けに取得することができる(federation/users)');