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..46ace4abc2 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 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/locales/index.d.ts b/locales/index.d.ts index 5691130b55..451f156b4b 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -13285,6 +13285,36 @@ export interface Locale extends ILocale { * Signup Reason */ "signupReason": string; + "clearCachedFilesOptions": { + /** + * Delete all cached remote files + */ + "title": string; + /** + * Only delete files older than: + */ + "olderThan": string; + /** + * now + */ + "now": string; + /** + * one week + */ + "oneWeek": string; + /** + * one month + */ + "oneMonth": string; + /** + * one year + */ + "oneYear": string; + /** + * Don't delete files used as avatars&c + */ + "keepFilesInUse": string; + }; } declare const locales: { [lang: string]: Locale; diff --git a/packages/backend/migration/1750541176036-userDescriptionText.js b/packages/backend/migration/1750541176036-userDescriptionText.js new file mode 100644 index 0000000000..56161dc969 --- /dev/null +++ b/packages/backend/migration/1750541176036-userDescriptionText.js @@ -0,0 +1,14 @@ +/* + * SPDX-FileCopyrightText: Lillychan and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class UserDescriptionText1750541176036 { + 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/migration/1750591589187-registry-unique-constraints.js b/packages/backend/migration/1750591589187-registry-unique-constraints.js new file mode 100644 index 0000000000..e9fa6f609d --- /dev/null +++ b/packages/backend/migration/1750591589187-registry-unique-constraints.js @@ -0,0 +1,19 @@ +/* + * SPDX-FileCopyrightText: dakkar and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class RegistryUniqueConstraints1750591589187 { + async up(queryRunner) { + await queryRunner.query(`DELETE FROM "registry_item" WHERE "id" IN ( +SELECT t."id" FROM ( +SELECT *, ROW_NUMBER() OVER (PARTITION BY "userId","key","scope","domain" ORDER BY "updatedAt" DESC) rn +FROM "registry_item" +) t WHERE t.rn>1)`); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_d9c48d580287308f8c1f674946" ON "registry_item" ("userId", "key", "scope", "domain") NULLS NOT DISTINCT`); + } + + async down(queryRunner) { + await queryRunner.query(`DROP INDEX "public"."IDX_d9c48d580287308f8c1f674946"`); + } +} 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/QueueService.ts b/packages/backend/src/core/QueueService.ts index 361e636662..f4f069b64b 100644 --- a/packages/backend/src/core/QueueService.ts +++ b/packages/backend/src/core/QueueService.ts @@ -684,8 +684,11 @@ export class QueueService { } @bindThis - public createCleanRemoteFilesJob() { - return this.objectStorageQueue.add('cleanRemoteFiles', {}, { + public createCleanRemoteFilesJob(olderThanSeconds: number = 0, keepFilesInUse: boolean = false) { + return this.objectStorageQueue.add('cleanRemoteFiles', { + keepFilesInUse, + olderThanSeconds, + }, { removeOnComplete: { age: 3600 * 24 * 7, // keep up to 7 days count: 30, diff --git a/packages/backend/src/core/RegistryApiService.ts b/packages/backend/src/core/RegistryApiService.ts index 2c8877d8a8..2c7ad4026d 100644 --- a/packages/backend/src/core/RegistryApiService.ts +++ b/packages/backend/src/core/RegistryApiService.ts @@ -27,25 +27,9 @@ export class RegistryApiService { public async set(userId: MiUser['id'], domain: string | null, scope: string[], key: string, value: any) { // TODO: 作成できるキーの数を制限する - const query = this.registryItemsRepository.createQueryBuilder('item'); - if (domain) { - query.where('item.domain = :domain', { domain: domain }); - } else { - query.where('item.domain IS NULL'); - } - query.andWhere('item.userId = :userId', { userId: userId }); - query.andWhere('item.key = :key', { key: key }); - query.andWhere('item.scope = :scope', { scope: scope }); - - const existingItem = await query.getOne(); - - if (existingItem) { - await this.registryItemsRepository.update(existingItem.id, { - updatedAt: new Date(), - value: value, - }); - } else { - await this.registryItemsRepository.insert({ + await this.registryItemsRepository.createQueryBuilder('item') + .insert() + .values({ id: this.idService.gen(), updatedAt: new Date(), userId: userId, @@ -53,8 +37,13 @@ export class RegistryApiService { scope: scope, key: key, value: value, - }); - } + }) + .orUpdate( + ['updatedAt', 'value'], + ['userId', 'key', 'scope', 'domain'], + { upsertType: 'on-conflict-do-update' } + ) + .execute(); if (domain == null) { // TODO: サードパーティアプリが傍受出来てしまうのでどうにかする 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/RegistryItem.ts b/packages/backend/src/models/RegistryItem.ts index 335e8b9eab..ceb0305bb4 100644 --- a/packages/backend/src/models/RegistryItem.ts +++ b/packages/backend/src/models/RegistryItem.ts @@ -7,8 +7,8 @@ import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typ import { id } from './util/id.js'; import { MiUser } from './User.js'; -// TODO: 同じdomain、同じscope、同じkeyのレコードは二つ以上存在しないように制約付けたい @Entity('registry_item') +@Index(['userId', 'key', 'scope', 'domain'], { unique: true }) export class MiRegistryItem { @PrimaryColumn(id()) public id: string; 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/queue/processors/CleanRemoteFilesProcessorService.ts b/packages/backend/src/queue/processors/CleanRemoteFilesProcessorService.ts index 2eddae95c8..354f351358 100644 --- a/packages/backend/src/queue/processors/CleanRemoteFilesProcessorService.ts +++ b/packages/backend/src/queue/processors/CleanRemoteFilesProcessorService.ts @@ -4,14 +4,17 @@ */ import { Inject, Injectable } from '@nestjs/common'; -import { IsNull, MoreThan, Not } from 'typeorm'; +import { IsNull, MoreThan, Not, Brackets } from 'typeorm'; import { DI } from '@/di-symbols.js'; import type { MiDriveFile, DriveFilesRepository } from '@/models/_.js'; +import { MiUser } from '@/models/_.js'; import type Logger from '@/logger.js'; import { DriveService } from '@/core/DriveService.js'; import { bindThis } from '@/decorators.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type * as Bull from 'bullmq'; +import type { CleanRemoteFilesJobData } from '../types.js'; +import { IdService } from '@/core/IdService.js'; @Injectable() export class CleanRemoteFilesProcessorService { @@ -23,35 +26,54 @@ export class CleanRemoteFilesProcessorService { private driveService: DriveService, private queueLoggerService: QueueLoggerService, + private idService: IdService, ) { this.logger = this.queueLoggerService.logger.createSubLogger('clean-remote-files'); } @bindThis - public async process(job: Bull.Job>): Promise { + public async process(job: Bull.Job): Promise { this.logger.info('Deleting cached remote files...'); + const olderThanTimestamp = Date.now() - (job.data.olderThanSeconds ?? 0) * 1000; + const olderThanDate = new Date(olderThanTimestamp); + const keepFilesInUse = job.data.keepFilesInUse ?? false; let deletedCount = 0; let cursor: MiDriveFile['id'] | null = null; let errorCount = 0; - const total = await this.driveFilesRepository.countBy({ - userHost: Not(IsNull()), - isLink: false, - }); + const filesQuery = this.driveFilesRepository.createQueryBuilder('file') + .where('file.userHost IS NOT NULL') // remote files + .andWhere('file.isLink = FALSE') // cached + .andWhere('file.id <= :id', { id: this.idService.gen(olderThanTimestamp) }) // and old + .orderBy('file.id', 'ASC'); + + if (keepFilesInUse) { + filesQuery + // are they used as avatar&&c? + .leftJoinAndSelect( + MiUser, 'fileuser', + 'fileuser."avatarId"="file"."id" OR fileuser."bannerId"="file"."id" OR fileuser."backgroundId"="file"."id"' + ) + .andWhere( + new Brackets((qb) => { + qb.where('fileuser.id IS NULL') // not used + .orWhere( // or attached to a user + new Brackets((qb) => { + qb.where('fileuser.lastFetchedAt IS NOT NULL') // weird? maybe this only applies to local users + .andWhere('fileuser.lastFetchedAt < :old', { old: olderThanDate }); // old user + }) + ); + }) + ); + } + + const total = await filesQuery.clone().getCount(); while (true) { - const files = await this.driveFilesRepository.find({ - where: { - userHost: Not(IsNull()), - isLink: false, - ...(cursor ? { id: MoreThan(cursor) } : {}), - }, - take: 256, - order: { - id: 1, - }, - }); + const thisBatchQuery = filesQuery.clone(); + if (cursor) thisBatchQuery.andWhere('file.id > :cursor', { cursor }); + const files = await thisBatchQuery.take(256).getMany(); if (files.length === 0) { job.updateProgress(100); diff --git a/packages/backend/src/queue/types.ts b/packages/backend/src/queue/types.ts index 1bd9f7a0ab..79ab68ab1d 100644 --- a/packages/backend/src/queue/types.ts +++ b/packages/backend/src/queue/types.ts @@ -40,6 +40,11 @@ export type RelationshipJobData = { withReplies?: boolean; }; +export type CleanRemoteFilesJobData = { + keepFilesInUse: boolean; + olderThanSeconds: number; +}; + export type DbJobData = DbJobMap[T]; export type DbJobMap = { 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/admin/drive/clean-remote-files.ts b/packages/backend/src/server/api/endpoints/admin/drive/clean-remote-files.ts index 9a7b3d5d62..27bbe8f29e 100644 --- a/packages/backend/src/server/api/endpoints/admin/drive/clean-remote-files.ts +++ b/packages/backend/src/server/api/endpoints/admin/drive/clean-remote-files.ts @@ -18,7 +18,10 @@ export const meta = { export const paramDef = { type: 'object', - properties: {}, + properties: { + olderThanSeconds: { type: 'number' }, + keepFilesInUse: { type: 'boolean' }, + }, required: [], } as const; @@ -30,7 +33,10 @@ export default class extends Endpoint { // eslint- ) { super(meta, paramDef, async (ps, me) => { await this.moderationLogService.log(me, 'clearRemoteFiles', {}); - await this.queueService.createCleanRemoteFilesJob(); + await this.queueService.createCleanRemoteFilesJob( + ps.olderThanSeconds ?? 0, + ps.keepFilesInUse ?? false, + ); }); } } 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)'); diff --git a/packages/frontend/src/pages/admin/files.vue b/packages/frontend/src/pages/admin/files.vue index 87595a820b..01745fcee2 100644 --- a/packages/frontend/src/pages/admin/files.vue +++ b/packages/frontend/src/pages/admin/files.vue @@ -58,14 +58,40 @@ const pagination = { })), }; -function clear() { - os.confirm({ - type: 'warning', - text: i18n.ts.clearCachedFilesConfirm, - }).then(({ canceled }) => { - if (canceled) return; +async function clear() { + const { canceled, result } = await os.form(i18n.ts.clearCachedFilesOptions.title, { + olderThanEnum: { + label: i18n.ts.clearCachedFilesOptions.olderThan, + type: 'enum', + default: 'now', + required: true, + enum: [ + { label: i18n.ts.clearCachedFilesOptions.now, value: 'now' }, + { label: i18n.ts.clearCachedFilesOptions.oneWeek, value: 'oneWeek' }, + { label: i18n.ts.clearCachedFilesOptions.oneMonth, value: 'oneMonth' }, + { label: i18n.ts.clearCachedFilesOptions.oneYear, value: 'oneYear' }, + ], + }, + keepFilesInUse: { + label: i18n.ts.clearCachedFilesOptions.keepFilesInUse, + description: i18n.ts.clearCachedFilesOptions.keepFilesInUseDescription, + type: 'boolean', + default: false, + }, + }); - os.apiWithDialog('admin/drive/clean-remote-files', {}); + if (canceled) return; + + const timesMap = { + now: 0, + oneWeek: 7 * 86400, + oneMonth: 30 * 86400, + oneYear: 365 * 86400, + }; + + await os.apiWithDialog('admin/drive/clean-remote-files', { + olderThanSeconds: timesMap[result.olderThanEnum] ?? 0, + keepFilesInUse: result.keepFilesInUse, }); } diff --git a/packages/frontend/src/pages/settings/profile.vue b/packages/frontend/src/pages/settings/profile.vue index 21bc74326a..f44c8d8514 100644 --- a/packages/frontend/src/pages/settings/profile.vue +++ b/packages/frontend/src/pages/settings/profile.vue @@ -37,7 +37,7 @@ SPDX-License-Identifier: AGPL-3.0-only - + @@ -195,6 +195,7 @@ import { langmap } from '@/utility/langmap.js'; import { definePage } from '@/page.js'; import { claimAchievement } from '@/utility/achievements.js'; import { store } from '@/store.js'; +import { instance } from '@/instance.js'; import MkInfo from '@/components/MkInfo.vue'; import MkTextarea from '@/components/MkTextarea.vue'; @@ -270,18 +271,13 @@ function save() { } os.apiWithDialog('i/update', { // 空文字列をnullにしたいので??は使うな - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing name: profile.name || null, - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing description: profile.description || null, - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing followedMessage: profile.followedMessage || null, - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + // eslint-disable-next-line id-denylist location: profile.location || null, - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing birthday: profile.birthday || null, listenbrainz: profile.listenbrainz || null, - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing lang: profile.lang || null, isBot: !!profile.isBot, isCat: !!profile.isCat, diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index aa6cecf4d2..f3d0b9ff28 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -5668,6 +5668,8 @@ export type components = { maxRemoteCwLength: number; maxAltTextLength: number; maxRemoteAltTextLength: number; + maxBioLength: number; + maxRemoteBioLength: number; ads: { /** * Format: id diff --git a/sharkey-locales/en-US.yml b/sharkey-locales/en-US.yml index f0aaf5e227..23a3afce82 100644 --- a/sharkey-locales/en-US.yml +++ b/sharkey-locales/en-US.yml @@ -636,3 +636,13 @@ rawInfoDescription: "Extended user data in its raw form. These fields are privat rawApDescription: "ActivityPub user data in its raw form. These fields are public and accessible to other instances." signupReason: "Signup Reason" + +clearCachedFilesOptions: + title: "Delete all cached remote files" + olderThan: "Only delete files older than:" + now: "now" + oneWeek: "one week" + oneMonth: "one month" + 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"