1
0

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
+2 -2
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);
+8 -3
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;
}
});
});
}
+183 -16
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>
+22 -9
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,
@@ -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;
}
}