1
0
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:
Leafus
2025-11-20 16:57:33 +01:00
parent 39177b6dcc
commit 04178a2ae5
12 changed files with 35584 additions and 33808 deletions

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,34 +1,59 @@
<!--
SPDX-FileCopyrightText: marie and other Sharkey contributors
SPDX-FileCopyrightText: pawinput and pawkey project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div>
<PageWithHeader :tabs="headerTabs">
<div class="_spacer" style="--MI_SPACER-w: 900px;">
<div class="_gaps_m">
<MkPagination ref="paginationComponent" :pagination="pagination" :displayLimit="50">
<template #default="{ items }">
<div class="_gaps_s">
<SkApprovalUser v-for="item in items" :key="item.id" :user="(item as any)" :onDeleted="deleted"/>
</div>
</template>
</MkPagination>
<div>
<PageWithHeader :tabs="headerTabs">
<div class="_spacer" style="--MI_SPACER-w: 900px;">
<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">
<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>
</div>
</div>
</div>
</PageWithHeader>
</div>
</PageWithHeader>
</div>
</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>

View File

@@ -1,5 +1,5 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-FileCopyrightText: pawinput and pawkey project
SPDX-License-Identifier: AGPL-3.0-only
-->
@@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkFolder v-if="meta.federation !== 'none'">
<template #label>{{ i18n.ts.authorizedFetchSection }}</template>
<template #suffix>{{ meta.allowUnsignedFetch !== 'always' ? i18n.ts.enabled : i18n.ts.disabled
}}</template>
}}</template>
<template v-if="authFetchForm.modified.value" #footer>
<MkFormFooter :form="authFetchForm" />
</template>
@@ -20,7 +20,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #caption>{{ i18n.ts.authorizedFetchDescription }}</template>
<option value="never">{{ i18n.ts._authorizedFetchValue.never }} - {{
i18n.ts._authorizedFetchValueDescription.never
}}</option>
}}</option>
<option value="always">{{ i18n.ts._authorizedFetchValue.always }} - {{
i18n.ts._authorizedFetchValueDescription.always }}</option>
<option value="essential">{{ i18n.ts._authorizedFetchValue.essential }} - {{
@@ -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,

View 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