mirror of
https://git.boykissers.com/pawkey/pawkey-sk.git
synced 2025-12-20 04:04:16 +00:00
feat: [frontend / backend / sdk] a bunch of changes
backend: - Validate registrationWord on backend - Add maxRegPerIP rate limit how much accounts can be created per-ip frontend: - Add support for bulk approval rejection / acception - Address unsafe cookies-js defaults - Address addition of maxRegPerIP sdk: - Type definitions
This commit is contained in:
12
packages/backend/migration/1763651560421-MaxRegPerIP.js
Normal file
12
packages/backend/migration/1763651560421-MaxRegPerIP.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
/** @type {import('typeorm').MigrationInterface} */
|
||||||
|
export class MaxRegPerIP1763651560421 {
|
||||||
|
name = 'MaxRegPerIP1763651560421'
|
||||||
|
|
||||||
|
async up(queryRunner) {
|
||||||
|
await queryRunner.query(`ALTER TABLE "meta" ADD "maxRegPerIp" integer NOT NULL DEFAULT 2`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async down(queryRunner) {
|
||||||
|
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "maxRegPerIp"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -89,6 +89,7 @@ export class MetaEntityService {
|
|||||||
approvalRequiredForSignup: instance.approvalRequiredForSignup,
|
approvalRequiredForSignup: instance.approvalRequiredForSignup,
|
||||||
regWordRequired: instance.regWordRequired,
|
regWordRequired: instance.regWordRequired,
|
||||||
registrationWord: instance.registrationWord,
|
registrationWord: instance.registrationWord,
|
||||||
|
maxRegPerIp: instance.maxRegPerIp,
|
||||||
enableHcaptcha: instance.enableHcaptcha,
|
enableHcaptcha: instance.enableHcaptcha,
|
||||||
hcaptchaSiteKey: instance.hcaptchaSiteKey,
|
hcaptchaSiteKey: instance.hcaptchaSiteKey,
|
||||||
enableMcaptcha: instance.enableMcaptcha,
|
enableMcaptcha: instance.enableMcaptcha,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,11 @@
|
|||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
* SPDX-FileCopyrightText: pawinput and pawkey project
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import * as argon2 from 'argon2';
|
import * as argon2 from 'argon2';
|
||||||
import { IsNull } from 'typeorm';
|
import { IsNull, MoreThan } from 'typeorm';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import type { RegistrationTicketsRepository, UsedUsernamesRepository, UserPendingsRepository, UserProfilesRepository, UsersRepository, MiRegistrationTicket, MiMeta } from '@/models/_.js';
|
import type { RegistrationTicketsRepository, UsedUsernamesRepository, UserPendingsRepository, UserProfilesRepository, UsersRepository, MiRegistrationTicket, MiMeta } from '@/models/_.js';
|
||||||
import type { Config } from '@/config.js';
|
import type { Config } from '@/config.js';
|
||||||
@@ -24,6 +24,9 @@ import type { FastifyRequest, FastifyReply } from 'fastify';
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SignupApiService {
|
export class SignupApiService {
|
||||||
|
// In-memory rate limit tracking (poof on server restart)
|
||||||
|
private ipSignupAttempts: Map<string, number[]> = new Map();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(DI.config)
|
@Inject(DI.config)
|
||||||
private config: Config,
|
private config: Config,
|
||||||
@@ -56,6 +59,66 @@ export class SignupApiService {
|
|||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
private getClientIp(request: FastifyRequest): string {
|
||||||
|
const forwardedFor = request.headers['x-forwarded-for'];
|
||||||
|
if (forwardedFor) {
|
||||||
|
const ips = Array.isArray(forwardedFor) ? forwardedFor[0] : forwardedFor;
|
||||||
|
return ips.split(',')[0].trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const realIp = request.headers['x-real-ip'];
|
||||||
|
if (realIp) {
|
||||||
|
return Array.isArray(realIp) ? realIp[0] : realIp;
|
||||||
|
}
|
||||||
|
|
||||||
|
return request.ip ?? request.socket.remoteAddress ?? 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
private checkIpRateLimit(ip: string): boolean {
|
||||||
|
const now = Date.now();
|
||||||
|
const oneDayAgo = now - (24 * 60 * 60 * 1000);
|
||||||
|
const maxRegistrations = Math.max(1, Math.floor(Number(this.meta.maxRegPerIp ?? 2) || 2));
|
||||||
|
|
||||||
|
// Get or create attempts list for this IP
|
||||||
|
let attempts = this.ipSignupAttempts.get(ip) || [];
|
||||||
|
|
||||||
|
// Filter out attempts older than 24 hours
|
||||||
|
attempts = attempts.filter(timestamp => timestamp > oneDayAgo);
|
||||||
|
|
||||||
|
// Check if limit exceeded
|
||||||
|
if (attempts.length >= maxRegistrations) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record this attempt
|
||||||
|
attempts.push(now);
|
||||||
|
this.ipSignupAttempts.set(ip, attempts);
|
||||||
|
|
||||||
|
// Cleanup old entries periodically (keep map from growing indefinitely)
|
||||||
|
if (Math.random() < 0.01) { // 1% chance on each signup
|
||||||
|
this.cleanupOldAttempts();
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
private cleanupOldAttempts(): void {
|
||||||
|
const oneDayAgo = Date.now() - (24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
for (const [ip, attempts] of this.ipSignupAttempts.entries()) {
|
||||||
|
const recentAttempts = attempts.filter(timestamp => timestamp > oneDayAgo);
|
||||||
|
|
||||||
|
if (recentAttempts.length === 0) {
|
||||||
|
this.ipSignupAttempts.delete(ip);
|
||||||
|
} else {
|
||||||
|
this.ipSignupAttempts.set(ip, recentAttempts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async signup(
|
public async signup(
|
||||||
request: FastifyRequest<{
|
request: FastifyRequest<{
|
||||||
@@ -78,6 +141,16 @@ export class SignupApiService {
|
|||||||
) {
|
) {
|
||||||
const body = request.body;
|
const body = request.body;
|
||||||
|
|
||||||
|
// Check IP rate limit (except in test mode)
|
||||||
|
if (process.env.NODE_ENV !== 'test') {
|
||||||
|
const clientIp = this.getClientIp(request);
|
||||||
|
const isAllowed = this.checkIpRateLimit(clientIp);
|
||||||
|
|
||||||
|
if (!isAllowed) {
|
||||||
|
throw new FastifyReplyError(429, 'RATE_LIMIT_EXCEEDED: Too many signups from this IP address. Please try again later.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Verify *Captcha
|
// Verify *Captcha
|
||||||
// ただしテスト時はこの機構は障害となるため無効にする
|
// ただしテスト時はこの機構は障害となるため無効にする
|
||||||
if (process.env.NODE_ENV !== 'test') {
|
if (process.env.NODE_ENV !== 'test') {
|
||||||
@@ -143,6 +216,16 @@ export class SignupApiService {
|
|||||||
reply.code(400);
|
reply.code(400);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate reg word
|
||||||
|
if (this.meta.regWordRequired && this.meta.registrationWord && this.meta.registrationWord.trim() !== '') {
|
||||||
|
const registrationWord = this.meta.registrationWord.toLowerCase().trim();
|
||||||
|
const reasonLower = reason.toLowerCase();
|
||||||
|
|
||||||
|
if (!reasonLower.includes(registrationWord)) {
|
||||||
|
throw new FastifyReplyError(400, 'REGISTRATION_WORD_MISSING: The signup reason must contain the required registration word.');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let ticket: MiRegistrationTicket | null = null;
|
let ticket: MiRegistrationTicket | null = null;
|
||||||
|
|||||||
@@ -47,6 +47,10 @@ export const meta = {
|
|||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
optional: false, nullable: false,
|
optional: false, nullable: false,
|
||||||
},
|
},
|
||||||
|
maxRegPerIp: {
|
||||||
|
type: 'integer',
|
||||||
|
optional: false, nullable: false,
|
||||||
|
},
|
||||||
enableHcaptcha: {
|
enableHcaptcha: {
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
optional: false, nullable: false,
|
optional: false, nullable: false,
|
||||||
@@ -680,6 +684,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||||||
approvalRequiredForSignup: instance.approvalRequiredForSignup,
|
approvalRequiredForSignup: instance.approvalRequiredForSignup,
|
||||||
registrationWord: instance.registrationWord,
|
registrationWord: instance.registrationWord,
|
||||||
regWordRequired: instance.regWordRequired,
|
regWordRequired: instance.regWordRequired,
|
||||||
|
maxRegPerIp: instance.maxRegPerIp,
|
||||||
enableHcaptcha: instance.enableHcaptcha,
|
enableHcaptcha: instance.enableHcaptcha,
|
||||||
hcaptchaSiteKey: instance.hcaptchaSiteKey,
|
hcaptchaSiteKey: instance.hcaptchaSiteKey,
|
||||||
enableMcaptcha: instance.enableMcaptcha,
|
enableMcaptcha: instance.enableMcaptcha,
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ export const paramDef = {
|
|||||||
approvalRequiredForSignup: { type: 'boolean' },
|
approvalRequiredForSignup: { type: 'boolean' },
|
||||||
regWordRequired: { type: 'boolean', nullable: false },
|
regWordRequired: { type: 'boolean', nullable: false },
|
||||||
registrationWord: { type: 'string', nullable: true },
|
registrationWord: { type: 'string', nullable: true },
|
||||||
|
maxRegPerIp: { type: 'integer', nullable: false },
|
||||||
enableHcaptcha: { type: 'boolean' },
|
enableHcaptcha: { type: 'boolean' },
|
||||||
hcaptchaSiteKey: { type: 'string', nullable: true },
|
hcaptchaSiteKey: { type: 'string', nullable: true },
|
||||||
hcaptchaSecretKey: { type: 'string', nullable: true },
|
hcaptchaSecretKey: { type: 'string', nullable: true },
|
||||||
@@ -382,6 +383,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||||||
set.regWordRequired = ps.regWordRequired;
|
set.regWordRequired = ps.regWordRequired;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (ps.maxRegPerIp !== undefined) {
|
||||||
|
set.maxRegPerIp = ps.maxRegPerIp;
|
||||||
|
}
|
||||||
|
|
||||||
if (ps.enableHcaptcha !== undefined) {
|
if (ps.enableHcaptcha !== undefined) {
|
||||||
set.enableHcaptcha = ps.enableHcaptcha;
|
set.enableHcaptcha = ps.enableHcaptcha;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
* SPDX-FileCopyrightText: pawinput and pawkey project
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -187,7 +187,7 @@ export async function login(token: AccountWithToken['token'], redirect?: string)
|
|||||||
token,
|
token,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
Cookies.set('token', token, { expires: 365 * 100 });
|
Cookies.set('token', token, { expires: 365 * 100, secure: true, sameSite: 'lax' });
|
||||||
|
|
||||||
await addAccount(host, me, token);
|
await addAccount(host, me, token);
|
||||||
|
|
||||||
|
|||||||
@@ -328,7 +328,6 @@ export function inputText(props: {
|
|||||||
} | {
|
} | {
|
||||||
canceled: false; result: string;
|
canceled: false; result: string;
|
||||||
}>;
|
}>;
|
||||||
// min lengthが指定されてたら result は null になり得ないことを保証する overload function
|
|
||||||
export function inputText(props: {
|
export function inputText(props: {
|
||||||
type?: 'text' | 'email' | 'password' | 'url';
|
type?: 'text' | 'email' | 'password' | 'url';
|
||||||
title?: string;
|
title?: string;
|
||||||
@@ -566,7 +565,7 @@ export function success(): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function waiting(text?: string | null): Promise<void> {
|
export function waiting(text?: string | null): Promise<{ close: () => void }> {
|
||||||
return new Promise(resolve => {
|
return new Promise(resolve => {
|
||||||
const showing = ref(true);
|
const showing = ref(true);
|
||||||
const { dispose } = popup(MkWaitingDialog, {
|
const { dispose } = popup(MkWaitingDialog, {
|
||||||
@@ -574,9 +573,15 @@ export function waiting(text?: string | null): Promise<void> {
|
|||||||
showing: showing,
|
showing: showing,
|
||||||
text,
|
text,
|
||||||
}, {
|
}, {
|
||||||
done: () => resolve(),
|
done: () => {},
|
||||||
closed: () => dispose(),
|
closed: () => dispose(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
resolve({
|
||||||
|
close: () => {
|
||||||
|
showing.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!--
|
<!--
|
||||||
SPDX-FileCopyrightText: marie and other Sharkey contributors
|
SPDX-FileCopyrightText: pawinput and pawkey project
|
||||||
SPDX-License-Identifier: AGPL-3.0-only
|
SPDX-License-Identifier: AGPL-3.0-only
|
||||||
-->
|
-->
|
||||||
|
|
||||||
@@ -10,8 +10,28 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||||||
<div class="_gaps_m">
|
<div class="_gaps_m">
|
||||||
<MkPagination ref="paginationComponent" :pagination="pagination" :displayLimit="50">
|
<MkPagination ref="paginationComponent" :pagination="pagination" :displayLimit="50">
|
||||||
<template #default="{ items }">
|
<template #default="{ items }">
|
||||||
|
<div :class="$style.buttons">
|
||||||
|
<MkButton @click="toggleSelectAll">
|
||||||
|
{{ allSelected ? 'Unselect All' : 'Select All' }}
|
||||||
|
</MkButton>
|
||||||
|
<MkButton danger :disabled="selectedIds.size === 0" @click="rejectSelected">
|
||||||
|
Reject Selected ({{ selectedIds.size }})
|
||||||
|
</MkButton>
|
||||||
|
<MkButton accent :disabled="selectedIds.size === 0" @click="approveSelected">
|
||||||
|
Approve Selected ({{ selectedIds.size }})
|
||||||
|
</MkButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="_gaps_s">
|
<div class="_gaps_s">
|
||||||
<SkApprovalUser v-for="item in items" :key="item.id" :user="(item as any)" :onDeleted="deleted"/>
|
<div v-for="item in items" :key="item.id" :class="$style.box">
|
||||||
|
<input
|
||||||
|
:class="$style.checkbox"
|
||||||
|
type="checkbox"
|
||||||
|
:checked="selectedIds.has(item.id)"
|
||||||
|
@change="toggleSelection(item.id, ($event.target as HTMLInputElement).checked)"
|
||||||
|
>
|
||||||
|
<SkApprovalUser style="width: 100%;" :user="(item as any)" :onDeleted="deleted" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</MkPagination>
|
</MkPagination>
|
||||||
@@ -22,13 +42,18 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed, useTemplateRef } from 'vue';
|
import { computed, ref, useTemplateRef } from 'vue';
|
||||||
import MkPagination from '@/components/MkPagination.vue';
|
import MkPagination from '@/components/MkPagination.vue';
|
||||||
import SkApprovalUser from '@/components/SkApprovalUser.vue';
|
import SkApprovalUser from '@/components/SkApprovalUser.vue';
|
||||||
|
import MkButton from '@/components/MkButton.vue';
|
||||||
|
import * as os from '@/os.js';
|
||||||
import { i18n } from '@/i18n.js';
|
import { i18n } from '@/i18n.js';
|
||||||
import { definePage } from '@/page';
|
import { definePage } from '@/page';
|
||||||
|
import { misskeyApi } from '@/utility/misskey-api.js';
|
||||||
|
|
||||||
let paginationComponent = useTemplateRef<InstanceType<typeof MkPagination>>('paginationComponent');
|
let paginationComponent = useTemplateRef<InstanceType<typeof MkPagination>>('paginationComponent');
|
||||||
|
const selectedIds = ref<Set<string>>(new Set());
|
||||||
|
const allSelected = ref(false);
|
||||||
|
|
||||||
const pagination = {
|
const pagination = {
|
||||||
endpoint: 'admin/show-users' as const,
|
endpoint: 'admin/show-users' as const,
|
||||||
@@ -41,7 +66,129 @@ const pagination = {
|
|||||||
offsetMode: true,
|
offsetMode: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function toggleSelection(id: string, checked: boolean) {
|
||||||
|
if (checked) {
|
||||||
|
selectedIds.value.add(id);
|
||||||
|
} else {
|
||||||
|
selectedIds.value.delete(id);
|
||||||
|
}
|
||||||
|
// Trigger reactivity
|
||||||
|
selectedIds.value = new Set(selectedIds.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleSelectAll() {
|
||||||
|
if (allSelected.value) {
|
||||||
|
selectedIds.value.clear();
|
||||||
|
allSelected.value = false;
|
||||||
|
} else {
|
||||||
|
const { close } = await os.waiting('Loading all users...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const allUsers: any[] = [];
|
||||||
|
let offset = 0;
|
||||||
|
const limit = 100;
|
||||||
|
let hasMore = true;
|
||||||
|
|
||||||
|
while (hasMore) {
|
||||||
|
const users: any[] = await misskeyApi('admin/show-users', {
|
||||||
|
...pagination.params.value,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (users.length === 0) {
|
||||||
|
hasMore = false;
|
||||||
|
} else {
|
||||||
|
allUsers.push(...users);
|
||||||
|
offset += users.length;
|
||||||
|
|
||||||
|
if (users.length < limit) {
|
||||||
|
hasMore = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedIds.value = new Set(allUsers.map((u: any) => u.id));
|
||||||
|
allSelected.value = true;
|
||||||
|
|
||||||
|
close();
|
||||||
|
await os.success();
|
||||||
|
} catch (error) {
|
||||||
|
close();
|
||||||
|
os.alert({
|
||||||
|
type: 'error',
|
||||||
|
text: 'Failed to load all users',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rejectSelected() {
|
||||||
|
const confirm = await os.confirm({
|
||||||
|
type: 'warning',
|
||||||
|
title: 'Reject Selected Users',
|
||||||
|
text: `Are you sure you want to reject ${selectedIds.value.size} selected user(s)?`,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (confirm.canceled) return;
|
||||||
|
|
||||||
|
const { close } = await os.waiting(`Rejecting ${selectedIds.value.size} user(s)...`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const id of selectedIds.value) {
|
||||||
|
await misskeyApi('admin/decline-user', { userId: id });
|
||||||
|
deleted(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedIds.value.clear();
|
||||||
|
close();
|
||||||
|
await os.success();
|
||||||
|
} catch (error) {
|
||||||
|
close();
|
||||||
|
os.alert({
|
||||||
|
type: 'error',
|
||||||
|
text: 'Failed to reject users',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function approveSelected() {
|
||||||
|
const confirm = await os.confirm({
|
||||||
|
type: 'warning',
|
||||||
|
title: 'Approve Selected Users',
|
||||||
|
text: `Are you sure you want to approve ${selectedIds.value.size} selected user(s)?`,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (confirm.canceled) return;
|
||||||
|
|
||||||
|
const { close } = await os.waiting(`Approving ${selectedIds.value.size} user(s)...`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const id of selectedIds.value) {
|
||||||
|
await misskeyApi('admin/approve-user', { userId: id });
|
||||||
|
deleted(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedIds.value.clear();
|
||||||
|
close();
|
||||||
|
await os.success();
|
||||||
|
} catch (error) {
|
||||||
|
close();
|
||||||
|
os.alert({
|
||||||
|
type: 'error',
|
||||||
|
text: 'Failed to approve users',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function deleted(id: string) {
|
function deleted(id: string) {
|
||||||
|
selectedIds.value.delete(id);
|
||||||
|
selectedIds.value = new Set(selectedIds.value);
|
||||||
|
|
||||||
|
if (allSelected.value && selectedIds.value.size === 0) {
|
||||||
|
allSelected.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
if (paginationComponent.value) {
|
if (paginationComponent.value) {
|
||||||
paginationComponent.value.items.delete(id);
|
paginationComponent.value.items.delete(id);
|
||||||
}
|
}
|
||||||
@@ -67,4 +214,24 @@ definePage(() => ({
|
|||||||
.input {
|
.input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.box {
|
||||||
|
background-color: var(--MI_THEME-panel);
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: var(--MI-radius);
|
||||||
|
display: flex;
|
||||||
|
align-items: start;
|
||||||
|
width: 100%;
|
||||||
|
gap: 10px;
|
||||||
|
|
||||||
|
.checkbox {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttons {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!--
|
<!--
|
||||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
SPDX-FileCopyrightText: pawinput and pawkey project
|
||||||
SPDX-License-Identifier: AGPL-3.0-only
|
SPDX-License-Identifier: AGPL-3.0-only
|
||||||
-->
|
-->
|
||||||
|
|
||||||
@@ -94,11 +94,12 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||||||
</MkFolder>
|
</MkFolder>
|
||||||
|
|
||||||
<MkFolder>
|
<MkFolder>
|
||||||
<template #label>Registration Word</template>
|
<template #label>User Registration</template>
|
||||||
<template v-if="regWordForm.savedState.regWordRequired" #suffix>Enabled</template>
|
<template v-if="regWordForm.modified.value || regMaxIpsForm.modified.value" #footer>
|
||||||
<template v-else #suffix>Disabled</template>
|
<div class="_gaps_s">
|
||||||
<template v-if="regWordForm.modified.value" #footer>
|
<MkFormFooter v-if="regWordForm.modified.value" :form="regWordForm" />
|
||||||
<MkFormFooter :form="regWordForm" />
|
<MkFormFooter v-if="regMaxIpsForm.modified.value" :form="regMaxIpsForm" />
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div class="_gaps_m">
|
<div class="_gaps_m">
|
||||||
@@ -110,6 +111,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||||||
<template #prefix><i class="ti ti-key"></i></template>
|
<template #prefix><i class="ti ti-key"></i></template>
|
||||||
<template #label>Registration Word</template>
|
<template #label>Registration Word</template>
|
||||||
</MkInput>
|
</MkInput>
|
||||||
|
<MkInput v-model="regMaxIpsForm.state.maxRegPerIp" type="number">
|
||||||
|
<template #label>Max registrations per IP</template>
|
||||||
|
</MkInput>
|
||||||
</div>
|
</div>
|
||||||
</MkFolder>
|
</MkFolder>
|
||||||
</div>
|
</div>
|
||||||
@@ -118,7 +122,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref, computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import XBotProtection from './bot-protection.vue';
|
import XBotProtection from './bot-protection.vue';
|
||||||
import MkFolder from '@/components/MkFolder.vue';
|
import MkFolder from '@/components/MkFolder.vue';
|
||||||
import MkRadios from '@/components/MkRadios.vue';
|
import MkRadios from '@/components/MkRadios.vue';
|
||||||
@@ -165,6 +169,15 @@ const regWordForm = useForm({
|
|||||||
fetchInstance(true);
|
fetchInstance(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const regMaxIpsForm = useForm({
|
||||||
|
maxRegPerIp: meta.maxRegPerIp,
|
||||||
|
}, async (state) => {
|
||||||
|
await os.apiWithDialog('admin/update-meta', {
|
||||||
|
maxRegPerIp: Number(state.maxRegPerIp),
|
||||||
|
});
|
||||||
|
fetchInstance(true);
|
||||||
|
})
|
||||||
|
|
||||||
const emailValidationForm = useForm({
|
const emailValidationForm = useForm({
|
||||||
enableActiveEmailValidation: meta.enableActiveEmailValidation,
|
enableActiveEmailValidation: meta.enableActiveEmailValidation,
|
||||||
enableVerifymailApi: meta.enableVerifymailApi,
|
enableVerifymailApi: meta.enableVerifymailApi,
|
||||||
|
|||||||
50
packages/frontend/src/utility/load-all-pages.ts
Normal file
50
packages/frontend/src/utility/load-all-pages.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as os from '@/os.js';
|
||||||
|
import { misskeyApi } from '@/utility/misskey-api.js';
|
||||||
|
import type { Endpoint } from '@/utility/misskey-api.js';
|
||||||
|
|
||||||
|
export async function loadAllPages<E extends Endpoint>(
|
||||||
|
endpoint: E,
|
||||||
|
params: any = {},
|
||||||
|
limit: number = 30
|
||||||
|
): Promise<any[]> {
|
||||||
|
const waitingDispose = await os.waiting('Loading all items...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const allItems: any[] = [];
|
||||||
|
let untilId: string | undefined = undefined;
|
||||||
|
let hasMore = true;
|
||||||
|
|
||||||
|
while (hasMore) {
|
||||||
|
const items = await misskeyApi(endpoint, {
|
||||||
|
...params,
|
||||||
|
limit,
|
||||||
|
untilId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
hasMore = false;
|
||||||
|
} else {
|
||||||
|
allItems.push(...items);
|
||||||
|
untilId = items[items.length - 1].id;
|
||||||
|
|
||||||
|
if (items.length < limit) {
|
||||||
|
hasMore = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await os.success();
|
||||||
|
return allItems;
|
||||||
|
} catch (error) {
|
||||||
|
await os.alert({
|
||||||
|
type: 'error',
|
||||||
|
text: 'Failed to load all items',
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user