enhance: 招待機能の改善 (#11195)

* refactor(backend): 招待機能を改修

* feat(backend): 招待コードのcreate/delete/listエンドポイントを追加

* add(misskey-js): エンドポイントと型を追加

* change(backend): metaでinvite関連の情報も返すように

* add(misskey-js): エンドポイントと型を追加

* add(backend): `/endpoints/invite/limit`を追加

* fix: createdByがnullableではなかったのを修正

* fix: relationが取得できていなかった問題を修正

* fix: パラメータを間違えていたのを修正

* feat(client): 招待ページを実装

* change(client): インスタンスメニューの「招待」押した場合に招待ページに飛ぶように変更

* feat: 招待コードをコピーできるように

* change(backend): metaに招待コード発行に関する情報を持たせるのをやめる

* feat: ロールごとに招待コードの発行上限数などを設定できるように

* change(client): 招待コードをコピーしたときにダイアログを出すように

* add: 招待に関する管理者用のエンドポイントを追加

* change(backend): モデレーターであれば作成者以外でも招待コードを削除できるように

* change(backend): admin/invite/listはオフセットでページネーションするように

* feat(client): 招待コードの管理ページを追加

* feat(client): 招待コードのリストをソートできるように

* change: `admin/invite/create`のレスポンスを修正

* fix(client): 有効期限を指定できていなかった問題を修正

* refactor: 必要のない箇所を削除

* perf(backend): use limit() instead of take()

* change(client): 作成ボタンを見た目を変更

* refactor: 招待コードの生成部分を共通化し、コード内に"01OI"のいずれかの文字を含まないように

* fix(client): paginationの仕様が変わっていたので修正

* change(backend): expiresAtパラメータのnullを許容

* change(client): 有効期限を設けないときは日付の入力欄を非表示に

* fix: 自身のポリシーよりもインスタンス側のポリシーが優先表示される問題を修正

* fix: n時間のときに「n時間間」となってしまうのを修正

* fix(backend): ポリシーが途中で変更されたときに作成可能数がマイナス表記になってしまうのを修正

* change(client): 招待コードのユーザー名が不明な理由を表示するように

* update: CHANGELOG.md

* lint

* refactor

* refactor

* tweak ui

* 🎨

* 🎨

* add(backend): indexを追加

* change(backend): indexの追加に伴う変更

* change(client): インスタンスメニューの「招待」の場所を変更

* add(frontend): MkInviteCode用のstorybookを追加

* Update misskey-js.api.md

* fix(misskey-js): InviteのcreatedByの型が間違っていたのを修正

---------

Co-authored-by: syuilo <Syuilotan@yahoo.co.jp>
Co-authored-by: tamaina <tamaina@hotmail.co.jp>
This commit is contained in:
yukineko
2023-07-15 09:57:58 +09:00
committed by GitHub
parent 1c82e97350
commit 02957a1b5d
36 changed files with 1341 additions and 56 deletions

View File

@@ -81,6 +81,7 @@ import { GalleryLikeEntityService } from './entities/GalleryLikeEntityService.js
import { GalleryPostEntityService } from './entities/GalleryPostEntityService.js';
import { HashtagEntityService } from './entities/HashtagEntityService.js';
import { InstanceEntityService } from './entities/InstanceEntityService.js';
import { InviteCodeEntityService } from './entities/InviteCodeEntityService.js';
import { ModerationLogEntityService } from './entities/ModerationLogEntityService.js';
import { MutingEntityService } from './entities/MutingEntityService.js';
import { RenoteMutingEntityService } from './entities/RenoteMutingEntityService.js';
@@ -205,6 +206,7 @@ const $GalleryLikeEntityService: Provider = { provide: 'GalleryLikeEntityService
const $GalleryPostEntityService: Provider = { provide: 'GalleryPostEntityService', useExisting: GalleryPostEntityService };
const $HashtagEntityService: Provider = { provide: 'HashtagEntityService', useExisting: HashtagEntityService };
const $InstanceEntityService: Provider = { provide: 'InstanceEntityService', useExisting: InstanceEntityService };
const $InviteCodeEntityService: Provider = { provide: 'InviteCodeEntityService', useExisting: InviteCodeEntityService };
const $ModerationLogEntityService: Provider = { provide: 'ModerationLogEntityService', useExisting: ModerationLogEntityService };
const $MutingEntityService: Provider = { provide: 'MutingEntityService', useExisting: MutingEntityService };
const $RenoteMutingEntityService: Provider = { provide: 'RenoteMutingEntityService', useExisting: RenoteMutingEntityService };
@@ -329,6 +331,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
GalleryPostEntityService,
HashtagEntityService,
InstanceEntityService,
InviteCodeEntityService,
ModerationLogEntityService,
MutingEntityService,
RenoteMutingEntityService,
@@ -448,6 +451,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$GalleryPostEntityService,
$HashtagEntityService,
$InstanceEntityService,
$InviteCodeEntityService,
$ModerationLogEntityService,
$MutingEntityService,
$RenoteMutingEntityService,
@@ -567,6 +571,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
GalleryPostEntityService,
HashtagEntityService,
InstanceEntityService,
InviteCodeEntityService,
ModerationLogEntityService,
MutingEntityService,
RenoteMutingEntityService,
@@ -685,6 +690,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$GalleryPostEntityService,
$HashtagEntityService,
$InstanceEntityService,
$InviteCodeEntityService,
$ModerationLogEntityService,
$MutingEntityService,
$RenoteMutingEntityService,

View File

@@ -21,6 +21,9 @@ export type RolePolicies = {
ltlAvailable: boolean;
canPublicNote: boolean;
canInvite: boolean;
inviteLimit: number;
inviteLimitCycle: number;
inviteExpirationTime: number;
canManageCustomEmojis: boolean;
canSearchNotes: boolean;
canHideAds: boolean;
@@ -42,6 +45,9 @@ export const DEFAULT_POLICIES: RolePolicies = {
ltlAvailable: true,
canPublicNote: true,
canInvite: false,
inviteLimit: 0,
inviteLimitCycle: 60 * 24 * 7,
inviteExpirationTime: 0,
canManageCustomEmojis: false,
canSearchNotes: false,
canHideAds: false,
@@ -277,6 +283,9 @@ export class RoleService implements OnApplicationShutdown {
ltlAvailable: calc('ltlAvailable', vs => vs.some(v => v === true)),
canPublicNote: calc('canPublicNote', vs => vs.some(v => v === true)),
canInvite: calc('canInvite', vs => vs.some(v => v === true)),
inviteLimit: calc('inviteLimit', vs => Math.max(...vs)),
inviteLimitCycle: calc('inviteLimitCycle', vs => Math.max(...vs)),
inviteExpirationTime: calc('inviteExpirationTime', vs => Math.max(...vs)),
canManageCustomEmojis: calc('canManageCustomEmojis', vs => vs.some(v => v === true)),
canSearchNotes: calc('canSearchNotes', vs => vs.some(v => v === true)),
canHideAds: calc('canHideAds', vs => vs.some(v => v === true)),

View File

@@ -0,0 +1,52 @@
import { Inject, Injectable } from '@nestjs/common';
import { DI } from '@/di-symbols.js';
import type { RegistrationTicketsRepository } from '@/models/index.js';
import { awaitAll } from '@/misc/prelude/await-all.js';
import type { Packed } from '@/misc/json-schema.js';
import type { User } from '@/models/entities/User.js';
import type { RegistrationTicket } from '@/models/entities/RegistrationTicket.js';
import { bindThis } from '@/decorators.js';
import { UserEntityService } from './UserEntityService.js';
@Injectable()
export class InviteCodeEntityService {
constructor(
@Inject(DI.registrationTicketsRepository)
private registrationTicketsRepository: RegistrationTicketsRepository,
private userEntityService: UserEntityService,
) {
}
@bindThis
public async pack(
src: RegistrationTicket['id'] | RegistrationTicket,
me?: { id: User['id'] } | null | undefined,
): Promise<Packed<'InviteCode'>> {
const target = typeof src === 'object' ? src : await this.registrationTicketsRepository.findOneOrFail({
where: {
id: src,
},
relations: ['createdBy', 'usedBy'],
});
return await awaitAll({
id: target.id,
code: target.code,
expiresAt: target.expiresAt ? target.expiresAt.toISOString() : null,
createdAt: target.createdAt.toISOString(),
createdBy: target.createdBy ? await this.userEntityService.pack(target.createdBy, me) : null,
usedBy: target.usedBy ? await this.userEntityService.pack(target.usedBy, me) : null,
usedAt: target.usedAt ? target.usedAt.toISOString() : null,
used: !!target.usedAt,
});
}
@bindThis
public packMany(
targets: any[],
me: { id: User['id'] },
) {
return Promise.all(targets.map(x => this.pack(x, me)));
}
}

View File

@@ -0,0 +1,20 @@
import { secureRndstr } from './secure-rndstr.js';
const CHARS = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ'; // [0-9A-Z] w/o [01IO] (32 patterns)
export function generateInviteCode(): string {
const code = secureRndstr(8, {
chars: CHARS,
});
const uniqueId = [];
let n = Math.floor(Date.now() / 1000 / 60);
while (true) {
uniqueId.push(CHARS[n % CHARS.length]);
const t = Math.floor(n / CHARS.length);
if (!t) break;
n = t;
}
return code + uniqueId.reverse().join('');
}

View File

@@ -19,6 +19,7 @@ import { packedRenoteMutingSchema } from '@/models/json-schema/renote-muting.js'
import { packedBlockingSchema } from '@/models/json-schema/blocking.js';
import { packedNoteReactionSchema } from '@/models/json-schema/note-reaction.js';
import { packedHashtagSchema } from '@/models/json-schema/hashtag.js';
import { packedInviteCodeSchema } from '@/models/json-schema/invite-code.js';
import { packedPageSchema } from '@/models/json-schema/page.js';
import { packedNoteFavoriteSchema } from '@/models/json-schema/note-favorite.js';
import { packedChannelSchema } from '@/models/json-schema/channel.js';
@@ -52,6 +53,7 @@ export const refs = {
RenoteMuting: packedRenoteMutingSchema,
Blocking: packedBlockingSchema,
Hashtag: packedHashtagSchema,
InviteCode: packedInviteCodeSchema,
Page: packedPageSchema,
Channel: packedChannelSchema,
QueueCount: packedQueueCountSchema,

View File

@@ -1,17 +1,60 @@
import { PrimaryColumn, Entity, Index, Column } from 'typeorm';
import { PrimaryColumn, Entity, Index, Column, ManyToOne, JoinColumn, OneToOne } from 'typeorm';
import { id } from '../id.js';
import { User } from './User.js';
@Entity()
export class RegistrationTicket {
@PrimaryColumn(id())
public id: string;
@Column('timestamp with time zone')
public createdAt: Date;
@Index({ unique: true })
@Column('varchar', {
length: 64,
})
public code: string;
@Column('timestamp with time zone', {
nullable: true,
})
public expiresAt: Date | null;
@Column('timestamp with time zone')
public createdAt: Date;
@ManyToOne(type => User, {
onDelete: 'CASCADE',
})
@JoinColumn()
public createdBy: User | null;
@Index()
@Column({
...id(),
nullable: true,
})
public createdById: User['id'] | null;
@OneToOne(type => User, {
onDelete: 'CASCADE',
})
@JoinColumn()
public usedBy: User | null;
@Index()
@Column({
...id(),
nullable: true,
})
public usedById: User['id'] | null;
@Column('timestamp with time zone', {
nullable: true,
})
public usedAt: Date | null;
@Column('varchar', {
length: 32,
nullable: true,
})
public pendingUserId: string | null;
}

View File

@@ -0,0 +1,45 @@
export const packedInviteCodeSchema = {
type: 'object',
properties: {
id: {
type: 'string',
optional: false, nullable: false,
format: 'id',
example: 'xxxxxxxxxx',
},
code: {
type: 'string',
optional: false, nullable: false,
example: 'GR6S02ERUA5VR',
},
expiresAt: {
type: 'string',
optional: false, nullable: true,
format: 'date-time',
},
createdAt: {
type: 'string',
optional: false, nullable: false,
format: 'date-time',
},
createdBy: {
type: 'object',
optional: false, nullable: true,
ref: 'UserLite',
},
usedBy: {
type: 'object',
optional: false, nullable: true,
ref: 'UserLite',
},
usedAt: {
type: 'string',
optional: false, nullable: true,
format: 'date-time',
},
used: {
type: 'boolean',
optional: false, nullable: false,
},
},
} as const;

View File

@@ -38,7 +38,8 @@ import * as ep___admin_federation_updateInstance from './endpoints/admin/federat
import * as ep___admin_getIndexStats from './endpoints/admin/get-index-stats.js';
import * as ep___admin_getTableStats from './endpoints/admin/get-table-stats.js';
import * as ep___admin_getUserIps from './endpoints/admin/get-user-ips.js';
import * as ep___invite from './endpoints/invite.js';
import * as ep___admin_invite_create from './endpoints/admin/invite/create.js';
import * as ep___admin_invite_list from './endpoints/admin/invite/list.js';
import * as ep___admin_promo_create from './endpoints/admin/promo/create.js';
import * as ep___admin_queue_clear from './endpoints/admin/queue/clear.js';
import * as ep___admin_queue_deliverDelayed from './endpoints/admin/queue/deliver-delayed.js';
@@ -230,6 +231,10 @@ import * as ep___i_webhooks_show from './endpoints/i/webhooks/show.js';
import * as ep___i_webhooks_list from './endpoints/i/webhooks/list.js';
import * as ep___i_webhooks_update from './endpoints/i/webhooks/update.js';
import * as ep___i_webhooks_delete from './endpoints/i/webhooks/delete.js';
import * as ep___invite_create from './endpoints/invite/create.js';
import * as ep___invite_delete from './endpoints/invite/delete.js';
import * as ep___invite_list from './endpoints/invite/list.js';
import * as ep___invite_limit from './endpoints/invite/limit.js';
import * as ep___meta from './endpoints/meta.js';
import * as ep___emojis from './endpoints/emojis.js';
import * as ep___emoji from './endpoints/emoji.js';
@@ -378,7 +383,8 @@ const $admin_federation_updateInstance: Provider = { provide: 'ep:admin/federati
const $admin_getIndexStats: Provider = { provide: 'ep:admin/get-index-stats', useClass: ep___admin_getIndexStats.default };
const $admin_getTableStats: Provider = { provide: 'ep:admin/get-table-stats', useClass: ep___admin_getTableStats.default };
const $admin_getUserIps: Provider = { provide: 'ep:admin/get-user-ips', useClass: ep___admin_getUserIps.default };
const $invite: Provider = { provide: 'ep:invite', useClass: ep___invite.default };
const $admin_invite_create: Provider = { provide: 'ep:admin/invite/create', useClass: ep___admin_invite_create.default };
const $admin_invite_list: Provider = { provide: 'ep:admin/invite/list', useClass: ep___admin_invite_list.default };
const $admin_promo_create: Provider = { provide: 'ep:admin/promo/create', useClass: ep___admin_promo_create.default };
const $admin_queue_clear: Provider = { provide: 'ep:admin/queue/clear', useClass: ep___admin_queue_clear.default };
const $admin_queue_deliverDelayed: Provider = { provide: 'ep:admin/queue/deliver-delayed', useClass: ep___admin_queue_deliverDelayed.default };
@@ -570,6 +576,10 @@ const $i_webhooks_list: Provider = { provide: 'ep:i/webhooks/list', useClass: ep
const $i_webhooks_show: Provider = { provide: 'ep:i/webhooks/show', useClass: ep___i_webhooks_show.default };
const $i_webhooks_update: Provider = { provide: 'ep:i/webhooks/update', useClass: ep___i_webhooks_update.default };
const $i_webhooks_delete: Provider = { provide: 'ep:i/webhooks/delete', useClass: ep___i_webhooks_delete.default };
const $invite_create: Provider = { provide: 'ep:invite/create', useClass: ep___invite_create.default };
const $invite_delete: Provider = { provide: 'ep:invite/delete', useClass: ep___invite_delete.default };
const $invite_list: Provider = { provide: 'ep:invite/list', useClass: ep___invite_list.default };
const $invite_limit: Provider = { provide: 'ep:invite/limit', useClass: ep___invite_limit.default };
const $meta: Provider = { provide: 'ep:meta', useClass: ep___meta.default };
const $emojis: Provider = { provide: 'ep:emojis', useClass: ep___emojis.default };
const $emoji: Provider = { provide: 'ep:emoji', useClass: ep___emoji.default };
@@ -722,7 +732,8 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$admin_getIndexStats,
$admin_getTableStats,
$admin_getUserIps,
$invite,
$admin_invite_create,
$admin_invite_list,
$admin_promo_create,
$admin_queue_clear,
$admin_queue_deliverDelayed,
@@ -914,6 +925,10 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$i_webhooks_show,
$i_webhooks_update,
$i_webhooks_delete,
$invite_create,
$invite_delete,
$invite_list,
$invite_limit,
$meta,
$emojis,
$emoji,
@@ -1060,7 +1075,8 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$admin_getIndexStats,
$admin_getTableStats,
$admin_getUserIps,
$invite,
$admin_invite_create,
$admin_invite_list,
$admin_promo_create,
$admin_queue_clear,
$admin_queue_deliverDelayed,
@@ -1252,6 +1268,10 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$i_webhooks_show,
$i_webhooks_update,
$i_webhooks_delete,
$invite_create,
$invite_delete,
$invite_list,
$invite_limit,
$meta,
$emojis,
$emoji,

View File

@@ -2,7 +2,7 @@ import { Inject, Injectable } from '@nestjs/common';
import bcrypt from 'bcryptjs';
import { IsNull } from 'typeorm';
import { DI } from '@/di-symbols.js';
import type { RegistrationTicketsRepository, UsedUsernamesRepository, UserPendingsRepository, UserProfilesRepository, UsersRepository } from '@/models/index.js';
import type { RegistrationTicketsRepository, UsedUsernamesRepository, UserPendingsRepository, UserProfilesRepository, UsersRepository, RegistrationTicket } from '@/models/index.js';
import type { Config } from '@/config.js';
import { MetaService } from '@/core/MetaService.js';
import { CaptchaService } from '@/core/CaptchaService.js';
@@ -109,13 +109,15 @@ export class SignupApiService {
}
}
let ticket: RegistrationTicket | null = null;
if (instance.disableRegistration) {
if (invitationCode == null || typeof invitationCode !== 'string') {
reply.code(400);
return;
}
const ticket = await this.registrationTicketsRepository.findOneBy({
ticket = await this.registrationTicketsRepository.findOneBy({
code: invitationCode,
});
@@ -124,7 +126,15 @@ export class SignupApiService {
return;
}
this.registrationTicketsRepository.delete(ticket.id);
if (ticket.expiresAt && ticket.expiresAt < new Date()) {
reply.code(400);
return;
}
if (ticket.usedAt) {
reply.code(400);
return;
}
}
if (instance.emailRequiredForSignup) {
@@ -148,14 +158,14 @@ export class SignupApiService {
const salt = await bcrypt.genSalt(8);
const hash = await bcrypt.hash(password, salt);
await this.userPendingsRepository.insert({
const pendingUser = await this.userPendingsRepository.insert({
id: this.idService.genId(),
createdAt: new Date(),
code,
email: emailAddress!,
username: username,
password: hash,
});
}).then(x => this.userPendingsRepository.findOneByOrFail(x.identifiers[0]));
const link = `${this.config.url}/signup-complete/${code}`;
@@ -163,6 +173,13 @@ export class SignupApiService {
`To complete signup, please click this link:<br><a href="${link}">${link}</a>`,
`To complete signup, please click this link: ${link}`);
if (ticket) {
await this.registrationTicketsRepository.update(ticket.id, {
usedAt: new Date(),
pendingUserId: pendingUser.id,
});
}
reply.code(204);
return;
} else {
@@ -176,6 +193,14 @@ export class SignupApiService {
includeSecrets: true,
});
if (ticket) {
await this.registrationTicketsRepository.update(ticket.id, {
usedAt: new Date(),
usedBy: account,
usedById: account.id,
});
}
return {
...res,
token: secret,
@@ -212,6 +237,15 @@ export class SignupApiService {
emailVerifyCode: null,
});
const ticket = await this.registrationTicketsRepository.findOneBy({ pendingUserId: pendingUser.id });
if (ticket) {
await this.registrationTicketsRepository.update(ticket.id, {
usedBy: account,
usedById: account.id,
pendingUserId: null,
});
}
return this.signinService.signin(request, reply, account as LocalUser);
} catch (err) {
throw new FastifyReplyError(400, typeof err === 'string' ? err : (err as Error).toString());

View File

@@ -38,7 +38,8 @@ import * as ep___admin_federation_updateInstance from './endpoints/admin/federat
import * as ep___admin_getIndexStats from './endpoints/admin/get-index-stats.js';
import * as ep___admin_getTableStats from './endpoints/admin/get-table-stats.js';
import * as ep___admin_getUserIps from './endpoints/admin/get-user-ips.js';
import * as ep___invite from './endpoints/invite.js';
import * as ep___admin_invite_create from './endpoints/admin/invite/create.js';
import * as ep___admin_invite_list from './endpoints/admin/invite/list.js';
import * as ep___admin_promo_create from './endpoints/admin/promo/create.js';
import * as ep___admin_queue_clear from './endpoints/admin/queue/clear.js';
import * as ep___admin_queue_deliverDelayed from './endpoints/admin/queue/deliver-delayed.js';
@@ -230,6 +231,10 @@ import * as ep___i_webhooks_show from './endpoints/i/webhooks/show.js';
import * as ep___i_webhooks_list from './endpoints/i/webhooks/list.js';
import * as ep___i_webhooks_update from './endpoints/i/webhooks/update.js';
import * as ep___i_webhooks_delete from './endpoints/i/webhooks/delete.js';
import * as ep___invite_create from './endpoints/invite/create.js';
import * as ep___invite_delete from './endpoints/invite/delete.js';
import * as ep___invite_list from './endpoints/invite/list.js';
import * as ep___invite_limit from './endpoints/invite/limit.js';
import * as ep___meta from './endpoints/meta.js';
import * as ep___emojis from './endpoints/emojis.js';
import * as ep___emoji from './endpoints/emoji.js';
@@ -376,7 +381,8 @@ const eps = [
['admin/get-index-stats', ep___admin_getIndexStats],
['admin/get-table-stats', ep___admin_getTableStats],
['admin/get-user-ips', ep___admin_getUserIps],
['invite', ep___invite],
['admin/invite/create', ep___admin_invite_create],
['admin/invite/list', ep___admin_invite_list],
['admin/promo/create', ep___admin_promo_create],
['admin/queue/clear', ep___admin_queue_clear],
['admin/queue/deliver-delayed', ep___admin_queue_deliverDelayed],
@@ -568,6 +574,10 @@ const eps = [
['i/webhooks/show', ep___i_webhooks_show],
['i/webhooks/update', ep___i_webhooks_update],
['i/webhooks/delete', ep___i_webhooks_delete],
['invite/create', ep___invite_create],
['invite/delete', ep___invite_delete],
['invite/list', ep___invite_list],
['invite/limit', ep___invite_limit],
['meta', ep___meta],
['emojis', ep___emojis],
['emoji', ep___emoji],

View File

@@ -0,0 +1,80 @@
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { RegistrationTicketsRepository } from '@/models/index.js';
import { InviteCodeEntityService } from '@/core/entities/InviteCodeEntityService.js';
import { IdService } from '@/core/IdService.js';
import { DI } from '@/di-symbols.js';
import { generateInviteCode } from '@/misc/generate-invite-code.js';
import { ApiError } from '../../../error.js';
export const meta = {
tags: ['admin'],
requireCredential: true,
requireModerator: true,
errors: {
invalidDateTime: {
message: 'Invalid date-time format',
code: 'INVALID_DATE_TIME',
id: 'f1380b15-3760-4c6c-a1db-5c3aaf1cbd49',
},
},
res: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'object',
optional: false, nullable: false,
properties: {
code: {
type: 'string',
optional: false, nullable: false,
example: 'GR6S02ERUA5VR',
},
},
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
count: { type: 'integer', minimum: 1, maximum: 100, default: 1 },
expiresAt: { type: 'string', nullable: true },
},
required: [],
} as const;
// eslint-disable-next-line import/no-default-export
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> {
constructor(
@Inject(DI.registrationTicketsRepository)
private registrationTicketsRepository: RegistrationTicketsRepository,
private inviteCodeEntityService: InviteCodeEntityService,
private idService: IdService,
) {
super(meta, paramDef, async (ps, me) => {
if (ps.expiresAt && isNaN(Date.parse(ps.expiresAt))) {
throw new ApiError(meta.errors.invalidDateTime);
}
const ticketsPromises = [];
for (let i = 0; i < ps.count; i++) {
ticketsPromises.push(this.registrationTicketsRepository.insert({
id: this.idService.genId(),
createdAt: new Date(),
expiresAt: ps.expiresAt ? new Date(ps.expiresAt) : null,
code: generateInviteCode(),
}).then(x => this.registrationTicketsRepository.findOneByOrFail(x.identifiers[0])));
}
const tickets = await Promise.all(ticketsPromises);
return await this.inviteCodeEntityService.packMany(tickets, me);
});
}
}

View File

@@ -0,0 +1,70 @@
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { RegistrationTicketsRepository } from '@/models/index.js';
import { InviteCodeEntityService } from '@/core/entities/InviteCodeEntityService.js';
import { DI } from '@/di-symbols.js';
export const meta = {
tags: ['admin'],
requireCredential: true,
requireModerator: true,
res: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'object',
optional: false, nullable: false,
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 },
offset: { type: 'integer', default: 0 },
type: { type: 'string', enum: ['unused', 'used', 'expired', 'all'], default: 'all' },
sort: { type: 'string', enum: ['+createdAt', '-createdAt', '+usedAt', '-usedAt'] },
},
required: [],
} as const;
// eslint-disable-next-line import/no-default-export
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> {
constructor(
@Inject(DI.registrationTicketsRepository)
private registrationTicketsRepository: RegistrationTicketsRepository,
private inviteCodeEntityService: InviteCodeEntityService,
) {
super(meta, paramDef, async (ps, me) => {
const query = this.registrationTicketsRepository.createQueryBuilder('ticket')
.leftJoinAndSelect('ticket.createdBy', 'createdBy')
.leftJoinAndSelect('ticket.usedBy', 'usedBy');
switch (ps.type) {
case 'unused': query.andWhere('ticket.usedBy IS NULL'); break;
case 'used': query.andWhere('ticket.usedBy IS NOT NULL'); break;
case 'expired': query.andWhere('ticket.expiresAt < :now', { now: new Date() }); break;
}
switch (ps.sort) {
case '+createdAt': query.orderBy('ticket.createdAt', 'DESC'); break;
case '-createdAt': query.orderBy('ticket.createdAt', 'ASC'); break;
case '+usedAt': query.orderBy('ticket.usedAt', 'DESC', 'NULLS LAST'); break;
case '-usedAt': query.orderBy('ticket.usedAt', 'ASC', 'NULLS FIRST'); break;
default: query.orderBy('ticket.id', 'DESC'); break;
}
query.limit(ps.limit);
query.skip(ps.offset);
const tickets = await query.getMany();
return await this.inviteCodeEntityService.packMany(tickets, me);
});
}
}

View File

@@ -0,0 +1,82 @@
import { MoreThan } from 'typeorm';
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { RegistrationTicketsRepository } from '@/models/index.js';
import { InviteCodeEntityService } from '@/core/entities/InviteCodeEntityService.js';
import { IdService } from '@/core/IdService.js';
import { RoleService } from '@/core/RoleService.js';
import { DI } from '@/di-symbols.js';
import { generateInviteCode } from '@/misc/generate-invite-code.js';
import { ApiError } from '../../error.js';
export const meta = {
tags: ['meta'],
requireCredential: true,
requireRolePolicy: 'canInvite',
errors: {
exceededCreateLimit: {
message: 'You have exceeded the limit for creating an invitation code.',
code: 'EXCEEDED_LIMIT_OF_CREATE_INVITE_CODE',
id: '8b165dd3-6f37-4557-8db1-73175d63c641',
},
},
res: {
type: 'object',
optional: false, nullable: false,
properties: {
code: {
type: 'string',
optional: false, nullable: false,
example: 'GR6S02ERUA5VR',
},
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {},
required: [],
} as const;
// eslint-disable-next-line import/no-default-export
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> {
constructor(
@Inject(DI.registrationTicketsRepository)
private registrationTicketsRepository: RegistrationTicketsRepository,
private inviteCodeEntityService: InviteCodeEntityService,
private idService: IdService,
private roleService: RoleService,
) {
super(meta, paramDef, async (ps, me) => {
const policies = await this.roleService.getUserPolicies(me.id);
if (policies.inviteLimit) {
const count = await this.registrationTicketsRepository.countBy({
createdAt: MoreThan(new Date(Date.now() - (policies.inviteLimitCycle * 1000 * 60))),
createdById: me.id,
});
if (count >= policies.inviteLimit) {
throw new ApiError(meta.errors.exceededCreateLimit);
}
}
const ticket = await this.registrationTicketsRepository.insert({
id: this.idService.genId(),
createdAt: new Date(),
createdBy: me,
createdById: me.id,
expiresAt: policies.inviteExpirationTime ? new Date(Date.now() + (policies.inviteExpirationTime * 1000 * 60)) : null,
code: generateInviteCode(),
}).then(x => this.registrationTicketsRepository.findOneByOrFail(x.identifiers[0]));
return await this.inviteCodeEntityService.pack(ticket, me);
});
}
}

View File

@@ -0,0 +1,71 @@
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { RegistrationTicketsRepository } from '@/models/index.js';
import { RoleService } from '@/core/RoleService.js';
import { DI } from '@/di-symbols.js';
import { ApiError } from '../../error.js';
export const meta = {
tags: ['meta'],
requireCredential: true,
requireRolePolicy: 'canInvite',
errors: {
noSuchCode: {
message: 'No such invite code.',
code: 'NO_SUCH_INVITE_CODE',
id: 'cd4f9ae4-7854-4e3e-8df9-c296f051e634',
},
cantDelete: {
message: 'You can\'t delete this invite code.',
code: 'CAN_NOT_DELETE_INVITE_CODE',
id: 'ff17af39-000c-4d4e-abdf-848fa30fc1ce',
},
accessDenied: {
message: 'Access denied.',
code: 'ACCESS_DENIED',
id: '5eb8d909-2540-4970-90b8-dd6f86088121',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
inviteId: { type: 'string', format: 'misskey:id' },
},
required: ['inviteId'],
} as const;
// eslint-disable-next-line import/no-default-export
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> {
constructor(
@Inject(DI.registrationTicketsRepository)
private registrationTicketsRepository: RegistrationTicketsRepository,
private roleService: RoleService,
) {
super(meta, paramDef, async (ps, me) => {
const ticket = await this.registrationTicketsRepository.findOneBy({ id: ps.inviteId });
const isModerator = await this.roleService.isModerator(me);
if (ticket == null) {
throw new ApiError(meta.errors.noSuchCode);
}
if (ticket.createdById !== me.id && !isModerator) {
throw new ApiError(meta.errors.accessDenied);
}
if (ticket.usedAt && !isModerator) {
throw new ApiError(meta.errors.cantDelete);
}
await this.registrationTicketsRepository.delete(ticket.id);
});
}
}

View File

@@ -1,9 +1,9 @@
import { Inject, Injectable } from '@nestjs/common';
import { MoreThan } from 'typeorm';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { RegistrationTicketsRepository } from '@/models/index.js';
import { IdService } from '@/core/IdService.js';
import { RoleService } from '@/core/RoleService.js';
import { DI } from '@/di-symbols.js';
import { secureRndstr } from '@/misc/secure-rndstr.js';
export const meta = {
tags: ['meta'],
@@ -15,12 +15,9 @@ export const meta = {
type: 'object',
optional: false, nullable: false,
properties: {
code: {
type: 'string',
optional: false, nullable: false,
example: '2ERUA5VR',
maxLength: 8,
minLength: 8,
remaining: {
type: 'integer',
optional: false, nullable: true,
},
},
},
@@ -39,21 +36,18 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
@Inject(DI.registrationTicketsRepository)
private registrationTicketsRepository: RegistrationTicketsRepository,
private idService: IdService,
private roleService: RoleService,
) {
super(meta, paramDef, async (ps, me) => {
const code = secureRndstr(8, {
chars: '23456789ABCDEFGHJKLMNPQRSTUVWXYZ', // [0-9A-Z] w/o [01IO] (32 patterns)
});
const policies = await this.roleService.getUserPolicies(me.id);
await this.registrationTicketsRepository.insert({
id: this.idService.genId(),
createdAt: new Date(),
code,
});
const count = policies.inviteLimit ? await this.registrationTicketsRepository.countBy({
createdAt: MoreThan(new Date(Date.now() - (policies.inviteExpirationTime * 60 * 1000))),
createdById: me.id,
}) : null;
return {
code,
remaining: count !== null ? Math.max(0, policies.inviteLimit - count) : null,
};
});
}

View File

@@ -0,0 +1,58 @@
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { RegistrationTicketsRepository } from '@/models/index.js';
import { InviteCodeEntityService } from '@/core/entities/InviteCodeEntityService.js';
import { QueryService } from '@/core/QueryService.js';
import { DI } from '@/di-symbols.js';
import { ApiError } from '../../error.js';
export const meta = {
tags: ['meta'],
requireCredential: true,
requireRolePolicy: 'canInvite',
res: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'object',
optional: false, nullable: false,
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 },
sinceId: { type: 'string', format: 'misskey:id' },
untilId: { type: 'string', format: 'misskey:id' },
},
required: [],
} as const;
// eslint-disable-next-line import/no-default-export
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> {
constructor(
@Inject(DI.registrationTicketsRepository)
private registrationTicketsRepository: RegistrationTicketsRepository,
private inviteCodeEntityService: InviteCodeEntityService,
private queryService: QueryService,
) {
super(meta, paramDef, async (ps, me) => {
const query = this.queryService.makePaginationQuery(this.registrationTicketsRepository.createQueryBuilder('ticket'), ps.sinceId, ps.untilId)
.andWhere('ticket.createdById = :meId', { meId: me.id })
.leftJoinAndSelect('ticket.createdBy', 'createdBy')
.leftJoinAndSelect('ticket.usedBy', 'usedBy');
const tickets = await query
.limit(ps.limit)
.getMany();
return await this.inviteCodeEntityService.packMany(tickets, me);
});
}
}