feat: preserved usernames

Resolve #10704
This commit is contained in:
syuilo 2023-04-29 17:03:14 +09:00
parent e8177ee311
commit 0ad7869249
11 changed files with 68 additions and 6 deletions

View File

@ -26,6 +26,7 @@
- ロールタイムラインをロールごとに表示するかどうかの選択できるようになりました。 - ロールタイムラインをロールごとに表示するかどうかの選択できるようになりました。
* デフォルトがオフになるので、ロールタイムラインを表示する場合はオンにしてください。 * デフォルトがオフになるので、ロールタイムラインを表示する場合はオンにしてください。
- カスタム絵文字のライセンスを複数でセットできるようになりました。 - カスタム絵文字のライセンスを複数でセットできるようになりました。
- 管理者が予約ユーザー名を設定できるようになりました。
### Client ### Client
- 通知の表示をカスタマイズできるように - 通知の表示をカスタマイズできるように

View File

@ -1022,6 +1022,8 @@ serverRules: "サーバールール"
pleaseConfirmBelowBeforeSignup: "このサーバーに登録する前に、以下を確認してください。" pleaseConfirmBelowBeforeSignup: "このサーバーに登録する前に、以下を確認してください。"
pleaseAgreeAllToContinue: "続けるには、全ての「同意する」にチェックが入っている必要があります。" pleaseAgreeAllToContinue: "続けるには、全ての「同意する」にチェックが入っている必要があります。"
continue: "続ける" continue: "続ける"
preservedUsernames: "予約ユーザー名"
preservedUsernamesDescription: "予約するユーザー名を改行で列挙します。ここで指定されたユーザー名はアカウント作成時に使えなくなりますが、管理者によるアカウント作成自はこの制限を受けません。また、既に存在するアカウントも影響を受けません。"
_serverRules: _serverRules:
description: "新規登録前に表示する、サーバーの簡潔なルールを設定します。内容は利用規約の要約とすることを推奨します。" description: "新規登録前に表示する、サーバーの簡潔なルールを設定します。内容は利用規約の要約とすることを推奨します。"

View File

@ -0,0 +1,11 @@
export class PreservedUsernames1682754135458 {
name = 'PreservedUsernames1682754135458'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" ADD "preservedUsernames" character varying(1024) array NOT NULL DEFAULT '{ "admin", "administrator", "root", "system", "maintainer", "host", "mod", "moderator", "owner", "superuser", "staff", "auth", "i", "me", "everyone", "all", "mention", "mentions", "example", "user", "users", "account", "accounts", "official", "help", "helps", "support", "supports", "info", "information", "informations", "announce", "announces", "announcement", "announcements", "notice", "notification", "notifications", "dev", "developer", "developers", "tech", "misskey" }'`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "preservedUsernames"`);
}
}

View File

