release: v1.2.1 — login captcha, docs site, and bug fixes

Add slider captcha for web login, VitePress documentation on GitHub Pages,
and fixes for gallery delete refresh, pagination jump, and copy-format persistence.
This commit is contained in:
O96u committed 2026-08-29 11:42:18 +08:00
1 parent 469d3be735
commit ff20f97522
66 files changed
+2497 -616

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
import { createLoginCaptchaChallenge } from '../../utils/login-captcha'
export default defineEventHandler(() => {
const { id, svg } = createLoginCaptchaChallenge()
return { id, svg }
})
+19
View File
@@ -0,0 +1,19 @@
import { createApiError } from '../../../utils/api-error'
import { confirmLoginCaptcha } from '../../../utils/login-captcha'
interface ConfirmBody {
id?: string
captchaPosition?: number
}
export default defineEventHandler(async (event) => {
const body = await readBody<ConfirmBody>(event)
const id = body?.id?.trim() ?? ''
const position = Number(body?.captchaPosition)
if (!id || !confirmLoginCaptcha(id, position)) {
createApiError(event, 'INVALID_REQUEST', '滑块未对齐,请重试', 400)
}
return { success: true }
})
+13
View File
@@ -1,3 +1,4 @@
import type { H3Event } from 'h3'
import {
checkAdminSecret,
setLegacyAuthCookie
@@ -10,12 +11,15 @@ import {
} from '../../utils/auth'
import { findUserByUsername } from '../../utils/db'
import { getAdminSecret } from '../../utils/env'
import { verifyLoginCaptcha } from '../../utils/login-captcha'
import { clientIp, logInfo, logWarn } from '../../utils/logger'
interface LoginBody {
username?: string
password?: string
secret?: string
captchaId?: string
captchaPosition?: number
}
const loginAttempts = new Map<string, { count: number, resetAt: number }>()
@@ -38,11 +42,20 @@ function checkRateLimit(ip: string): void {
}
}
function verifyCaptchaOrFail(event: H3Event, body: LoginBody) {
const id = body.captchaId?.trim() ?? ''
const position = Number(body.captchaPosition)
if (!id || !verifyLoginCaptcha(id, position)) {
createApiError(event, 'INVALID_REQUEST', '请先完成滑块验证', 400)
}
}
export default defineEventHandler(async (event) => {
const ip = clientIp(event)
checkRateLimit(ip)
const body = await readBody<LoginBody>(event)
verifyCaptchaOrFail(event, body ?? {})
const initialized = isInitialized()
if (!initialized) {
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest'
import {
__peekLoginCaptchaTargetForTests,
__resetLoginCaptchaStoreForTests,
confirmLoginCaptcha,
createLoginCaptchaChallenge,
verifyLoginCaptcha
} from './login-captcha'
describe('login-captcha', () => {
it('requires confirm before login verify', () => {
__resetLoginCaptchaStoreForTests()
const { id } = createLoginCaptchaChallenge()
const target = __peekLoginCaptchaTargetForTests(id)!
expect(verifyLoginCaptcha(id, target)).toBe(false)
expect(confirmLoginCaptcha(id, target)).toBe(true)
expect(verifyLoginCaptcha(id, target)).toBe(true)
expect(verifyLoginCaptcha(id, target)).toBe(false)
})
it('rejects position outside tolerance', () => {
__resetLoginCaptchaStoreForTests()
const { id } = createLoginCaptchaChallenge()
const target = __peekLoginCaptchaTargetForTests(id)!
expect(confirmLoginCaptcha(id, target + 10)).toBe(false)
})
})
+85
View File
@@ -0,0 +1,85 @@
const TOLERANCE_PERCENT = 4
const TTL_MS = 5 * 60 * 1000
const TRACK_WIDTH = 320
const TRACK_HEIGHT = 48
const TRACK_INSET = 4
const SLOT_SIZE = 40
interface CaptchaChallenge {
targetPercent: number
expiresAt: number
confirmed: boolean
}
const challenges = new Map<string, CaptchaChallenge>()
function purgeExpired() {
const now = Date.now()
for (const [id, challenge] of challenges) {
if (challenge.expiresAt <= now) {
challenges.delete(id)
}
}
}
function isPositionMatch(targetPercent: number, positionPercent: number): boolean {
return Math.abs(positionPercent - targetPercent) <= TOLERANCE_PERCENT
}
/** 缺口槽位 SVG(不返回 targetPercent 数字) */
export function buildCaptchaTrackSvg(targetPercent: number): string {
const maxTravel = TRACK_WIDTH - SLOT_SIZE - TRACK_INSET * 2
const slotX = (TRACK_INSET + (targetPercent / 100) * maxTravel).toFixed(1)
const slotY = (TRACK_HEIGHT - SLOT_SIZE) / 2
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${TRACK_WIDTH} ${TRACK_HEIGHT}" width="100%" height="100%" preserveAspectRatio="none" aria-hidden="true">
<rect x="${slotX}" y="${slotY}" width="${SLOT_SIZE}" height="${SLOT_SIZE}" rx="6" fill="#64748b" opacity="0.14"/>
</svg>`
}
export function createLoginCaptchaChallenge(): { id: string, svg: string } {
purgeExpired()
const id = crypto.randomUUID()
const targetPercent = Math.floor(20 + Math.random() * 61)
challenges.set(id, {
targetPercent,
expiresAt: Date.now() + TTL_MS,
confirmed: false
})
return { id, svg: buildCaptchaTrackSvg(targetPercent) }
}
export function confirmLoginCaptcha(id: string, positionPercent: number): boolean {
purgeExpired()
const challenge = challenges.get(id)
if (!challenge || challenge.expiresAt <= Date.now()) {
if (challenge) challenges.delete(id)
return false
}
if (!Number.isFinite(positionPercent)) return false
if (!isPositionMatch(challenge.targetPercent, positionPercent)) {
return false
}
challenge.confirmed = true
return true
}
export function verifyLoginCaptcha(id: string, positionPercent: number): boolean {
purgeExpired()
const challenge = challenges.get(id)
if (!challenge || challenge.expiresAt <= Date.now()) {
if (challenge) challenges.delete(id)
return false
}
if (!challenge.confirmed) return false
challenges.delete(id)
if (!Number.isFinite(positionPercent)) return false
return isPositionMatch(challenge.targetPercent, positionPercent)
}
export function __resetLoginCaptchaStoreForTests() {
challenges.clear()
}
export function __peekLoginCaptchaTargetForTests(id: string): number | undefined {
return challenges.get(id)?.targetPercent
}