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,
|
||||
regWordRequired: instance.regWordRequired,
|
||||
registrationWord: instance.registrationWord,
|
||||
maxRegPerIp: instance.maxRegPerIp,
|
||||
enableHcaptcha: instance.enableHcaptcha,
|
||||
hcaptchaSiteKey: instance.hcaptchaSiteKey,
|
||||
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
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import * as argon2 from 'argon2';
|
||||
import { IsNull } from 'typeorm';
|
||||
import { IsNull, MoreThan } from 'typeorm';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { RegistrationTicketsRepository, UsedUsernamesRepository, UserPendingsRepository, UserProfilesRepository, UsersRepository, MiRegistrationTicket, MiMeta } from '@/models/_.js';
|
||||
import type { Config } from '@/config.js';
|
||||
@@ -24,6 +24,9 @@ import type { FastifyRequest, FastifyReply } from 'fastify';
|
||||
|
||||
@Injectable()
|
||||
export class SignupApiService {
|
||||
// In-memory rate limit tracking (poof on server restart)
|
||||
private ipSignupAttempts: Map<string, number[]> = new Map();
|
||||
|
||||
constructor(
|
||||
@Inject(DI.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
|
||||
public async signup(
|
||||
request: FastifyRequest<{
|
||||
@@ -78,6 +141,16 @@ export class SignupApiService {
|
||||
) {
|
||||
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
|
||||
// ただしテスト時はこの機構は障害となるため無効にする
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
@@ -143,6 +216,16 @@ export class SignupApiService {
|
||||
reply.code(400);
|
||||
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;
|
||||
|
||||
@@ -47,6 +47,10 @@ export const meta = {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
maxRegPerIp: {
|
||||
type: 'integer',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
enableHcaptcha: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
@@ -680,6 +684,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
approvalRequiredForSignup: instance.approvalRequiredForSignup,
|
||||
registrationWord: instance.registrationWord,
|
||||
regWordRequired: instance.regWordRequired,
|
||||
maxRegPerIp: instance.maxRegPerIp,
|
||||
enableHcaptcha: instance.enableHcaptcha,
|
||||
hcaptchaSiteKey: instance.hcaptchaSiteKey,
|
||||
enableMcaptcha: instance.enableMcaptcha,
|
||||
|
||||
@@ -79,6 +79,7 @@ export const paramDef = {
|
||||
approvalRequiredForSignup: { type: 'boolean' },
|
||||
regWordRequired: { type: 'boolean', nullable: false },
|
||||
registrationWord: { type: 'string', nullable: true },
|
||||
maxRegPerIp: { type: 'integer', nullable: false },
|
||||
enableHcaptcha: { type: 'boolean' },
|
||||
hcaptchaSiteKey: { 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;
|
||||
}
|
||||
|
||||
if (ps.maxRegPerIp !== undefined) {
|
||||
set.maxRegPerIp = ps.maxRegPerIp;
|
||||
}
|
||||
|
||||
if (ps.enableHcaptcha !== undefined) {
|
||||
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
|
||||
*/
|
||||
|
||||
@@ -187,7 +187,7 @@ export async function login(token: AccountWithToken['token'], redirect?: string)
|
||||
token,
|
||||
}));
|
||||
|
||||
Cookies.set('token', token, { expires: 365 * 100 });
|
||||
Cookies.set('token', token, { expires: 365 * 100, secure: true, sameSite: 'lax' });
|
||||
|
||||
await addAccount(host, me, token);
|
||||
|
||||
|
||||
@@ -328,7 +328,6 @@ export function inputText(props: {
|
||||
} | {
|
||||
canceled: false; result: string;
|
||||
}>;
|
||||
// min lengthが指定されてたら result は null になり得ないことを保証する overload function
|
||||
export function inputText(props: {
|
||||
type?: 'text' | 'email' | 'password' | 'url';
|
||||
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 => {
|
||||
const showing = ref(true);
|
||||
const { dispose } = popup(MkWaitingDialog, {
|
||||
@@ -574,9 +573,15 @@ export function waiting(text?: string | null): Promise<void> {
|
||||
showing: showing,
|
||||
text,
|
||||
}, {
|
||||
done: () => resolve(),
|
||||
done: () => {},
|
||||
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
|
||||
-->
|
||||
|
||||
@@ -10,8 +10,28 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<div class="_gaps_m">
|
||||
<MkPagination ref="paginationComponent" :pagination="pagination" :displayLimit="50">
|
||||
<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">
|
||||
<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>
|
||||
</template>
|
||||
</MkPagination>
|
||||
@@ -22,13 +42,18 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, useTemplateRef } from 'vue';
|
||||
import { computed, ref, useTemplateRef } from 'vue';
|
||||
import MkPagination from '@/components/MkPagination.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 { definePage } from '@/page';
|
||||
import { misskeyApi } from '@/utility/misskey-api.js';
|
||||
|
||||
let paginationComponent = useTemplateRef<InstanceType<typeof MkPagination>>('paginationComponent');
|
||||
const selectedIds = ref<Set<string>>(new Set());
|
||||
const allSelected = ref(false);
|
||||
|
||||
const pagination = {
|
||||
endpoint: 'admin/show-users' as const,
|
||||
@@ -41,7 +66,129 @@ const pagination = {
|
||||
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) {
|
||||
selectedIds.value.delete(id);
|
||||
selectedIds.value = new Set(selectedIds.value);
|
||||
|
||||
if (allSelected.value && selectedIds.value.size === 0) {
|
||||
allSelected.value = false;
|
||||
}
|
||||
|
||||
if (paginationComponent.value) {
|
||||
paginationComponent.value.items.delete(id);
|
||||
}
|
||||
@@ -67,4 +214,24 @@ definePage(() => ({
|
||||
.input {
|
||||
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>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-FileCopyrightText: pawinput and pawkey project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
@@ -94,11 +94,12 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</MkFolder>
|
||||
|
||||
<MkFolder>
|
||||
<template #label>Registration Word</template>
|
||||
<template v-if="regWordForm.savedState.regWordRequired" #suffix>Enabled</template>
|
||||
<template v-else #suffix>Disabled</template>
|
||||
<template v-if="regWordForm.modified.value" #footer>
|
||||
<MkFormFooter :form="regWordForm" />
|
||||
<template #label>User Registration</template>
|
||||
<template v-if="regWordForm.modified.value || regMaxIpsForm.modified.value" #footer>
|
||||
<div class="_gaps_s">
|
||||
<MkFormFooter v-if="regWordForm.modified.value" :form="regWordForm" />
|
||||
<MkFormFooter v-if="regMaxIpsForm.modified.value" :form="regMaxIpsForm" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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 #label>Registration Word</template>
|
||||
</MkInput>
|
||||
<MkInput v-model="regMaxIpsForm.state.maxRegPerIp" type="number">
|
||||
<template #label>Max registrations per IP</template>
|
||||
</MkInput>
|
||||
</div>
|
||||
</MkFolder>
|
||||
</div>
|
||||
@@ -118,7 +122,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { computed } from 'vue';
|
||||
import XBotProtection from './bot-protection.vue';
|
||||
import MkFolder from '@/components/MkFolder.vue';
|
||||
import MkRadios from '@/components/MkRadios.vue';
|
||||
@@ -165,6 +169,15 @@ const regWordForm = useForm({
|
||||
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({
|
||||
enableActiveEmailValidation: meta.enableActiveEmailValidation,
|
||||
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