@ -13,8 +13,9 @@ import { UsedUsername } from '@/models/entities/UsedUsername.js';
import generateUserToken from '@/misc/generate-native-user-token.js'; import generateUserToken from '@/misc/generate-native-user-token.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import UsersChart from './chart/charts/users.js'; import UsersChart from '@/core/chart/charts/users.js';
import { UtilityService } from './UtilityService.js'; import { UtilityService } from '@/core/UtilityService.js';
import { MetaService } from '@/core/MetaService.js';
@Injectable() @Injectable()
export class SignupService { export class SignupService {
@ -34,6 +35,7 @@ export class SignupService {
private utilityService: UtilityService, private utilityService: UtilityService,
private userEntityService: UserEntityService, private userEntityService: UserEntityService,
private idService: IdService, private idService: IdService,
private metaService: MetaService,
private usersChart: UsersChart, private usersChart: UsersChart,
) { ) {
} }
@ -44,6 +46,7 @@ export class SignupService {
password?: string | null; password?: string | null;
passwordHash?: UserProfile['password'] | null; passwordHash?: UserProfile['password'] | null;
host?: string | null; host?: string | null;
ignorePreservedUsernames?: boolean;
}) { }) {
const { username, password, passwordHash, host } = opts; const { username, password, passwordHash, host } = opts;
let hash = passwordHash; let hash = passwordHash;
@ -77,6 +80,14 @@ export class SignupService {
throw new Error('USED_USERNAME'); throw new Error('USED_USERNAME');
} }
if (!opts.ignorePreservedUsernames) {
const instance = await this.metaService.fetch(true);
const isPreserved = instance.preservedUsernames.map(x => x.toLowerCase()).includes(username.toLowerCase());
if (isPreserved) {
throw new Error('USED_USERNAME');
}
}
const keyPair = await new Promise<string[]>((res, rej) => const keyPair = await new Promise<string[]>((res, rej) =>
generateKeyPair('rsa', { generateKeyPair('rsa', {
modulusLength: 4096, modulusLength: 4096,

View File

@ -412,4 +412,9 @@ export class Meta {
default: '{}', default: '{}',
}) })
public serverRules: string[]; public serverRules: string[];
@Column('varchar', {
length: 1024, array: true, default: '{ "admin", "administrator", "root", "system", "maintainer", "host", "mod", "moderator", "owner", "superuser", "staff", "auth", "i", "me", "everyone", "all", "mention", "mentions", "example", "user", "users", "account", "accounts", "official", "help", "helps", "support", "supports", "info", "information", "informations", "announce", "announces", "announcement", "announcements", "notice", "notification", "notifications", "dev", "developer", "developers", "tech", "misskey" }',
})
public preservedUsernames: string[];
} }

View File

@ -1,6 +1,7 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import rndstr from 'rndstr'; import rndstr from 'rndstr';
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
import { IsNull } from 'typeorm';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { RegistrationTicketsRepository, UsedUsernamesRepository, UserPendingsRepository, UserProfilesRepository, UsersRepository } from '@/models/index.js'; import type { RegistrationTicketsRepository, UsedUsernamesRepository, UserPendingsRepository, UserProfilesRepository, UsersRepository } from '@/models/index.js';
import type { Config } from '@/config.js'; import type { Config } from '@/config.js';
@ -15,7 +16,6 @@ import { FastifyReplyError } from '@/misc/fastify-reply-error.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { SigninService } from './SigninService.js'; import { SigninService } from './SigninService.js';
import type { FastifyRequest, FastifyReply } from 'fastify'; import type { FastifyRequest, FastifyReply } from 'fastify';
import { IsNull } from 'typeorm';
@Injectable() @Injectable()
export class SignupApiService { export class SignupApiService {
@ -137,6 +137,11 @@ export class SignupApiService {
throw new FastifyReplyError(400, 'USED_USERNAME'); throw new FastifyReplyError(400, 'USED_USERNAME');
} }
const isPreserved = instance.preservedUsernames.map(x => x.toLowerCase()).includes(username.toLowerCase());
if (isPreserved) {
throw new FastifyReplyError(400, 'USED_USERNAME');
}
const code = rndstr('a-z0-9', 16); const code = rndstr('a-z0-9', 16);
// Generate hash of password // Generate hash of password

View File

@ -52,6 +52,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
const { account, secret } = await this.signupService.signup({ const { account, secret } = await this.signupService.signup({
username: ps.username, username: ps.username,
password: ps.password, password: ps.password,
ignorePreservedUsernames: true,
}); });
const res = await this.userEntityService.pack(account, account, { const res = await this.userEntityService.pack(account, account, {

View File

@ -118,6 +118,14 @@ export const meta = {
optional: false, nullable: false, optional: false, nullable: false,
}, },
}, },
preservedUsernames: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'string',
optional: false, nullable: false,
},
},
hcaptchaSecretKey: { hcaptchaSecretKey: {
type: 'string', type: 'string',
optional: true, nullable: true, optional: true, nullable: true,
@ -311,6 +319,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
hiddenTags: instance.hiddenTags, hiddenTags: instance.hiddenTags,
blockedHosts: instance.blockedHosts, blockedHosts: instance.blockedHosts,
sensitiveWords: instance.sensitiveWords, sensitiveWords: instance.sensitiveWords,
preservedUsernames: instance.preservedUsernames,
hcaptchaSecretKey: instance.hcaptchaSecretKey, hcaptchaSecretKey: instance.hcaptchaSecretKey,
recaptchaSecretKey: instance.recaptchaSecretKey, recaptchaSecretKey: instance.recaptchaSecretKey,
turnstileSecretKey: instance.turnstileSecretKey, turnstileSecretKey: instance.turnstileSecretKey,

View File

@ -95,6 +95,7 @@ export const paramDef = {
enableChartsForRemoteUser: { type: 'boolean' }, enableChartsForRemoteUser: { type: 'boolean' },
enableChartsForFederatedInstances: { type: 'boolean' }, enableChartsForFederatedInstances: { type: 'boolean' },
serverRules: { type: 'array', items: { type: 'string' } }, serverRules: { type: 'array', items: { type: 'string' } },
preservedUsernames: { type: 'array', items: { type: 'string' } },
}, },
required: [], required: [],
} as const; } as const;
@ -392,6 +393,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
set.serverRules = ps.serverRules; set.serverRules = ps.serverRules;
} }
if (ps.preservedUsernames !== undefined) {
set.preservedUsernames = ps.preservedUsernames;
}
await this.metaService.update(set); await this.metaService.update(set);
this.moderationLogService.insertModerationLog(me, 'updateMeta'); this.moderationLogService.insertModerationLog(me, 'updateMeta');
}); });

