Merge branch Sharkey:develop into feat/better-beta-emoji-panel

This commit is contained in:
lunya.pet 2025-06-29 07:02:50 +00:00
commit d5ce4810a3
24 changed files with 235 additions and 72 deletions

View file

@ -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

View file

@ -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

30
locales/index.d.ts vendored
View file

@ -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;

View file

@ -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)`);
}
}

View file

@ -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"`);
}
}

View file

@ -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']]);

View file

@ -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,

View file

@ -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: サードパーティアプリが傍受出来てしまうのでどうにかする

View file

@ -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 }, {

View file

@ -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,

View file

@ -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;

View file

@ -433,7 +433,7 @@ export type MiPartialRemoteUser = Partial<MiUser> & {
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;

View file

@ -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;

View file

@ -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,

View file

@ -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<Record<string, unknown>>): Promise<void> {
public async process(job: Bull.Job<CleanRemoteFilesJobData>): Promise<void> {
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);

View file

@ -40,6 +40,11 @@ export type RelationshipJobData = {
withReplies?: boolean;
};
export type CleanRemoteFilesJobData = {
keepFilesInUse: boolean;
olderThanSeconds: number;
};
export type DbJobData<T extends keyof DbJobMap> = DbJobMap[T];
export type DbJobMap = {

View file

@ -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,

View file

@ -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<typeof meta, typeof paramDef> { // 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,
);
});
}
}

View file

@ -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<typeof meta, typeof paramDef> { // 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;

View file

@ -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)');

View file

@ -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,
});
}

View file

@ -37,7 +37,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</SearchMarker>
<SearchMarker :keywords="['description', 'bio']">
<MkTextarea v-model="profile.description" :max="500" tall manualSave mfmAutocomplete :mfmPreview="true">
<MkTextarea v-model="profile.description" :max="instance.maxBioLength" tall manualSave mfmAutocomplete :mfmPreview="true">
<template #label><SearchLabel>{{ i18n.ts._profile.description }}</SearchLabel></template>
<template #caption>{{ i18n.ts._profile.youCanIncludeHashtags }}</template>
</MkTextarea>
@ -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,

View file

@ -5668,6 +5668,8 @@ export type components = {
maxRemoteCwLength: number;
maxAltTextLength: number;
maxRemoteAltTextLength: number;
maxBioLength: number;
maxRemoteBioLength: number;
ads: {
/**
* Format: id

View file

@ -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"