release: v1.2.7 — 登录人机验证(滑块 / Turnstile / Cap)
This commit is contained in:
1 parent
8408d01197
commit
dcdfb793e6
42 files changed
+1103
-72
No files matched your search
@@ -4,6 +4,17 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [1.2.7] - 2026-09-03
|
||||||
|
|
||||||
|
### 新增
|
||||||
|
|
||||||
|
- 登录/注册人机验证:本地滑块、Cloudflare Turnstile、Cap;管理员在「设置 → 访问控制」中切换
|
||||||
|
- 运维 CLI `slider`:将登录验证重置为本地滑块(`docker exec pichost slider`)
|
||||||
|
|
||||||
|
### 改进
|
||||||
|
|
||||||
|
- 登出后登录页验证码可正常重新加载
|
||||||
|
|
||||||
## [1.2.6] - 2026-09-01
|
## [1.2.6] - 2026-09-01
|
||||||
|
|
||||||
### 新增
|
### 新增
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ ENV NODE_ENV=production \
|
|||||||
COPY --from=build /app/.output ./.output
|
COPY --from=build /app/.output ./.output
|
||||||
COPY server/cli/reset-password.mjs ./server/cli/reset-password.mjs
|
COPY server/cli/reset-password.mjs ./server/cli/reset-password.mjs
|
||||||
COPY server/cli/migrate-to-single-images.mjs ./server/cli/migrate-to-single-images.mjs
|
COPY server/cli/migrate-to-single-images.mjs ./server/cli/migrate-to-single-images.mjs
|
||||||
|
COPY server/cli/slider.mjs ./server/cli/slider.mjs
|
||||||
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
||||||
RUN chmod +x /docker-entrypoint.sh
|
RUN chmod +x /docker-entrypoint.sh
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import logoLight from '~/assets/image/logo-light.png'
|
import logoLight from '~/assets/image/logo-light.png'
|
||||||
import logoDark from '~/assets/image/logo-dark.png'
|
import logoDark from '~/assets/image/logo-dark.png'
|
||||||
|
import type { LoginVerificationPayload } from '~/types/auth'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { login, fetchStatus, authStatus } = useAuth()
|
const { login, fetchStatus, authStatus } = useAuth()
|
||||||
@@ -11,45 +12,53 @@ const username = ref('')
|
|||||||
const password = ref('')
|
const password = ref('')
|
||||||
const secret = ref('')
|
const secret = ref('')
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const captchaRef = ref<{ reset: () => Promise<void> } | null>(null)
|
const verificationRef = ref<{ reset: () => Promise<void> } | null>(null)
|
||||||
const captchaPayload = ref<{ captchaId: string, captchaPosition: number } | null>(null)
|
const verificationPayload = ref<LoginVerificationPayload | null>(null)
|
||||||
|
const verificationMountKey = ref(0)
|
||||||
|
|
||||||
const legacyMode = computed(() => authStatus.value?.legacyMode ?? false)
|
const legacyMode = computed(() => authStatus.value?.legacyMode ?? false)
|
||||||
const allowRegistration = computed(() => authStatus.value?.allowRegistration ?? false)
|
const allowRegistration = computed(() => authStatus.value?.allowRegistration ?? false)
|
||||||
|
const loginVerification = computed(() =>
|
||||||
|
authStatus.value?.loginVerification ?? { method: 'slider' as const }
|
||||||
|
)
|
||||||
|
|
||||||
const canSubmit = computed(() =>
|
const canSubmit = computed(() =>
|
||||||
Boolean(captchaPayload.value)
|
Boolean(verificationPayload.value)
|
||||||
&& (legacyMode.value ? Boolean(secret.value) : Boolean(username.value && password.value))
|
&& (legacyMode.value ? Boolean(secret.value) : Boolean(username.value && password.value))
|
||||||
)
|
)
|
||||||
|
|
||||||
function onCaptchaVerified(payload: { id: string, positionPercent: number }) {
|
function onVerificationVerified(payload: LoginVerificationPayload) {
|
||||||
captchaPayload.value = {
|
verificationPayload.value = payload
|
||||||
captchaId: payload.id,
|
|
||||||
captchaPosition: payload.positionPercent
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resetCaptcha() {
|
function onVerificationCleared() {
|
||||||
captchaPayload.value = null
|
verificationPayload.value = null
|
||||||
await captchaRef.value?.reset()
|
}
|
||||||
|
|
||||||
|
async function resetVerification() {
|
||||||
|
verificationPayload.value = null
|
||||||
|
await verificationRef.value?.reset()
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
verificationPayload.value = null
|
||||||
|
verificationMountKey.value += 1
|
||||||
const status = await fetchStatus()
|
const status = await fetchStatus()
|
||||||
if (!status.initialized && !status.legacyMode) {
|
if (!status.initialized && !status.legacyMode) {
|
||||||
await router.replace('/setup')
|
await router.replace('/setup')
|
||||||
|
return
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
if (!canSubmit.value || loading.value || !captchaPayload.value) return
|
if (!canSubmit.value || loading.value || !verificationPayload.value) return
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const captcha = captchaPayload.value
|
const verification = verificationPayload.value
|
||||||
const result = legacyMode.value
|
const result = legacyMode.value
|
||||||
? await login({ secret: secret.value }, captcha)
|
? await login({ secret: secret.value }, verification)
|
||||||
: await login({ username: username.value.trim(), password: password.value }, captcha)
|
: await login({ username: username.value.trim(), password: password.value }, verification)
|
||||||
|
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
if (result.needsMigration) {
|
if (result.needsMigration) {
|
||||||
@@ -59,10 +68,10 @@ async function submit() {
|
|||||||
username.value = ''
|
username.value = ''
|
||||||
password.value = ''
|
password.value = ''
|
||||||
secret.value = ''
|
secret.value = ''
|
||||||
await resetCaptcha()
|
await resetVerification()
|
||||||
toast.add({ title: t('auth.loginSuccess'), color: 'success' })
|
toast.add({ title: t('auth.loginSuccess'), color: 'success' })
|
||||||
} else {
|
} else {
|
||||||
await resetCaptcha()
|
await resetVerification()
|
||||||
toast.add({
|
toast.add({
|
||||||
title: result.error ?? (legacyMode.value ? t('auth.loginFailedSecret') : t('auth.loginFailedCredentials')),
|
title: result.error ?? (legacyMode.value ? t('auth.loginFailedSecret') : t('auth.loginFailedCredentials')),
|
||||||
color: 'error'
|
color: 'error'
|
||||||
@@ -170,9 +179,14 @@ async function submit() {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<SliderCaptcha
|
<LoginVerification
|
||||||
ref="captchaRef"
|
:key="verificationMountKey"
|
||||||
@verified="onCaptchaVerified"
|
ref="verificationRef"
|
||||||
|
:method="loginVerification.method"
|
||||||
|
:turnstile-site-key="loginVerification.turnstileSiteKey"
|
||||||
|
:cap-api-endpoint="loginVerification.capApiEndpoint"
|
||||||
|
@verified="onVerificationVerified"
|
||||||
|
@cleared="onVerificationCleared"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<UButton
|
<UButton
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import 'cap-widget'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
apiEndpoint: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
verified: [payload: { capToken: string }]
|
||||||
|
cleared: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const widgetRef = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
function onSolve(event: Event) {
|
||||||
|
const token = (event as CustomEvent<{ token: string }>).detail?.token
|
||||||
|
if (token) {
|
||||||
|
emit('verified', { capToken: token })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onError() {
|
||||||
|
emit('cleared')
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
const widget = widgetRef.value as (HTMLElement & { reset?: () => void }) | null
|
||||||
|
widget?.reset?.()
|
||||||
|
emit('cleared')
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ reset })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ClientOnly>
|
||||||
|
<cap-widget
|
||||||
|
ref="widgetRef"
|
||||||
|
:data-cap-api-endpoint="props.apiEndpoint"
|
||||||
|
:data-cap-i18n-initial-state="t('auth.capInitialState')"
|
||||||
|
:data-cap-i18n-verifying-label="t('auth.capVerifying')"
|
||||||
|
:data-cap-i18n-solved-label="t('auth.capSolved')"
|
||||||
|
:data-cap-i18n-error-label="t('auth.capError')"
|
||||||
|
@solve="onSolve"
|
||||||
|
@error="onError"
|
||||||
|
/>
|
||||||
|
</ClientOnly>
|
||||||
|
</template>
|
||||||
@@ -10,12 +10,14 @@ const props = withDefaults(defineProps<{
|
|||||||
showStorage?: boolean
|
showStorage?: boolean
|
||||||
compact?: boolean
|
compact?: boolean
|
||||||
gallery?: boolean
|
gallery?: boolean
|
||||||
|
allowDelete?: boolean
|
||||||
}>(), {
|
}>(), {
|
||||||
selectable: true,
|
selectable: true,
|
||||||
showKey: true,
|
showKey: true,
|
||||||
showStorage: false,
|
showStorage: false,
|
||||||
compact: false,
|
compact: false,
|
||||||
gallery: false
|
gallery: false,
|
||||||
|
allowDelete: true
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -251,6 +253,7 @@ async function copyGalleryUrl() {
|
|||||||
@click="emit('preview')"
|
@click="emit('preview')"
|
||||||
/>
|
/>
|
||||||
<UButton
|
<UButton
|
||||||
|
v-if="allowDelete"
|
||||||
icon="i-lucide-trash-2"
|
icon="i-lucide-trash-2"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
color="neutral"
|
color="neutral"
|
||||||
|
|||||||
@@ -12,13 +12,15 @@ const props = withDefaults(defineProps<{
|
|||||||
compact?: boolean
|
compact?: boolean
|
||||||
/** 图库:5 列 × 2 行,缩略图略扁 */
|
/** 图库:5 列 × 2 行,缩略图略扁 */
|
||||||
gallery?: boolean
|
gallery?: boolean
|
||||||
|
allowDelete?: boolean
|
||||||
}>(), {
|
}>(), {
|
||||||
selectable: true,
|
selectable: true,
|
||||||
showKey: true,
|
showKey: true,
|
||||||
showStorage: false,
|
showStorage: false,
|
||||||
dense: false,
|
dense: false,
|
||||||
compact: false,
|
compact: false,
|
||||||
gallery: false
|
gallery: false,
|
||||||
|
allowDelete: true
|
||||||
})
|
})
|
||||||
|
|
||||||
const gridClass = computed(() => {
|
const gridClass = computed(() => {
|
||||||
@@ -65,6 +67,7 @@ function updateSelection(key: string, selected: boolean) {
|
|||||||
:show-storage="showStorage"
|
:show-storage="showStorage"
|
||||||
:compact="compact"
|
:compact="compact"
|
||||||
:gallery="gallery"
|
:gallery="gallery"
|
||||||
|
:allow-delete="allowDelete"
|
||||||
@update:selected="updateSelection(image.key, $event)"
|
@update:selected="updateSelection(image.key, $event)"
|
||||||
@preview="emit('preview', image)"
|
@preview="emit('preview', image)"
|
||||||
@delete="emit('delete', image)"
|
@delete="emit('delete', image)"
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ const props = withDefaults(defineProps<{
|
|||||||
image: ImageItem | null
|
image: ImageItem | null
|
||||||
showStorage?: boolean
|
showStorage?: boolean
|
||||||
deleting?: boolean
|
deleting?: boolean
|
||||||
|
allowDelete?: boolean
|
||||||
}>(), {
|
}>(), {
|
||||||
showStorage: false
|
showStorage: false,
|
||||||
|
allowDelete: true
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -396,6 +398,7 @@ function onImageLoad(event: Event) {
|
|||||||
@click="copyLink"
|
@click="copyLink"
|
||||||
/>
|
/>
|
||||||
<UButton
|
<UButton
|
||||||
|
v-if="allowDelete"
|
||||||
:label="t('common.delete')"
|
:label="t('common.delete')"
|
||||||
icon="i-lucide-trash-2"
|
icon="i-lucide-trash-2"
|
||||||
color="error"
|
color="error"
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type {
|
||||||
|
LoginVerificationMethod,
|
||||||
|
LoginVerificationPayload
|
||||||
|
} from '~/types/auth'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
method: LoginVerificationMethod
|
||||||
|
turnstileSiteKey?: string
|
||||||
|
capApiEndpoint?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
verified: [payload: LoginVerificationPayload]
|
||||||
|
cleared: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const sliderRef = ref<{ reset: () => Promise<void> } | null>(null)
|
||||||
|
const turnstileRef = ref<{ reset: () => void } | null>(null)
|
||||||
|
const capRef = ref<{ reset: () => void } | null>(null)
|
||||||
|
|
||||||
|
function onSliderVerified(payload: { id: string, positionPercent: number }) {
|
||||||
|
emit('verified', {
|
||||||
|
method: 'slider',
|
||||||
|
captchaId: payload.id,
|
||||||
|
captchaPosition: payload.positionPercent
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTurnstileVerified(payload: { turnstileToken: string }) {
|
||||||
|
emit('verified', {
|
||||||
|
method: 'turnstile',
|
||||||
|
turnstileToken: payload.turnstileToken
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCapVerified(payload: { capToken: string }) {
|
||||||
|
emit('verified', {
|
||||||
|
method: 'cap',
|
||||||
|
capToken: payload.capToken
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCleared() {
|
||||||
|
emit('cleared')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reset() {
|
||||||
|
emit('cleared')
|
||||||
|
if (props.method === 'slider') {
|
||||||
|
await sliderRef.value?.reset()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (props.method === 'turnstile') {
|
||||||
|
turnstileRef.value?.reset()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
capRef.value?.reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ reset })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SliderCaptcha
|
||||||
|
v-if="method === 'slider'"
|
||||||
|
ref="sliderRef"
|
||||||
|
@verified="onSliderVerified"
|
||||||
|
/>
|
||||||
|
<TurnstileCaptcha
|
||||||
|
v-else-if="method === 'turnstile' && turnstileSiteKey"
|
||||||
|
ref="turnstileRef"
|
||||||
|
:site-key="turnstileSiteKey"
|
||||||
|
@verified="onTurnstileVerified"
|
||||||
|
@cleared="onCleared"
|
||||||
|
/>
|
||||||
|
<CapCaptcha
|
||||||
|
v-else-if="method === 'cap' && capApiEndpoint"
|
||||||
|
ref="capRef"
|
||||||
|
:api-endpoint="capApiEndpoint"
|
||||||
|
@verified="onCapVerified"
|
||||||
|
@cleared="onCleared"
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
v-else
|
||||||
|
class="rounded-lg border border-warning/25 bg-warning/5 px-3 py-2 text-xs text-warning"
|
||||||
|
>
|
||||||
|
{{ $t('auth.verificationMisconfigured') }}
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps<{
|
||||||
|
siteKey: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
verified: [payload: { turnstileToken: string }]
|
||||||
|
cleared: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
interface TurnstileRenderOptions {
|
||||||
|
'sitekey': string
|
||||||
|
'action'?: string
|
||||||
|
'callback'?: (token: string) => void
|
||||||
|
'expired-callback'?: () => void
|
||||||
|
'error-callback'?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TurnstileApi {
|
||||||
|
render: (container: HTMLElement, options: TurnstileRenderOptions) => string
|
||||||
|
reset: (widgetId?: string) => void
|
||||||
|
remove: (widgetId: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
turnstile?: TurnstileApi
|
||||||
|
onTurnstileLoad?: () => void
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const containerRef = ref<HTMLElement | null>(null)
|
||||||
|
const widgetId = ref<string | null>(null)
|
||||||
|
const scriptLoaded = ref(false)
|
||||||
|
|
||||||
|
function onVerified(token: string) {
|
||||||
|
if (token) {
|
||||||
|
emit('verified', { turnstileToken: token })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCleared() {
|
||||||
|
emit('cleared')
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderWidget() {
|
||||||
|
if (!containerRef.value || !window.turnstile || !props.siteKey) return
|
||||||
|
if (widgetId.value) {
|
||||||
|
window.turnstile.remove(widgetId.value)
|
||||||
|
widgetId.value = null
|
||||||
|
}
|
||||||
|
widgetId.value = window.turnstile.render(containerRef.value, {
|
||||||
|
'sitekey': props.siteKey,
|
||||||
|
'action': 'login',
|
||||||
|
'callback': onVerified,
|
||||||
|
'expired-callback': onCleared,
|
||||||
|
'error-callback': onCleared
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryRenderWidget() {
|
||||||
|
if (scriptLoaded.value) {
|
||||||
|
renderWidget()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadScript() {
|
||||||
|
if (window.turnstile) {
|
||||||
|
scriptLoaded.value = true
|
||||||
|
tryRenderWidget()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
window.onTurnstileLoad = () => {
|
||||||
|
scriptLoaded.value = true
|
||||||
|
tryRenderWidget()
|
||||||
|
}
|
||||||
|
const existing = document.querySelector('script[data-turnstile]')
|
||||||
|
if (existing) {
|
||||||
|
if (window.turnstile) {
|
||||||
|
scriptLoaded.value = true
|
||||||
|
tryRenderWidget()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const script = document.createElement('script')
|
||||||
|
script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit&onload=onTurnstileLoad'
|
||||||
|
script.async = true
|
||||||
|
script.defer = true
|
||||||
|
script.dataset.turnstile = 'true'
|
||||||
|
document.head.appendChild(script)
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
if (window.turnstile && widgetId.value) {
|
||||||
|
window.turnstile.reset(widgetId.value)
|
||||||
|
}
|
||||||
|
onCleared()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
loadScript()
|
||||||
|
await nextTick()
|
||||||
|
tryRenderWidget()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch([containerRef, () => props.siteKey, scriptLoaded], () => {
|
||||||
|
tryRenderWidget()
|
||||||
|
}, { flush: 'post' })
|
||||||
|
|
||||||
|
defineExpose({ reset })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ClientOnly>
|
||||||
|
<div
|
||||||
|
ref="containerRef"
|
||||||
|
class="flex min-h-16 items-center justify-center"
|
||||||
|
/>
|
||||||
|
</ClientOnly>
|
||||||
|
</template>
|
||||||
@@ -8,9 +8,11 @@ const props = withDefaults(defineProps<{
|
|||||||
viewMode: 'grid' | 'list'
|
viewMode: 'grid' | 'list'
|
||||||
selectedCount?: number
|
selectedCount?: number
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
|
showBatchDelete?: boolean
|
||||||
}>(), {
|
}>(), {
|
||||||
selectedCount: 0,
|
selectedCount: 0,
|
||||||
loading: false
|
loading: false,
|
||||||
|
showBatchDelete: true
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -99,6 +101,7 @@ function viewButtonClass(active: boolean) {
|
|||||||
</UButton>
|
</UButton>
|
||||||
|
|
||||||
<UButton
|
<UButton
|
||||||
|
v-if="showBatchDelete"
|
||||||
type="button"
|
type="button"
|
||||||
icon="i-lucide-trash-2"
|
icon="i-lucide-trash-2"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ const props = withDefaults(defineProps<{
|
|||||||
selectable?: boolean
|
selectable?: boolean
|
||||||
showStorage?: boolean
|
showStorage?: boolean
|
||||||
emptyText?: string
|
emptyText?: string
|
||||||
|
showDelete?: boolean
|
||||||
}>(), {
|
}>(), {
|
||||||
selectable: false,
|
selectable: false,
|
||||||
showStorage: false
|
showStorage: false,
|
||||||
|
showDelete: true
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -198,6 +200,7 @@ function typeLabel(image: ImageItem) {
|
|||||||
@click="emit('preview', image)"
|
@click="emit('preview', image)"
|
||||||
/>
|
/>
|
||||||
<UButton
|
<UButton
|
||||||
|
v-if="showDelete"
|
||||||
icon="i-lucide-trash-2"
|
icon="i-lucide-trash-2"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
color="neutral"
|
color="neutral"
|
||||||
|
|||||||
+37
-10
@@ -1,3 +1,5 @@
|
|||||||
|
import type { LoginVerificationPayload } from '~/types/auth'
|
||||||
|
|
||||||
export type AuthStatus = 'checking' | 'authenticated' | 'unauthenticated'
|
export type AuthStatus = 'checking' | 'authenticated' | 'unauthenticated'
|
||||||
|
|
||||||
export type UserRole = 'admin' | 'user'
|
export type UserRole = 'admin' | 'user'
|
||||||
@@ -8,11 +10,18 @@ export interface AuthUser {
|
|||||||
role: UserRole
|
role: UserRole
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LoginVerificationPublicConfig {
|
||||||
|
method: 'slider' | 'turnstile' | 'cap'
|
||||||
|
turnstileSiteKey?: string
|
||||||
|
capApiEndpoint?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface AuthStatusResponse {
|
export interface AuthStatusResponse {
|
||||||
initialized: boolean
|
initialized: boolean
|
||||||
allowRegistration: boolean
|
allowRegistration: boolean
|
||||||
legacyMode: boolean
|
legacyMode: boolean
|
||||||
needsMigration: boolean
|
needsMigration: boolean
|
||||||
|
loginVerification: LoginVerificationPublicConfig
|
||||||
user: AuthUser | null
|
user: AuthUser | null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,9 +75,19 @@ export function getFetchErrorMessage(error: unknown, fallback: string): string {
|
|||||||
|
|
||||||
export type AuthActionResult = { ok: true } | { ok: false, error: string }
|
export type AuthActionResult = { ok: true } | { ok: false, error: string }
|
||||||
|
|
||||||
export interface LoginCaptchaPayload {
|
export type { LoginVerificationPayload }
|
||||||
captchaId: string
|
|
||||||
captchaPosition: number
|
function buildVerificationBody(verification: LoginVerificationPayload): Record<string, unknown> {
|
||||||
|
if (verification.method === 'slider') {
|
||||||
|
return {
|
||||||
|
captchaId: verification.captchaId,
|
||||||
|
captchaPosition: verification.captchaPosition
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (verification.method === 'turnstile') {
|
||||||
|
return { turnstileToken: verification.turnstileToken }
|
||||||
|
}
|
||||||
|
return { capToken: verification.capToken }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAuth() {
|
export function useAuth() {
|
||||||
@@ -123,7 +142,7 @@ export function useAuth() {
|
|||||||
async function login(
|
async function login(
|
||||||
credentials: { username: string, password: string }
|
credentials: { username: string, password: string }
|
||||||
| { secret: string },
|
| { secret: string },
|
||||||
captcha: LoginCaptchaPayload
|
verification: LoginVerificationPayload
|
||||||
): Promise<{ ok: boolean, needsMigration?: boolean, error?: string }> {
|
): Promise<{ ok: boolean, needsMigration?: boolean, error?: string }> {
|
||||||
try {
|
try {
|
||||||
const result = await $fetch<{
|
const result = await $fetch<{
|
||||||
@@ -135,8 +154,7 @@ export function useAuth() {
|
|||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
body: {
|
body: {
|
||||||
...credentials,
|
...credentials,
|
||||||
captchaId: captcha.captchaId,
|
...buildVerificationBody(verification)
|
||||||
captchaPosition: captcha.captchaPosition
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
if (result.needsMigration) {
|
if (result.needsMigration) {
|
||||||
@@ -159,7 +177,8 @@ export function useAuth() {
|
|||||||
|
|
||||||
async function register(
|
async function register(
|
||||||
username: string,
|
username: string,
|
||||||
password: string
|
password: string,
|
||||||
|
verification: LoginVerificationPayload
|
||||||
): Promise<AuthActionResult> {
|
): Promise<AuthActionResult> {
|
||||||
const validationError = validateAuthInput(username, password)
|
const validationError = validateAuthInput(username, password)
|
||||||
if (validationError) {
|
if (validationError) {
|
||||||
@@ -172,7 +191,11 @@ export function useAuth() {
|
|||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
body: { username: username.trim(), password }
|
body: {
|
||||||
|
username: username.trim(),
|
||||||
|
password,
|
||||||
|
...buildVerificationBody(verification)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
user.value = result.user
|
user.value = result.user
|
||||||
@@ -291,15 +314,19 @@ export function useAuth() {
|
|||||||
|
|
||||||
async function logout() {
|
async function logout() {
|
||||||
try {
|
try {
|
||||||
await $fetch('/api/auth/logout', { method: 'POST' })
|
await $fetch('/api/auth/logout', { method: 'POST', credentials: 'include' })
|
||||||
} catch {
|
} catch {
|
||||||
// 忽略登出失败
|
// 忽略登出失败
|
||||||
} finally {
|
} finally {
|
||||||
status.value = 'unauthenticated'
|
status.value = 'unauthenticated'
|
||||||
user.value = null
|
user.value = null
|
||||||
authStatus.value = null
|
|
||||||
hasValidatedOnce.value = true
|
hasValidatedOnce.value = true
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
await fetchStatus()
|
||||||
|
} catch {
|
||||||
|
// 保留既有公开配置(loginVerification 等)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleAuthError(error: unknown) {
|
function handleAuthError(error: unknown) {
|
||||||
|
|||||||
+3
-1
@@ -35,7 +35,9 @@ const baseUrl = computed(() => {
|
|||||||
|
|
||||||
const tokenDisplay = computed(() => settings.value?.apiUploadToken ?? '')
|
const tokenDisplay = computed(() => settings.value?.apiUploadToken ?? '')
|
||||||
const hasToken = computed(() => tokenDisplay.value.length > 0)
|
const hasToken = computed(() => tokenDisplay.value.length > 0)
|
||||||
const canRegenerate = computed(() => !settings.value?.envTokenOverride && !loading.value)
|
const canRegenerate = computed(() =>
|
||||||
|
!settings.value?.envTokenOverride && !loading.value
|
||||||
|
)
|
||||||
|
|
||||||
const authHeader = computed(() =>
|
const authHeader = computed(() =>
|
||||||
hasToken.value ? tokenDisplay.value : 'YOUR-TOKEN'
|
hasToken.value ? tokenDisplay.value : 'YOUR-TOKEN'
|
||||||
|
|||||||
@@ -401,6 +401,7 @@ watch(isAuthenticated, async (authed, prev) => {
|
|||||||
:view-mode="viewMode"
|
:view-mode="viewMode"
|
||||||
:selected-count="selectedCount"
|
:selected-count="selectedCount"
|
||||||
:loading="galleryLoading"
|
:loading="galleryLoading"
|
||||||
|
:show-batch-delete="true"
|
||||||
@update:storage-backend="handleStorageBackendChange"
|
@update:storage-backend="handleStorageBackendChange"
|
||||||
@update:upload-source="handleUploadSourceChange"
|
@update:upload-source="handleUploadSourceChange"
|
||||||
@update:view-mode="viewMode = $event"
|
@update:view-mode="viewMode = $event"
|
||||||
@@ -430,7 +431,8 @@ watch(isAuthenticated, async (authed, prev) => {
|
|||||||
show-storage
|
show-storage
|
||||||
:items="items"
|
:items="items"
|
||||||
:selected-keys="selectedKeys"
|
:selected-keys="selectedKeys"
|
||||||
selectable
|
:selectable="true"
|
||||||
|
:allow-delete="true"
|
||||||
@update:selected-keys="updateSelectedKeys"
|
@update:selected-keys="updateSelectedKeys"
|
||||||
@preview="openPreview"
|
@preview="openPreview"
|
||||||
@delete="(image) => requestDelete(image.key)"
|
@delete="(image) => requestDelete(image.key)"
|
||||||
@@ -442,6 +444,7 @@ watch(isAuthenticated, async (authed, prev) => {
|
|||||||
:items="items"
|
:items="items"
|
||||||
:selected-keys="selectedKeys"
|
:selected-keys="selectedKeys"
|
||||||
:selectable="true"
|
:selectable="true"
|
||||||
|
:show-delete="true"
|
||||||
@update:selected-keys="updateSelectedKeys"
|
@update:selected-keys="updateSelectedKeys"
|
||||||
@preview="openPreview"
|
@preview="openPreview"
|
||||||
@delete="(image) => requestDelete(image.key)"
|
@delete="(image) => requestDelete(image.key)"
|
||||||
@@ -452,6 +455,7 @@ watch(isAuthenticated, async (authed, prev) => {
|
|||||||
:image="previewImage"
|
:image="previewImage"
|
||||||
show-storage
|
show-storage
|
||||||
:deleting="previewImage ? deletingKeys.has(previewImage.key) : false"
|
:deleting="previewImage ? deletingKeys.has(previewImage.key) : false"
|
||||||
|
:allow-delete="true"
|
||||||
@delete="requestPreviewDelete"
|
@delete="requestPreviewDelete"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -206,6 +206,7 @@ function requestPreviewDelete() {
|
|||||||
v-model:open="previewOpen"
|
v-model:open="previewOpen"
|
||||||
:image="previewImage"
|
:image="previewImage"
|
||||||
:deleting="previewImage ? deletingKeys.has(previewImage.key) : false"
|
:deleting="previewImage ? deletingKeys.has(previewImage.key) : false"
|
||||||
|
:allow-delete="true"
|
||||||
@delete="requestPreviewDelete"
|
@delete="requestPreviewDelete"
|
||||||
/>
|
/>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
|
|||||||
+39
-4
@@ -2,9 +2,10 @@
|
|||||||
import logoLight from '~/assets/image/logo-light.png'
|
import logoLight from '~/assets/image/logo-light.png'
|
||||||
import logoDark from '~/assets/image/logo-dark.png'
|
import logoDark from '~/assets/image/logo-dark.png'
|
||||||
import { isPasswordValid } from '~/utils/password-strength'
|
import { isPasswordValid } from '~/utils/password-strength'
|
||||||
|
import type { LoginVerificationPayload } from '~/types/auth'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { register, fetchStatus } = useAuth()
|
const { register, fetchStatus, authStatus } = useAuth()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
@@ -12,14 +13,34 @@ const username = ref('')
|
|||||||
const password = ref('')
|
const password = ref('')
|
||||||
const confirmPassword = ref('')
|
const confirmPassword = ref('')
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
const verificationRef = ref<{ reset: () => Promise<void> } | null>(null)
|
||||||
|
const verificationPayload = ref<LoginVerificationPayload | null>(null)
|
||||||
|
|
||||||
|
const loginVerification = computed(() =>
|
||||||
|
authStatus.value?.loginVerification ?? { method: 'slider' as const }
|
||||||
|
)
|
||||||
|
|
||||||
const canSubmit = computed(() =>
|
const canSubmit = computed(() =>
|
||||||
Boolean(username.value && password.value && confirmPassword.value)
|
Boolean(verificationPayload.value)
|
||||||
|
&& Boolean(username.value && password.value && confirmPassword.value)
|
||||||
&& password.value === confirmPassword.value
|
&& password.value === confirmPassword.value
|
||||||
&& isPasswordValid(password.value)
|
&& isPasswordValid(password.value)
|
||||||
&& AUTH_USERNAME_PATTERN.test(username.value.trim())
|
&& AUTH_USERNAME_PATTERN.test(username.value.trim())
|
||||||
)
|
)
|
||||||
|
|
||||||
|
function onVerificationVerified(payload: LoginVerificationPayload) {
|
||||||
|
verificationPayload.value = payload
|
||||||
|
}
|
||||||
|
|
||||||
|
function onVerificationCleared() {
|
||||||
|
verificationPayload.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetVerification() {
|
||||||
|
verificationPayload.value = null
|
||||||
|
await verificationRef.value?.reset()
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const status = await fetchStatus()
|
const status = await fetchStatus()
|
||||||
if (!status.initialized && !status.legacyMode) {
|
if (!status.initialized && !status.legacyMode) {
|
||||||
@@ -32,7 +53,7 @@ onMounted(async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
if (!canSubmit.value || loading.value) return
|
if (!canSubmit.value || loading.value || !verificationPayload.value) return
|
||||||
|
|
||||||
if (password.value !== confirmPassword.value) {
|
if (password.value !== confirmPassword.value) {
|
||||||
toast.add({ title: t('auth.passwordMismatch'), color: 'error' })
|
toast.add({ title: t('auth.passwordMismatch'), color: 'error' })
|
||||||
@@ -41,11 +62,16 @@ async function submit() {
|
|||||||
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const result = await register(username.value.trim(), password.value)
|
const result = await register(
|
||||||
|
username.value.trim(),
|
||||||
|
password.value,
|
||||||
|
verificationPayload.value
|
||||||
|
)
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
toast.add({ title: t('auth.registerSuccess'), color: 'success' })
|
toast.add({ title: t('auth.registerSuccess'), color: 'success' })
|
||||||
await router.replace('/')
|
await router.replace('/')
|
||||||
} else {
|
} else {
|
||||||
|
await resetVerification()
|
||||||
toast.add({ title: result.error, color: 'error' })
|
toast.add({ title: result.error, color: 'error' })
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -142,6 +168,15 @@ async function submit() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<LoginVerification
|
||||||
|
ref="verificationRef"
|
||||||
|
:method="loginVerification.method"
|
||||||
|
:turnstile-site-key="loginVerification.turnstileSiteKey"
|
||||||
|
:cap-api-endpoint="loginVerification.capApiEndpoint"
|
||||||
|
@verified="onVerificationVerified"
|
||||||
|
@cleared="onVerificationCleared"
|
||||||
|
/>
|
||||||
|
|
||||||
<UButton
|
<UButton
|
||||||
type="submit"
|
type="submit"
|
||||||
:label="t('auth.register')"
|
:label="t('auth.register')"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { ADMIN_SETTINGS_TABS, USER_SETTINGS_TABS } from '~/types/settings'
|
|||||||
|
|
||||||
type SettingSource = 'env' | 'db' | 'none'
|
type SettingSource = 'env' | 'db' | 'none'
|
||||||
type WebpQualitySource = 'env' | 'db' | 'default'
|
type WebpQualitySource = 'env' | 'db' | 'default'
|
||||||
|
type LoginVerificationMethod = 'slider' | 'turnstile' | 'cap'
|
||||||
|
|
||||||
interface SettingsResponse {
|
interface SettingsResponse {
|
||||||
apiUploadToken: string
|
apiUploadToken: string
|
||||||
@@ -33,6 +34,11 @@ interface SettingsResponse {
|
|||||||
storageUseDatePath: boolean
|
storageUseDatePath: boolean
|
||||||
storageUseDatePathSource: SettingSource
|
storageUseDatePathSource: SettingSource
|
||||||
allowRegistration: boolean
|
allowRegistration: boolean
|
||||||
|
loginVerificationMethod: LoginVerificationMethod
|
||||||
|
turnstileSiteKey: string
|
||||||
|
turnstileSecretKey: string
|
||||||
|
capApiEndpoint: string
|
||||||
|
capSecret: string
|
||||||
appVersion: string
|
appVersion: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,6 +104,11 @@ const imageBaseUrlDraft = ref('')
|
|||||||
const hideFolderInUrlDraft = ref(false)
|
const hideFolderInUrlDraft = ref(false)
|
||||||
const storageUseDatePathDraft = ref(true)
|
const storageUseDatePathDraft = ref(true)
|
||||||
const allowRegistrationDraft = ref(false)
|
const allowRegistrationDraft = ref(false)
|
||||||
|
const loginVerificationMethodDraft = ref<LoginVerificationMethod>('slider')
|
||||||
|
const turnstileSiteKeyDraft = ref('')
|
||||||
|
const turnstileSecretKeyDraft = ref('')
|
||||||
|
const capApiEndpointDraft = ref('')
|
||||||
|
const capSecretDraft = ref('')
|
||||||
const domainSeparationDraft = ref(false)
|
const domainSeparationDraft = ref(false)
|
||||||
const disableDomainSeparationOpen = ref(false)
|
const disableDomainSeparationOpen = ref(false)
|
||||||
const pendingDomainSeparationDisable = ref(false)
|
const pendingDomainSeparationDisable = ref(false)
|
||||||
@@ -120,6 +131,11 @@ const hasServerChanges = computed(() => {
|
|||||||
|| hideFolderInUrlDraft.value !== settings.value.hideFolderInUrl
|
|| hideFolderInUrlDraft.value !== settings.value.hideFolderInUrl
|
||||||
|| storageUseDatePathDraft.value !== settings.value.storageUseDatePath
|
|| storageUseDatePathDraft.value !== settings.value.storageUseDatePath
|
||||||
|| allowRegistrationDraft.value !== settings.value.allowRegistration
|
|| allowRegistrationDraft.value !== settings.value.allowRegistration
|
||||||
|
|| loginVerificationMethodDraft.value !== settings.value.loginVerificationMethod
|
||||||
|
|| turnstileSiteKeyDraft.value !== settings.value.turnstileSiteKey
|
||||||
|
|| turnstileSecretKeyDraft.value !== settings.value.turnstileSecretKey
|
||||||
|
|| capApiEndpointDraft.value !== settings.value.capApiEndpoint
|
||||||
|
|| capSecretDraft.value !== settings.value.capSecret
|
||||||
})
|
})
|
||||||
|
|
||||||
function sourceBadge(source: SettingSource | WebpQualitySource) {
|
function sourceBadge(source: SettingSource | WebpQualitySource) {
|
||||||
@@ -150,6 +166,11 @@ function applySettings(data: SettingsResponse) {
|
|||||||
hideFolderInUrlDraft.value = data.hideFolderInUrl
|
hideFolderInUrlDraft.value = data.hideFolderInUrl
|
||||||
storageUseDatePathDraft.value = data.storageUseDatePath
|
storageUseDatePathDraft.value = data.storageUseDatePath
|
||||||
allowRegistrationDraft.value = data.allowRegistration
|
allowRegistrationDraft.value = data.allowRegistration
|
||||||
|
loginVerificationMethodDraft.value = data.loginVerificationMethod
|
||||||
|
turnstileSiteKeyDraft.value = data.turnstileSiteKey
|
||||||
|
turnstileSecretKeyDraft.value = data.turnstileSecretKey
|
||||||
|
capApiEndpointDraft.value = data.capApiEndpoint
|
||||||
|
capSecretDraft.value = data.capSecret
|
||||||
domainSeparationDraft.value = data.domainSeparation
|
domainSeparationDraft.value = data.domainSeparation
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,7 +242,12 @@ async function saveServerSettings() {
|
|||||||
imageBaseUrl: imageBaseUrlDraft.value,
|
imageBaseUrl: imageBaseUrlDraft.value,
|
||||||
hideFolderInUrl: hideFolderInUrlDraft.value,
|
hideFolderInUrl: hideFolderInUrlDraft.value,
|
||||||
storageUseDatePath: storageUseDatePathDraft.value,
|
storageUseDatePath: storageUseDatePathDraft.value,
|
||||||
allowRegistration: allowRegistrationDraft.value
|
allowRegistration: allowRegistrationDraft.value,
|
||||||
|
loginVerificationMethod: loginVerificationMethodDraft.value,
|
||||||
|
turnstileSiteKey: turnstileSiteKeyDraft.value,
|
||||||
|
turnstileSecretKey: turnstileSecretKeyDraft.value,
|
||||||
|
capApiEndpoint: capApiEndpointDraft.value,
|
||||||
|
capSecret: capSecretDraft.value
|
||||||
})
|
})
|
||||||
toast.add({ title: t('settings.saved'), color: 'success' })
|
toast.add({ title: t('settings.saved'), color: 'success' })
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
@@ -279,9 +305,27 @@ async function checkLatestRelease(options: { notify?: boolean, refresh?: boolean
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const loginVerificationOptions = computed(() => [
|
||||||
|
{ value: 'slider' as const, label: t('settings.loginVerificationSlider') },
|
||||||
|
{ value: 'turnstile' as const, label: t('settings.loginVerificationTurnstile') },
|
||||||
|
{ value: 'cap' as const, label: t('settings.loginVerificationCap') }
|
||||||
|
])
|
||||||
|
|
||||||
|
const loginVerificationHint = computed(() => {
|
||||||
|
switch (loginVerificationMethodDraft.value) {
|
||||||
|
case 'turnstile':
|
||||||
|
return t('settings.loginVerificationTurnstileHint')
|
||||||
|
case 'cap':
|
||||||
|
return t('settings.loginVerificationCapHint')
|
||||||
|
default:
|
||||||
|
return t('settings.loginVerificationSliderHint')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
async function loadPage() {
|
async function loadPage() {
|
||||||
if (isAdmin.value) {
|
if (isAdmin.value) {
|
||||||
await Promise.all([loadSettings(), checkLatestRelease()])
|
await loadSettings()
|
||||||
|
await checkLatestRelease()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -382,7 +426,10 @@ watch(isAdmin, () => {
|
|||||||
:title="t('settings.systemInfo')"
|
:title="t('settings.systemInfo')"
|
||||||
:hint="t('settings.systemInfoHint')"
|
:hint="t('settings.systemInfoHint')"
|
||||||
>
|
>
|
||||||
<template #action>
|
<template
|
||||||
|
v-if="isAdmin"
|
||||||
|
#action
|
||||||
|
>
|
||||||
<UButton
|
<UButton
|
||||||
:label="t('settings.checkUpdate')"
|
:label="t('settings.checkUpdate')"
|
||||||
icon="i-lucide-refresh-cw"
|
icon="i-lucide-refresh-cw"
|
||||||
@@ -712,6 +759,75 @@ watch(isAdmin, () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<SettingsSection
|
||||||
|
:title="t('settings.loginVerification')"
|
||||||
|
:hint="t('settings.loginVerificationHint')"
|
||||||
|
>
|
||||||
|
<SettingsGroup>
|
||||||
|
<div class="space-y-4 px-4 py-4 sm:px-5">
|
||||||
|
<URadioGroup
|
||||||
|
v-model="loginVerificationMethodDraft"
|
||||||
|
:items="loginVerificationOptions"
|
||||||
|
/>
|
||||||
|
<p class="text-xs leading-relaxed text-muted">
|
||||||
|
{{ loginVerificationHint }}
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
v-if="loginVerificationMethodDraft === 'turnstile'"
|
||||||
|
class="space-y-3 rounded-xl border border-default bg-muted/20 p-4"
|
||||||
|
>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="text-sm font-medium text-highlighted">
|
||||||
|
{{ t('settings.turnstileSiteKey') }}
|
||||||
|
</label>
|
||||||
|
<UInput
|
||||||
|
v-model="turnstileSiteKeyDraft"
|
||||||
|
:placeholder="t('settings.turnstileSiteKeyPlaceholder')"
|
||||||
|
class="w-full font-mono text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="text-sm font-medium text-highlighted">
|
||||||
|
{{ t('settings.turnstileSecretKey') }}
|
||||||
|
</label>
|
||||||
|
<UInput
|
||||||
|
v-model="turnstileSecretKeyDraft"
|
||||||
|
type="password"
|
||||||
|
:placeholder="t('settings.turnstileSecretKeyPlaceholder')"
|
||||||
|
class="w-full font-mono text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="loginVerificationMethodDraft === 'cap'"
|
||||||
|
class="space-y-3 rounded-xl border border-default bg-muted/20 p-4"
|
||||||
|
>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="text-sm font-medium text-highlighted">
|
||||||
|
{{ t('settings.capApiEndpoint') }}
|
||||||
|
</label>
|
||||||
|
<UInput
|
||||||
|
v-model="capApiEndpointDraft"
|
||||||
|
:placeholder="t('settings.capApiEndpointPlaceholder')"
|
||||||
|
class="w-full font-mono text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="text-sm font-medium text-highlighted">
|
||||||
|
{{ t('settings.capSecretKey') }}
|
||||||
|
</label>
|
||||||
|
<UInput
|
||||||
|
v-model="capSecretDraft"
|
||||||
|
type="password"
|
||||||
|
:placeholder="t('settings.capSecretKeyPlaceholder')"
|
||||||
|
class="w-full font-mono text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SettingsGroup>
|
||||||
|
</SettingsSection>
|
||||||
</SettingsPanel>
|
</SettingsPanel>
|
||||||
<div
|
<div
|
||||||
v-else
|
v-else
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
export type LoginVerificationMethod = 'slider' | 'turnstile' | 'cap'
|
||||||
|
|
||||||
|
export interface LoginVerificationPublicConfig {
|
||||||
|
method: LoginVerificationMethod
|
||||||
|
turnstileSiteKey?: string
|
||||||
|
capApiEndpoint?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LoginVerificationPayload = {
|
||||||
|
method: 'slider'
|
||||||
|
captchaId: string
|
||||||
|
captchaPosition: number
|
||||||
|
} | {
|
||||||
|
method: 'turnstile'
|
||||||
|
turnstileToken: string
|
||||||
|
} | {
|
||||||
|
method: 'cap'
|
||||||
|
capToken: string
|
||||||
|
}
|
||||||
+1
-1
@@ -10,7 +10,7 @@ services:
|
|||||||
- "6892:6892"
|
- "6892:6892"
|
||||||
# 首次访问 Web 引导创建管理员,无需预先配置环境变量
|
# 首次访问 Web 引导创建管理员,无需预先配置环境变量
|
||||||
# 可选:environment 中设置 API_UPLOAD_TOKEN、ALLOWED_REFERER_HOSTS 等
|
# 可选:environment 中设置 API_UPLOAD_TOKEN、ALLOWED_REFERER_HOSTS 等
|
||||||
# 运维:docker exec pichost reset-password | migrate [--apply]
|
# 运维:docker exec pichost reset-password | migrate [--apply] | slider
|
||||||
volumes:
|
volumes:
|
||||||
# 冒号左边改成 NAS 上的实际存图目录(飞牛示例:/vol1/1000/pichost-data)
|
# 冒号左边改成 NAS 上的实际存图目录(飞牛示例:/vol1/1000/pichost-data)
|
||||||
- ./data:/data
|
- ./data:/data
|
||||||
@@ -9,4 +9,8 @@ if [ "$1" = "migrate" ]; then
|
|||||||
exec node /app/server/cli/migrate-to-single-images.mjs "${@:2}"
|
exec node /app/server/cli/migrate-to-single-images.mjs "${@:2}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ "$1" = "slider" ]; then
|
||||||
|
exec node /app/server/cli/slider.mjs "${@:2}"
|
||||||
|
fi
|
||||||
|
|
||||||
exec node /app/.output/server/index.mjs "$@"
|
exec node /app/.output/server/index.mjs "$@"
|
||||||
@@ -4,6 +4,17 @@ Version history and notable changes. The full log is also in the repo root [CHAN
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [1.2.7] — 2026-09-03
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Login verification**: slider, Cloudflare Turnstile, or Cap on login/register; configure under Settings → Access
|
||||||
|
- **Ops CLI**: `slider` resets verification to the local slider (`docker exec pichost slider`)
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
|
||||||
|
- Login captcha reloads correctly after logout
|
||||||
|
|
||||||
## [1.2.6] — 2026-09-01
|
## [1.2.6] — 2026-09-01
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -4,6 +4,17 @@ PicHost 版本历史与重要变更说明。完整记录亦见仓库根目录 [C
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [1.2.7] — 2026-09-03
|
||||||
|
|
||||||
|
### 新增
|
||||||
|
|
||||||
|
- **登录验证**:登录/注册支持本地滑块、Cloudflare Turnstile、Cap;在「设置 → 访问控制」配置
|
||||||
|
- **运维 CLI**:`slider` 将验证方式重置为本地滑块(`docker exec pichost slider`)
|
||||||
|
|
||||||
|
### 改进
|
||||||
|
|
||||||
|
- 登出后登录页验证码可正常重新加载
|
||||||
|
|
||||||
## [1.2.6] — 2026-09-01
|
## [1.2.6] — 2026-09-01
|
||||||
|
|
||||||
### 新增
|
### 新增
|
||||||
|
|||||||
@@ -67,6 +67,11 @@
|
|||||||
"loginFailed": "Sign-in failed. Check username and password",
|
"loginFailed": "Sign-in failed. Check username and password",
|
||||||
"captchaHint": "Drag the slider to the slot",
|
"captchaHint": "Drag the slider to the slot",
|
||||||
"captchaSuccess": "Verified",
|
"captchaSuccess": "Verified",
|
||||||
|
"verificationMisconfigured": "Human verification is not configured correctly. Contact an administrator.",
|
||||||
|
"capInitialState": "Verify you are human",
|
||||||
|
"capVerifying": "Verifying…",
|
||||||
|
"capSolved": "Verified",
|
||||||
|
"capError": "Verification failed",
|
||||||
"registerFailed": "Registration failed. Check your input",
|
"registerFailed": "Registration failed. Check your input",
|
||||||
"setupFailed": "Setup failed. Check your input",
|
"setupFailed": "Setup failed. Check your input",
|
||||||
"migrateFailed": "Migration failed. Check your input",
|
"migrateFailed": "Migration failed. Check your input",
|
||||||
@@ -212,6 +217,22 @@
|
|||||||
"refererScenarioDesc": "Allow your main site and static site to embed images",
|
"refererScenarioDesc": "Allow your main site and static site to embed images",
|
||||||
"refererEnabled": "Enabled",
|
"refererEnabled": "Enabled",
|
||||||
"refererUnrestricted": "Unrestricted",
|
"refererUnrestricted": "Unrestricted",
|
||||||
|
"loginVerification": "Login verification",
|
||||||
|
"loginVerificationHint": "Human verification for login and registration (choose one)",
|
||||||
|
"loginVerificationSlider": "Local slider",
|
||||||
|
"loginVerificationSliderHint": "Built-in slider puzzle; no third-party service",
|
||||||
|
"loginVerificationTurnstile": "Cloudflare Turnstile",
|
||||||
|
"loginVerificationTurnstileHint": "Create a widget in Cloudflare Dashboard, then enter Site Key and Secret Key",
|
||||||
|
"loginVerificationCap": "Cap POW",
|
||||||
|
"loginVerificationCapHint": "Self-hosted Cap Standalone; enter API Endpoint and Secret Key",
|
||||||
|
"turnstileSiteKey": "Turnstile Site Key",
|
||||||
|
"turnstileSiteKeyPlaceholder": "0x4AAAAAAA…",
|
||||||
|
"turnstileSecretKey": "Turnstile Secret Key",
|
||||||
|
"turnstileSecretKeyPlaceholder": "0x4AAAAAAA…",
|
||||||
|
"capApiEndpoint": "Cap API Endpoint",
|
||||||
|
"capApiEndpointPlaceholder": "https://cap.example.com/your-site-key/",
|
||||||
|
"capSecretKey": "Cap Secret Key",
|
||||||
|
"capSecretKeyPlaceholder": "Secret key from your Cap dashboard",
|
||||||
"systemInfo": "System",
|
"systemInfo": "System",
|
||||||
"systemInfoHint": "Current version and updates",
|
"systemInfoHint": "Current version and updates",
|
||||||
"uploadConvenience": "Upload convenience",
|
"uploadConvenience": "Upload convenience",
|
||||||
|
|||||||
@@ -67,6 +67,11 @@
|
|||||||
"loginFailed": "登录失败,请检查用户名和密码",
|
"loginFailed": "登录失败,请检查用户名和密码",
|
||||||
"captchaHint": "拖动滑块对齐缺口",
|
"captchaHint": "拖动滑块对齐缺口",
|
||||||
"captchaSuccess": "验证通过",
|
"captchaSuccess": "验证通过",
|
||||||
|
"verificationMisconfigured": "人机验证未正确配置,请联系管理员",
|
||||||
|
"capInitialState": "验证你是人类",
|
||||||
|
"capVerifying": "验证中…",
|
||||||
|
"capSolved": "验证通过",
|
||||||
|
"capError": "验证失败",
|
||||||
"registerFailed": "注册失败,请检查输入",
|
"registerFailed": "注册失败,请检查输入",
|
||||||
"setupFailed": "初始化失败,请检查输入",
|
"setupFailed": "初始化失败,请检查输入",
|
||||||
"migrateFailed": "迁移失败,请检查输入",
|
"migrateFailed": "迁移失败,请检查输入",
|
||||||
@@ -212,6 +217,22 @@
|
|||||||
"refererScenarioDesc": "允许主站与静态站点引用图片",
|
"refererScenarioDesc": "允许主站与静态站点引用图片",
|
||||||
"refererEnabled": "已开启",
|
"refererEnabled": "已开启",
|
||||||
"refererUnrestricted": "未限制",
|
"refererUnrestricted": "未限制",
|
||||||
|
"loginVerification": "登录验证",
|
||||||
|
"loginVerificationHint": "登录与注册时使用的人机验证方式,三选一",
|
||||||
|
"loginVerificationSlider": "本地滑块",
|
||||||
|
"loginVerificationSliderHint": "内置滑块拼图,无需第三方服务",
|
||||||
|
"loginVerificationTurnstile": "Cloudflare Turnstile",
|
||||||
|
"loginVerificationTurnstileHint": "在 Cloudflare 控制台创建 Widget,填写 Site Key 与 Secret Key",
|
||||||
|
"loginVerificationCap": "Cap POW",
|
||||||
|
"loginVerificationCapHint": "自托管 Cap Standalone,填写 API Endpoint 与 Secret Key",
|
||||||
|
"turnstileSiteKey": "Turnstile Site Key",
|
||||||
|
"turnstileSiteKeyPlaceholder": "0x4AAAAAAA…",
|
||||||
|
"turnstileSecretKey": "Turnstile Secret Key",
|
||||||
|
"turnstileSecretKeyPlaceholder": "0x4AAAAAAA…",
|
||||||
|
"capApiEndpoint": "Cap API Endpoint",
|
||||||
|
"capApiEndpointPlaceholder": "https://cap.example.com/your-site-key/",
|
||||||
|
"capSecretKey": "Cap Secret Key",
|
||||||
|
"capSecretKeyPlaceholder": "在 Cap Dashboard 创建的 Secret Key",
|
||||||
"systemInfo": "系统信息",
|
"systemInfo": "系统信息",
|
||||||
"systemInfoHint": "当前系统版本与更新",
|
"systemInfoHint": "当前系统版本与更新",
|
||||||
"uploadConvenience": "上传便捷功能",
|
"uploadConvenience": "上传便捷功能",
|
||||||
|
|||||||
@@ -21,6 +21,12 @@ export default defineNuxtConfig({
|
|||||||
|
|
||||||
css: ['~/assets/css/main.css'],
|
css: ['~/assets/css/main.css'],
|
||||||
|
|
||||||
|
vue: {
|
||||||
|
compilerOptions: {
|
||||||
|
isCustomElement: tag => tag.startsWith('cap-')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
colorMode: {
|
colorMode: {
|
||||||
preference: 'system',
|
preference: 'system',
|
||||||
fallback: 'light'
|
fallback: 'light'
|
||||||
|
|||||||
Generated
+9
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "pichost",
|
"name": "pichost",
|
||||||
"version": "1.2.6",
|
"version": "1.2.7",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "pichost",
|
"name": "pichost",
|
||||||
"version": "1.2.6",
|
"version": "1.2.7",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "GPL-3.0-only",
|
"license": "GPL-3.0-only",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
"@iconify-json/lucide": "^1.2.114",
|
"@iconify-json/lucide": "^1.2.114",
|
||||||
"@nuxt/ui": "^4.9.0",
|
"@nuxt/ui": "^4.9.0",
|
||||||
"@nuxtjs/i18n": "^10.6.0",
|
"@nuxtjs/i18n": "^10.6.0",
|
||||||
|
"cap-widget": "^0.1.57",
|
||||||
"nuxt": "^4.4.8",
|
"nuxt": "^4.4.8",
|
||||||
"sharp": "^0.35.3",
|
"sharp": "^0.35.3",
|
||||||
"tailwindcss": "^4.3.1"
|
"tailwindcss": "^4.3.1"
|
||||||
@@ -10704,6 +10705,12 @@
|
|||||||
],
|
],
|
||||||
"license": "CC-BY-4.0"
|
"license": "CC-BY-4.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/cap-widget": {
|
||||||
|
"version": "0.1.57",
|
||||||
|
"resolved": "https://registry.npmmirror.com/cap-widget/-/cap-widget-0.1.57.tgz",
|
||||||
|
"integrity": "sha512-SyIK5XgYIFSDgH683M9KpfBmDSDB0ZfPZQBxL6MggNrjjnbIQYpbsNpj0S7PC/wXp7oJgAuBD2vtJnN9jJghBg==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/ccount": {
|
"node_modules/ccount": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmmirror.com/ccount/-/ccount-2.0.1.tgz",
|
"resolved": "https://registry.npmmirror.com/ccount/-/ccount-2.0.1.tgz",
|
||||||
|
|||||||
+3
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pichost",
|
"name": "pichost",
|
||||||
"version": "1.2.6",
|
"version": "1.2.7",
|
||||||
"description": "Self-hosted image hosting for your NAS / home server — Nuxt admin UI, local disk or S3-compatible storage, server-side WebP compression, referer protection, Twikoo/EasyImage compatible.",
|
"description": "Self-hosted image hosting for your NAS / home server — Nuxt admin UI, local disk or S3-compatible storage, server-side WebP compression, referer protection, Twikoo/EasyImage compatible.",
|
||||||
"homepage": "https://github.com/O96u/PicHost#readme",
|
"homepage": "https://github.com/O96u/PicHost#readme",
|
||||||
"repository": {
|
"repository": {
|
||||||
@@ -35,6 +35,7 @@
|
|||||||
"start": "node .output/server/index.mjs",
|
"start": "node .output/server/index.mjs",
|
||||||
"reset-password": "node server/cli/reset-password.mjs",
|
"reset-password": "node server/cli/reset-password.mjs",
|
||||||
"migrate": "node server/cli/migrate-to-single-images.mjs",
|
"migrate": "node server/cli/migrate-to-single-images.mjs",
|
||||||
|
"slider": "node server/cli/slider.mjs",
|
||||||
"docs:prepare": "node docs-site/copy-screenshots.mjs",
|
"docs:prepare": "node docs-site/copy-screenshots.mjs",
|
||||||
"docs:dev": "npm run docs:prepare && vitepress dev docs-site",
|
"docs:dev": "npm run docs:prepare && vitepress dev docs-site",
|
||||||
"docs:build": "npm run docs:prepare && vitepress build docs-site",
|
"docs:build": "npm run docs:prepare && vitepress build docs-site",
|
||||||
@@ -45,6 +46,7 @@
|
|||||||
"@iconify-json/lucide": "^1.2.114",
|
"@iconify-json/lucide": "^1.2.114",
|
||||||
"@nuxt/ui": "^4.9.0",
|
"@nuxt/ui": "^4.9.0",
|
||||||
"@nuxtjs/i18n": "^10.6.0",
|
"@nuxtjs/i18n": "^10.6.0",
|
||||||
|
"cap-widget": "^0.1.57",
|
||||||
"nuxt": "^4.4.8",
|
"nuxt": "^4.4.8",
|
||||||
"sharp": "^0.35.3",
|
"sharp": "^0.35.3",
|
||||||
"tailwindcss": "^4.3.1"
|
"tailwindcss": "^4.3.1"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { isAllowRegistration } from '../../utils/db'
|
import { isAllowRegistration } from '../../utils/db'
|
||||||
import { isAdminSecretConfigured, isApiUploadTokenConfigured } from '../../utils/env'
|
import { getLoginVerificationPublicConfig, isAdminSecretConfigured, isApiUploadTokenConfigured } from '../../utils/env'
|
||||||
import { isInitialized } from '../../utils/auth'
|
import { isInitialized } from '../../utils/auth'
|
||||||
|
|
||||||
export default defineEventHandler((event) => {
|
export default defineEventHandler((event) => {
|
||||||
@@ -8,6 +8,7 @@ export default defineEventHandler((event) => {
|
|||||||
allowRegistration: isAllowRegistration(),
|
allowRegistration: isAllowRegistration(),
|
||||||
legacyMode: !isInitialized() && isAdminSecretConfigured(event),
|
legacyMode: !isInitialized() && isAdminSecretConfigured(event),
|
||||||
adminSecretConfigured: isAdminSecretConfigured(event),
|
adminSecretConfigured: isAdminSecretConfigured(event),
|
||||||
apiUploadTokenConfigured: isApiUploadTokenConfigured(event)
|
apiUploadTokenConfigured: isApiUploadTokenConfigured(event),
|
||||||
|
loginVerification: getLoginVerificationPublicConfig(event)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import type { H3Event } from 'h3'
|
|
||||||
import {
|
import {
|
||||||
checkAdminSecret,
|
checkAdminSecret,
|
||||||
setLegacyAuthCookie
|
setLegacyAuthCookie
|
||||||
@@ -7,19 +6,18 @@ import { createApiError } from '../../utils/api-error'
|
|||||||
import {
|
import {
|
||||||
createSession,
|
createSession,
|
||||||
isInitialized,
|
isInitialized,
|
||||||
|
rowToAuthUser,
|
||||||
verifyPassword
|
verifyPassword
|
||||||
} from '../../utils/auth'
|
} from '../../utils/auth'
|
||||||
import { findUserByUsername } from '../../utils/db'
|
import { findUserByUsername } from '../../utils/db'
|
||||||
import { getAdminSecret } from '../../utils/env'
|
import { getAdminSecret } from '../../utils/env'
|
||||||
import { verifyLoginCaptcha } from '../../utils/login-captcha'
|
import { verifyLoginVerification, type VerificationBody } from '../../utils/login-verification'
|
||||||
import { clientIp, logInfo, logWarn } from '../../utils/logger'
|
import { clientIp, logInfo, logWarn } from '../../utils/logger'
|
||||||
|
|
||||||
interface LoginBody {
|
interface LoginBody extends VerificationBody {
|
||||||
username?: string
|
username?: string
|
||||||
password?: string
|
password?: string
|
||||||
secret?: string
|
secret?: string
|
||||||
captchaId?: string
|
|
||||||
captchaPosition?: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const loginAttempts = new Map<string, { count: number, resetAt: number }>()
|
const loginAttempts = new Map<string, { count: number, resetAt: number }>()
|
||||||
@@ -42,20 +40,12 @@ 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) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const ip = clientIp(event)
|
const ip = clientIp(event)
|
||||||
checkRateLimit(ip)
|
checkRateLimit(ip)
|
||||||
|
|
||||||
const body = await readBody<LoginBody>(event)
|
const body = await readBody<LoginBody>(event)
|
||||||
verifyCaptchaOrFail(event, body ?? {})
|
await verifyLoginVerification(event, body ?? {})
|
||||||
const initialized = isInitialized()
|
const initialized = isInitialized()
|
||||||
|
|
||||||
if (!initialized) {
|
if (!initialized) {
|
||||||
@@ -94,6 +84,6 @@ export default defineEventHandler(async (event) => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
user: { id: user.id, username: user.username, role: user.role }
|
user: rowToAuthUser(user)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -9,6 +9,10 @@ export default defineEventHandler(async (event) => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
authenticated: true,
|
authenticated: true,
|
||||||
user: { id: user.id, username: user.username, role: user.role }
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
role: user.role
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -5,9 +5,10 @@ import {
|
|||||||
isInitialized
|
isInitialized
|
||||||
} from '../../utils/auth'
|
} from '../../utils/auth'
|
||||||
import { isAllowRegistration } from '../../utils/db'
|
import { isAllowRegistration } from '../../utils/db'
|
||||||
|
import { verifyLoginVerification, type VerificationBody } from '../../utils/login-verification'
|
||||||
import { clientIp, logInfo } from '../../utils/logger'
|
import { clientIp, logInfo } from '../../utils/logger'
|
||||||
|
|
||||||
interface RegisterBody {
|
interface RegisterBody extends VerificationBody {
|
||||||
username?: string
|
username?: string
|
||||||
password?: string
|
password?: string
|
||||||
}
|
}
|
||||||
@@ -22,6 +23,8 @@ export default defineEventHandler(async (event) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const body = await readBody<RegisterBody>(event)
|
const body = await readBody<RegisterBody>(event)
|
||||||
|
await verifyLoginVerification(event, body ?? {})
|
||||||
|
|
||||||
const username = body?.username?.trim() ?? ''
|
const username = body?.username?.trim() ?? ''
|
||||||
const password = body?.password ?? ''
|
const password = body?.password ?? ''
|
||||||
|
|
||||||
@@ -38,7 +41,11 @@ export default defineEventHandler(async (event) => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
user: { id: user.id, username: user.username, role: user.role }
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
role: user.role
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { isAllowRegistration } from '../../utils/db'
|
import { isAllowRegistration } from '../../utils/db'
|
||||||
import { isAdminSecretConfigured } from '../../utils/env'
|
import { getLoginVerificationPublicConfig, isAdminSecretConfigured } from '../../utils/env'
|
||||||
import {
|
import {
|
||||||
getCurrentUser,
|
getCurrentUser,
|
||||||
isInitialized
|
isInitialized
|
||||||
@@ -17,8 +17,13 @@ export default defineEventHandler(async (event) => {
|
|||||||
allowRegistration: isAllowRegistration(),
|
allowRegistration: isAllowRegistration(),
|
||||||
legacyMode,
|
legacyMode,
|
||||||
needsMigration,
|
needsMigration,
|
||||||
|
loginVerification: getLoginVerificationPublicConfig(event),
|
||||||
user: user
|
user: user
|
||||||
? { id: user.id, username: user.username, role: user.role }
|
? {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
role: user.role
|
||||||
|
}
|
||||||
: null
|
: null
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -9,6 +9,11 @@ import {
|
|||||||
SETTINGS_IMAGE_BASE_URL,
|
SETTINGS_IMAGE_BASE_URL,
|
||||||
SETTINGS_SITE_BASE_URL,
|
SETTINGS_SITE_BASE_URL,
|
||||||
SETTINGS_WEBP_QUALITY,
|
SETTINGS_WEBP_QUALITY,
|
||||||
|
SETTINGS_LOGIN_VERIFICATION_METHOD,
|
||||||
|
SETTINGS_TURNSTILE_SITE_KEY,
|
||||||
|
SETTINGS_TURNSTILE_SECRET_KEY,
|
||||||
|
SETTINGS_CAP_API_ENDPOINT,
|
||||||
|
SETTINGS_CAP_SECRET,
|
||||||
setAllowRegistration
|
setAllowRegistration
|
||||||
} from '../../utils/db'
|
} from '../../utils/db'
|
||||||
import {
|
import {
|
||||||
@@ -16,6 +21,8 @@ import {
|
|||||||
isValidImageBaseUrl,
|
isValidImageBaseUrl,
|
||||||
isValidRefererHost,
|
isValidRefererHost,
|
||||||
isValidSiteBaseUrl,
|
isValidSiteBaseUrl,
|
||||||
|
normalizeCapApiEndpoint,
|
||||||
|
parseLoginVerificationMethod,
|
||||||
MAX_AUTO_DELETE_DAYS,
|
MAX_AUTO_DELETE_DAYS,
|
||||||
normalizeImageBaseUrl,
|
normalizeImageBaseUrl,
|
||||||
normalizeRefererHosts,
|
normalizeRefererHosts,
|
||||||
@@ -23,6 +30,7 @@ import {
|
|||||||
parseAutoDeleteDays,
|
parseAutoDeleteDays,
|
||||||
parseWebpQuality,
|
parseWebpQuality,
|
||||||
setGlobalAutoDeletePolicy,
|
setGlobalAutoDeletePolicy,
|
||||||
|
validateLoginVerificationSettings,
|
||||||
validateSettingsDomainPatch
|
validateSettingsDomainPatch
|
||||||
} from '../../utils/env'
|
} from '../../utils/env'
|
||||||
|
|
||||||
@@ -36,6 +44,11 @@ interface SettingsPatchBody {
|
|||||||
storageUseDatePath?: unknown
|
storageUseDatePath?: unknown
|
||||||
autoDeleteDays?: unknown
|
autoDeleteDays?: unknown
|
||||||
allowRegistration?: unknown
|
allowRegistration?: unknown
|
||||||
|
loginVerificationMethod?: unknown
|
||||||
|
turnstileSiteKey?: unknown
|
||||||
|
turnstileSecretKey?: unknown
|
||||||
|
capApiEndpoint?: unknown
|
||||||
|
capSecret?: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
@@ -161,5 +174,79 @@ export default defineEventHandler(async (event) => {
|
|||||||
setSetting(SETTINGS_STORAGE_USE_DATE_PATH, body.storageUseDatePath ? 'true' : 'false')
|
setSetting(SETTINGS_STORAGE_USE_DATE_PATH, body.storageUseDatePath ? 'true' : 'false')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const currentMethod = parseLoginVerificationMethod(
|
||||||
|
getSetting(SETTINGS_LOGIN_VERIFICATION_METHOD) ?? 'slider'
|
||||||
|
) ?? 'slider'
|
||||||
|
let nextMethod = currentMethod
|
||||||
|
let nextTurnstileSiteKey = getSetting(SETTINGS_TURNSTILE_SITE_KEY) ?? ''
|
||||||
|
let nextTurnstileSecretKey = getSetting(SETTINGS_TURNSTILE_SECRET_KEY) ?? ''
|
||||||
|
let nextCapApiEndpoint = getSetting(SETTINGS_CAP_API_ENDPOINT) ?? ''
|
||||||
|
let nextCapSecret = getSetting(SETTINGS_CAP_SECRET) ?? ''
|
||||||
|
|
||||||
|
if (body.loginVerificationMethod !== undefined) {
|
||||||
|
const parsed = parseLoginVerificationMethod(String(body.loginVerificationMethod))
|
||||||
|
if (!parsed) {
|
||||||
|
createApiError(event, 'INVALID_REQUEST', '无效的登录验证方式', 400)
|
||||||
|
}
|
||||||
|
nextMethod = parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.turnstileSiteKey !== undefined) {
|
||||||
|
nextTurnstileSiteKey = typeof body.turnstileSiteKey === 'string'
|
||||||
|
? body.turnstileSiteKey.trim()
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.turnstileSecretKey !== undefined) {
|
||||||
|
nextTurnstileSecretKey = typeof body.turnstileSecretKey === 'string'
|
||||||
|
? body.turnstileSecretKey.trim()
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.capApiEndpoint !== undefined) {
|
||||||
|
nextCapApiEndpoint = typeof body.capApiEndpoint === 'string'
|
||||||
|
? normalizeCapApiEndpoint(body.capApiEndpoint)
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.capSecret !== undefined) {
|
||||||
|
nextCapSecret = typeof body.capSecret === 'string'
|
||||||
|
? body.capSecret.trim()
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.loginVerificationMethod !== undefined
|
||||||
|
|| body.turnstileSiteKey !== undefined
|
||||||
|
|| body.turnstileSecretKey !== undefined
|
||||||
|
|| body.capApiEndpoint !== undefined
|
||||||
|
|| body.capSecret !== undefined) {
|
||||||
|
const settingsError = validateLoginVerificationSettings(
|
||||||
|
nextMethod,
|
||||||
|
nextTurnstileSiteKey,
|
||||||
|
nextTurnstileSecretKey,
|
||||||
|
nextCapApiEndpoint,
|
||||||
|
nextCapSecret
|
||||||
|
)
|
||||||
|
if (settingsError) {
|
||||||
|
createApiError(event, 'INVALID_REQUEST', settingsError, 400)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.loginVerificationMethod !== undefined) {
|
||||||
|
setSetting(SETTINGS_LOGIN_VERIFICATION_METHOD, nextMethod)
|
||||||
|
}
|
||||||
|
if (body.turnstileSiteKey !== undefined) {
|
||||||
|
setSetting(SETTINGS_TURNSTILE_SITE_KEY, nextTurnstileSiteKey)
|
||||||
|
}
|
||||||
|
if (body.turnstileSecretKey !== undefined) {
|
||||||
|
setSetting(SETTINGS_TURNSTILE_SECRET_KEY, nextTurnstileSecretKey)
|
||||||
|
}
|
||||||
|
if (body.capApiEndpoint !== undefined) {
|
||||||
|
setSetting(SETTINGS_CAP_API_ENDPOINT, nextCapApiEndpoint)
|
||||||
|
}
|
||||||
|
if (body.capSecret !== undefined) {
|
||||||
|
setSetting(SETTINGS_CAP_SECRET, nextCapSecret)
|
||||||
|
}
|
||||||
|
|
||||||
return getSettingsPayload(event)
|
return getSettingsPayload(event)
|
||||||
})
|
})
|
||||||
@@ -41,7 +41,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
const userFilter = await getImageUserFilter(event)
|
const userFilter = await getImageUserFilter(event)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (user.role === 'admin' && userFilter === 'admin') {
|
if (userFilter === 'admin') {
|
||||||
const activity = getActivityStats()
|
const activity = getActivityStats()
|
||||||
const [storedCount, byFolder, storageUsage] = await Promise.all([
|
const [storedCount, byFolder, storageUsage] = await Promise.all([
|
||||||
countImages(),
|
countImages(),
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { mkdirSync } from 'node:fs'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { DatabaseSync } from 'node:sqlite'
|
||||||
|
|
||||||
|
const SETTING_KEY = 'login_verification_method'
|
||||||
|
const SLIDER = 'slider'
|
||||||
|
|
||||||
|
if (process.argv.length > 2) {
|
||||||
|
console.error('Usage: slider')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataDir = process.env.DATA_DIR || join(process.cwd(), 'data')
|
||||||
|
mkdirSync(dataDir, { recursive: true })
|
||||||
|
const db = new DatabaseSync(join(dataDir, 'pichost.db'))
|
||||||
|
|
||||||
|
const row = db.prepare(`
|
||||||
|
SELECT value
|
||||||
|
FROM settings
|
||||||
|
WHERE key = ?
|
||||||
|
`).get(SETTING_KEY)
|
||||||
|
|
||||||
|
const previous = row?.value ?? null
|
||||||
|
const updatedAt = new Date().toISOString()
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO settings (key, value, updated_at)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET
|
||||||
|
value = excluded.value,
|
||||||
|
updated_at = excluded.updated_at
|
||||||
|
`).run(SETTING_KEY, SLIDER, updatedAt)
|
||||||
|
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
if (previous === SLIDER) {
|
||||||
|
console.log('[PicHost] Login verification is already set to slider.')
|
||||||
|
} else {
|
||||||
|
console.log(`[PicHost] Login verification changed: ${previous ?? '(unset)'} → slider`)
|
||||||
|
}
|
||||||
|
console.log('[PicHost] Refresh the login page to use the local slider captcha.')
|
||||||
@@ -180,7 +180,10 @@ export async function getImageUserFilter(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const user = await requireUserAuth(event)
|
const user = await requireUserAuth(event)
|
||||||
return user.role === 'admin' ? 'admin' : user.id
|
if (user.role === 'admin') {
|
||||||
|
return 'admin'
|
||||||
|
}
|
||||||
|
return user.id
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function assertImageOwnership(
|
export async function assertImageOwnership(
|
||||||
|
|||||||
@@ -384,6 +384,11 @@ export const SETTINGS_IMAGE_BASE_URL = 'image_base_url'
|
|||||||
export const SETTINGS_AUTO_DELETE_DAYS = 'auto_delete_days'
|
export const SETTINGS_AUTO_DELETE_DAYS = 'auto_delete_days'
|
||||||
export const SETTINGS_AUTO_DELETE_ENABLED_AT = 'auto_delete_enabled_at'
|
export const SETTINGS_AUTO_DELETE_ENABLED_AT = 'auto_delete_enabled_at'
|
||||||
export const SETTINGS_ALLOW_REGISTRATION = 'allow_registration'
|
export const SETTINGS_ALLOW_REGISTRATION = 'allow_registration'
|
||||||
|
export const SETTINGS_LOGIN_VERIFICATION_METHOD = 'login_verification_method'
|
||||||
|
export const SETTINGS_TURNSTILE_SITE_KEY = 'turnstile_site_key'
|
||||||
|
export const SETTINGS_TURNSTILE_SECRET_KEY = 'turnstile_secret_key'
|
||||||
|
export const SETTINGS_CAP_API_ENDPOINT = 'cap_api_endpoint'
|
||||||
|
export const SETTINGS_CAP_SECRET = 'cap_secret'
|
||||||
|
|
||||||
export function isAllowRegistration(): boolean {
|
export function isAllowRegistration(): boolean {
|
||||||
return getSetting(SETTINGS_ALLOW_REGISTRATION) === 'true'
|
return getSetting(SETTINGS_ALLOW_REGISTRATION) === 'true'
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ import {
|
|||||||
SETTINGS_IMAGE_BASE_URL,
|
SETTINGS_IMAGE_BASE_URL,
|
||||||
SETTINGS_SITE_BASE_URL,
|
SETTINGS_SITE_BASE_URL,
|
||||||
SETTINGS_WEBP_QUALITY,
|
SETTINGS_WEBP_QUALITY,
|
||||||
|
SETTINGS_LOGIN_VERIFICATION_METHOD,
|
||||||
|
SETTINGS_TURNSTILE_SITE_KEY,
|
||||||
|
SETTINGS_TURNSTILE_SECRET_KEY,
|
||||||
|
SETTINGS_CAP_API_ENDPOINT,
|
||||||
|
SETTINGS_CAP_SECRET,
|
||||||
isAllowRegistration
|
isAllowRegistration
|
||||||
} from './db'
|
} from './db'
|
||||||
|
|
||||||
@@ -257,6 +262,93 @@ export function getApiUploadToken(event: H3Event): string {
|
|||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const LOGIN_VERIFICATION_METHODS = ['slider', 'turnstile', 'cap'] as const
|
||||||
|
export type LoginVerificationMethod = typeof LOGIN_VERIFICATION_METHODS[number]
|
||||||
|
|
||||||
|
const DEFAULT_LOGIN_VERIFICATION_METHOD: LoginVerificationMethod = 'slider'
|
||||||
|
|
||||||
|
export function parseLoginVerificationMethod(raw: string): LoginVerificationMethod | null {
|
||||||
|
if (raw === 'slider' || raw === 'turnstile' || raw === 'cap') return raw
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLoginVerificationMethod(_event?: H3Event): LoginVerificationMethod {
|
||||||
|
const fromDb = getSetting(SETTINGS_LOGIN_VERIFICATION_METHOD)
|
||||||
|
if (fromDb !== null) {
|
||||||
|
return parseLoginVerificationMethod(fromDb) ?? DEFAULT_LOGIN_VERIFICATION_METHOD
|
||||||
|
}
|
||||||
|
return DEFAULT_LOGIN_VERIFICATION_METHOD
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTurnstileSiteKeyConfigured(_event?: H3Event): string {
|
||||||
|
return getSetting(SETTINGS_TURNSTILE_SITE_KEY)?.trim() ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTurnstileSecretKey(_event?: H3Event): string {
|
||||||
|
return getSetting(SETTINGS_TURNSTILE_SECRET_KEY)?.trim() ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeCapApiEndpoint(raw: string): string {
|
||||||
|
const trimmed = raw.trim()
|
||||||
|
if (!trimmed) return ''
|
||||||
|
return trimmed.endsWith('/') ? trimmed : `${trimmed}/`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCapApiEndpointConfigured(_event?: H3Event): string {
|
||||||
|
const fromDb = getSetting(SETTINGS_CAP_API_ENDPOINT)
|
||||||
|
return fromDb ? normalizeCapApiEndpoint(fromDb) : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCapSecret(_event?: H3Event): string {
|
||||||
|
return getSetting(SETTINGS_CAP_SECRET)?.trim() ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginVerificationPublicConfig {
|
||||||
|
method: LoginVerificationMethod
|
||||||
|
turnstileSiteKey?: string
|
||||||
|
capApiEndpoint?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLoginVerificationPublicConfig(event: H3Event): LoginVerificationPublicConfig {
|
||||||
|
const method = getLoginVerificationMethod(event)
|
||||||
|
const config: LoginVerificationPublicConfig = { method }
|
||||||
|
if (method === 'turnstile') {
|
||||||
|
const siteKey = getTurnstileSiteKeyConfigured(event)
|
||||||
|
if (siteKey) config.turnstileSiteKey = siteKey
|
||||||
|
}
|
||||||
|
if (method === 'cap') {
|
||||||
|
const endpoint = getCapApiEndpointConfigured(event)
|
||||||
|
if (endpoint) config.capApiEndpoint = endpoint
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateLoginVerificationSettings(
|
||||||
|
method: LoginVerificationMethod,
|
||||||
|
turnstileSiteKey: string,
|
||||||
|
turnstileSecretKey: string,
|
||||||
|
capApiEndpoint: string,
|
||||||
|
capSecret: string
|
||||||
|
): string | null {
|
||||||
|
if (method === 'turnstile') {
|
||||||
|
if (!turnstileSiteKey.trim()) {
|
||||||
|
return '请配置 Turnstile Site Key'
|
||||||
|
}
|
||||||
|
if (!turnstileSecretKey.trim()) {
|
||||||
|
return '请配置 Turnstile Secret Key'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (method === 'cap') {
|
||||||
|
if (!normalizeCapApiEndpoint(capApiEndpoint)) {
|
||||||
|
return '请配置 Cap API Endpoint'
|
||||||
|
}
|
||||||
|
if (!capSecret.trim()) {
|
||||||
|
return '请配置 Cap Secret Key'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
export const DEFAULT_WEBP_QUALITY = 80
|
export const DEFAULT_WEBP_QUALITY = 80
|
||||||
|
|
||||||
export type WebpQualitySource = 'env' | 'db' | 'default'
|
export type WebpQualitySource = 'env' | 'db' | 'default'
|
||||||
@@ -596,6 +688,11 @@ export function getSettingsPayload(event: H3Event) {
|
|||||||
autoDeleteDaysSource: getAutoDeleteDaysSource(),
|
autoDeleteDaysSource: getAutoDeleteDaysSource(),
|
||||||
autoDeleteEnvFallback: parseAutoDeleteDays(getAutoDeleteDaysFromEnv()) ?? 0,
|
autoDeleteEnvFallback: parseAutoDeleteDays(getAutoDeleteDaysFromEnv()) ?? 0,
|
||||||
allowRegistration: isAllowRegistration(),
|
allowRegistration: isAllowRegistration(),
|
||||||
|
loginVerificationMethod: getLoginVerificationMethod(event),
|
||||||
|
turnstileSiteKey: getTurnstileSiteKeyConfigured(event),
|
||||||
|
turnstileSecretKey: getTurnstileSecretKey(event),
|
||||||
|
capApiEndpoint: getCapApiEndpointConfigured(event),
|
||||||
|
capSecret: getCapSecret(event),
|
||||||
appVersion,
|
appVersion,
|
||||||
env: {
|
env: {
|
||||||
webpQuality,
|
webpQuality,
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { verifyTurnstileToken } from './verify-turnstile'
|
||||||
|
import { verifyCapToken } from './verify-cap'
|
||||||
|
|
||||||
|
describe('verify-turnstile', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn())
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns true when siteverify succeeds', async () => {
|
||||||
|
vi.mocked(fetch).mockResolvedValue({
|
||||||
|
json: async () => ({ success: true })
|
||||||
|
} as Response)
|
||||||
|
|
||||||
|
await expect(verifyTurnstileToken('secret', 'token', '1.2.3.4')).resolves.toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false when siteverify fails', async () => {
|
||||||
|
vi.mocked(fetch).mockResolvedValue({
|
||||||
|
json: async () => ({ success: false })
|
||||||
|
} as Response)
|
||||||
|
|
||||||
|
await expect(verifyTurnstileToken('secret', 'token')).resolves.toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false when token is missing', async () => {
|
||||||
|
await expect(verifyTurnstileToken('secret', '')).resolves.toBe(false)
|
||||||
|
expect(fetch).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('verify-cap', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn())
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('posts to endpoint siteverify and returns true on success', async () => {
|
||||||
|
vi.mocked(fetch).mockResolvedValue({
|
||||||
|
json: async () => ({ success: true })
|
||||||
|
} as Response)
|
||||||
|
|
||||||
|
const ok = await verifyCapToken(
|
||||||
|
'https://cap.example.com/site-key/',
|
||||||
|
'secret',
|
||||||
|
'token'
|
||||||
|
)
|
||||||
|
expect(ok).toBe(true)
|
||||||
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
|
'https://cap.example.com/site-key/siteverify',
|
||||||
|
expect.objectContaining({ method: 'POST' })
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false on network error', async () => {
|
||||||
|
vi.mocked(fetch).mockRejectedValue(new Error('network'))
|
||||||
|
|
||||||
|
await expect(verifyCapToken(
|
||||||
|
'https://cap.example.com/site-key/',
|
||||||
|
'secret',
|
||||||
|
'token'
|
||||||
|
)).resolves.toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import type { H3Event } from 'h3'
|
||||||
|
import { createApiError } from './api-error'
|
||||||
|
import {
|
||||||
|
getCapApiEndpointConfigured,
|
||||||
|
getCapSecret,
|
||||||
|
getLoginVerificationMethod,
|
||||||
|
getTurnstileSecretKey,
|
||||||
|
getTurnstileSiteKeyConfigured
|
||||||
|
} from './env'
|
||||||
|
import { verifyLoginCaptcha } from './login-captcha'
|
||||||
|
import { clientIp } from './logger'
|
||||||
|
import { verifyCapToken } from './verify-cap'
|
||||||
|
import { verifyTurnstileToken } from './verify-turnstile'
|
||||||
|
|
||||||
|
export interface VerificationBody {
|
||||||
|
captchaId?: string
|
||||||
|
captchaPosition?: number
|
||||||
|
turnstileToken?: string
|
||||||
|
capToken?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyLoginVerification(
|
||||||
|
event: H3Event,
|
||||||
|
body: VerificationBody
|
||||||
|
): Promise<void> {
|
||||||
|
const method = getLoginVerificationMethod(event)
|
||||||
|
|
||||||
|
if (method === 'slider') {
|
||||||
|
const id = body.captchaId?.trim() ?? ''
|
||||||
|
const position = Number(body.captchaPosition)
|
||||||
|
if (!id || !verifyLoginCaptcha(id, position)) {
|
||||||
|
createApiError(event, 'INVALID_REQUEST', '请先完成滑块验证', 400)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'turnstile') {
|
||||||
|
if (!getTurnstileSiteKeyConfigured(event) || !getTurnstileSecretKey(event)) {
|
||||||
|
createApiError(event, 'INVALID_REQUEST', '人机验证未正确配置', 400)
|
||||||
|
}
|
||||||
|
const token = body.turnstileToken?.trim() ?? ''
|
||||||
|
const ok = await verifyTurnstileToken(
|
||||||
|
getTurnstileSecretKey(event),
|
||||||
|
token,
|
||||||
|
clientIp(event)
|
||||||
|
)
|
||||||
|
if (!ok) {
|
||||||
|
createApiError(event, 'INVALID_REQUEST', '请先完成人机验证', 400)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!getCapApiEndpointConfigured(event) || !getCapSecret(event)) {
|
||||||
|
createApiError(event, 'INVALID_REQUEST', '人机验证未正确配置', 400)
|
||||||
|
}
|
||||||
|
const token = body.capToken?.trim() ?? ''
|
||||||
|
const ok = await verifyCapToken(
|
||||||
|
getCapApiEndpointConfigured(event),
|
||||||
|
getCapSecret(event),
|
||||||
|
token
|
||||||
|
)
|
||||||
|
if (!ok) {
|
||||||
|
createApiError(event, 'INVALID_REQUEST', '请先完成人机验证', 400)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
interface CapVerifyResult {
|
||||||
|
success?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyCapToken(
|
||||||
|
endpoint: string,
|
||||||
|
secret: string,
|
||||||
|
token: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!endpoint || !secret || !token) return false
|
||||||
|
|
||||||
|
const base = endpoint.endsWith('/') ? endpoint : `${endpoint}/`
|
||||||
|
const url = `${base}siteverify`
|
||||||
|
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeout = setTimeout(() => controller.abort(), 5000)
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ secret, response: token }),
|
||||||
|
signal: controller.signal
|
||||||
|
})
|
||||||
|
const result = await response.json() as CapVerifyResult
|
||||||
|
return result.success === true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
interface TurnstileVerifyResult {
|
||||||
|
success?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyTurnstileToken(
|
||||||
|
secret: string,
|
||||||
|
token: string,
|
||||||
|
remoteIp?: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!secret || !token) return false
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
secret,
|
||||||
|
response: token
|
||||||
|
})
|
||||||
|
if (remoteIp) {
|
||||||
|
params.set('remoteip', remoteIp)
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeout = setTimeout(() => controller.abort(), 5000)
|
||||||
|
try {
|
||||||
|
const response = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: params,
|
||||||
|
signal: controller.signal
|
||||||
|
})
|
||||||
|
const result = await response.json() as TurnstileVerifyResult
|
||||||
|
return result.success === true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user