View File

@ -4,6 +4,7 @@ import type { UsedUsernamesRepository, UsersRepository } from '@/models/index.js
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import { localUsernameSchema } from '@/models/entities/User.js'; import { localUsernameSchema } from '@/models/entities/User.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { MetaService } from '@/core/MetaService.js';
export const meta = { export const meta = {
tags: ['users'], tags: ['users'],
@ -39,9 +40,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
@Inject(DI.usedUsernamesRepository) @Inject(DI.usedUsernamesRepository)
private usedUsernamesRepository: UsedUsernamesRepository, private usedUsernamesRepository: UsedUsernamesRepository,
private metaService: MetaService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
// Get exist
const exist = await this.usersRepository.countBy({ const exist = await this.usersRepository.countBy({
host: IsNull(), host: IsNull(),
usernameLower: ps.username.toLowerCase(), usernameLower: ps.username.toLowerCase(),
@ -49,8 +51,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
const exist2 = await this.usedUsernamesRepository.countBy({ username: ps.username.toLowerCase() }); const exist2 = await this.usedUsernamesRepository.countBy({ username: ps.username.toLowerCase() });
const meta = await this.metaService.fetch();
const isPreserved = meta.preservedUsernames.map(x => x.toLowerCase()).includes(ps.username.toLowerCase());
return { return {
available: exist === 0 && exist2 === 0, available: exist === 0 && exist2 === 0 && !isPreserved,
}; };
}); });
} }

View File

@ -12,6 +12,10 @@
<template #prefix><i class="ti ti-link"></i></template> <template #prefix><i class="ti ti-link"></i></template>
<template #label>{{ i18n.ts.tosUrl }}</template> <template #label>{{ i18n.ts.tosUrl }}</template>
</MkInput> </MkInput>
<MkTextarea v-model="preservedUsernames">
<template #label>{{ i18n.ts.preservedUsernames }}</template>
<template #caption>{{ i18n.ts.preservedUsernamesDescription }}</template>
</MkTextarea>
<MkTextarea v-model="sensitiveWords"> <MkTextarea v-model="sensitiveWords">
<template #label>{{ i18n.ts.sensitiveWords }}</template> <template #label>{{ i18n.ts.sensitiveWords }}</template>
<template #caption>{{ i18n.ts.sensitiveWordsDescription }}</template> <template #caption>{{ i18n.ts.sensitiveWordsDescription }}</template>
@ -46,14 +50,16 @@ import { fetchInstance } from '@/instance';
import { i18n } from '@/i18n'; import { i18n } from '@/i18n';
import { definePageMetadata } from '@/scripts/page-metadata'; import { definePageMetadata } from '@/scripts/page-metadata';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import FormLink from "@/components/form/link.vue"; import FormLink from '@/components/form/link.vue';
let sensitiveWords: string = $ref(''); let sensitiveWords: string = $ref('');
let preservedUsernames: string = $ref('');
let tosUrl: string | null = $ref(null); let tosUrl: string | null = $ref(null);
async function init() { async function init() {
const meta = await os.api('admin/meta'); const meta = await os.api('admin/meta');
sensitiveWords = meta.sensitiveWords.join('\n'); sensitiveWords = meta.sensitiveWords.join('\n');
preservedUsernames = meta.preservedUsernames.join('\n');
tosUrl = meta.tosUrl; tosUrl = meta.tosUrl;
} }
@ -61,6 +67,7 @@ function save() {
os.apiWithDialog('admin/update-meta', { os.apiWithDialog('admin/update-meta', {
tosUrl, tosUrl,
sensitiveWords: sensitiveWords.split('\n'), sensitiveWords: sensitiveWords.split('\n'),
preservedUsernames: preservedUsernames.split('\n'),
}).then(() => { }).then(() => {
fetchInstance(); fetchInstance();
}); });