diff --git a/CHANGELOG.md b/CHANGELOG.md index 57866db..76f436f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ ## [Unreleased] +## [1.2.7] - 2026-09-03 + +### 新增 + +- 登录/注册人机验证:本地滑块、Cloudflare Turnstile、Cap;管理员在「设置 → 访问控制」中切换 +- 运维 CLI `slider`:将登录验证重置为本地滑块(`docker exec pichost slider`) + +### 改进 + +- 登出后登录页验证码可正常重新加载 + ## [1.2.6] - 2026-09-01 ### 新增 diff --git a/Dockerfile b/Dockerfile index fdd2c93..e4a5206 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,6 +25,7 @@ ENV NODE_ENV=production \ COPY --from=build /app/.output ./.output 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/slider.mjs ./server/cli/slider.mjs COPY docker-entrypoint.sh /docker-entrypoint.sh RUN chmod +x /docker-entrypoint.sh diff --git a/app/components/AdminLoginGate.vue b/app/components/AdminLoginGate.vue index 663a5d6..b6f2ff2 100644 --- a/app/components/AdminLoginGate.vue +++ b/app/components/AdminLoginGate.vue @@ -1,6 +1,7 @@ + + diff --git a/app/components/ImageCard.vue b/app/components/ImageCard.vue index 4137438..6bd8054 100644 --- a/app/components/ImageCard.vue +++ b/app/components/ImageCard.vue @@ -10,12 +10,14 @@ const props = withDefaults(defineProps<{ showStorage?: boolean compact?: boolean gallery?: boolean + allowDelete?: boolean }>(), { selectable: true, showKey: true, showStorage: false, compact: false, - gallery: false + gallery: false, + allowDelete: true }) const emit = defineEmits<{ @@ -251,6 +253,7 @@ async function copyGalleryUrl() { @click="emit('preview')" /> (), { selectable: true, showKey: true, showStorage: false, dense: false, compact: false, - gallery: false + gallery: false, + allowDelete: true }) const gridClass = computed(() => { @@ -65,6 +67,7 @@ function updateSelection(key: string, selected: boolean) { :show-storage="showStorage" :compact="compact" :gallery="gallery" + :allow-delete="allowDelete" @update:selected="updateSelection(image.key, $event)" @preview="emit('preview', image)" @delete="emit('delete', image)" diff --git a/app/components/ImagePreviewModal.vue b/app/components/ImagePreviewModal.vue index 9e01e44..1e54778 100644 --- a/app/components/ImagePreviewModal.vue +++ b/app/components/ImagePreviewModal.vue @@ -10,8 +10,10 @@ const props = withDefaults(defineProps<{ image: ImageItem | null showStorage?: boolean deleting?: boolean + allowDelete?: boolean }>(), { - showStorage: false + showStorage: false, + allowDelete: true }) const emit = defineEmits<{ @@ -396,6 +398,7 @@ function onImageLoad(event: Event) { @click="copyLink" /> +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 } | 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 }) + + + diff --git a/app/components/TurnstileCaptcha.vue b/app/components/TurnstileCaptcha.vue new file mode 100644 index 0000000..b7fa8ed --- /dev/null +++ b/app/components/TurnstileCaptcha.vue @@ -0,0 +1,120 @@ + + + diff --git a/app/components/gallery/GalleryFilterBar.vue b/app/components/gallery/GalleryFilterBar.vue index c582a70..2b1ae55 100644 --- a/app/components/gallery/GalleryFilterBar.vue +++ b/app/components/gallery/GalleryFilterBar.vue @@ -8,9 +8,11 @@ const props = withDefaults(defineProps<{ viewMode: 'grid' | 'list' selectedCount?: number loading?: boolean + showBatchDelete?: boolean }>(), { selectedCount: 0, - loading: false + loading: false, + showBatchDelete: true }) const emit = defineEmits<{ @@ -99,6 +101,7 @@ function viewButtonClass(active: boolean) { (), { selectable: false, - showStorage: false + showStorage: false, + showDelete: true }) const emit = defineEmits<{ @@ -198,6 +200,7 @@ function typeLabel(image: ImageItem) { @click="emit('preview', image)" /> { + 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() { @@ -123,7 +142,7 @@ export function useAuth() { async function login( credentials: { username: string, password: string } | { secret: string }, - captcha: LoginCaptchaPayload + verification: LoginVerificationPayload ): Promise<{ ok: boolean, needsMigration?: boolean, error?: string }> { try { const result = await $fetch<{ @@ -135,8 +154,7 @@ export function useAuth() { credentials: 'include', body: { ...credentials, - captchaId: captcha.captchaId, - captchaPosition: captcha.captchaPosition + ...buildVerificationBody(verification) } }) if (result.needsMigration) { @@ -159,7 +177,8 @@ export function useAuth() { async function register( username: string, - password: string + password: string, + verification: LoginVerificationPayload ): Promise { const validationError = validateAuthInput(username, password) if (validationError) { @@ -172,7 +191,11 @@ export function useAuth() { { method: 'POST', credentials: 'include', - body: { username: username.trim(), password } + body: { + username: username.trim(), + password, + ...buildVerificationBody(verification) + } } ) user.value = result.user @@ -291,15 +314,19 @@ export function useAuth() { async function logout() { try { - await $fetch('/api/auth/logout', { method: 'POST' }) + await $fetch('/api/auth/logout', { method: 'POST', credentials: 'include' }) } catch { // 忽略登出失败 } finally { status.value = 'unauthenticated' user.value = null - authStatus.value = null hasValidatedOnce.value = true } + try { + await fetchStatus() + } catch { + // 保留既有公开配置(loginVerification 等) + } } function handleAuthError(error: unknown) { diff --git a/app/pages/api.vue b/app/pages/api.vue index a9522a3..c1f5cf7 100644 --- a/app/pages/api.vue +++ b/app/pages/api.vue @@ -35,7 +35,9 @@ const baseUrl = computed(() => { const tokenDisplay = computed(() => settings.value?.apiUploadToken ?? '') 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(() => hasToken.value ? tokenDisplay.value : 'YOUR-TOKEN' diff --git a/app/pages/gallery.vue b/app/pages/gallery.vue index f38f43b..8864cd4 100644 --- a/app/pages/gallery.vue +++ b/app/pages/gallery.vue @@ -401,6 +401,7 @@ watch(isAuthenticated, async (authed, prev) => { :view-mode="viewMode" :selected-count="selectedCount" :loading="galleryLoading" + :show-batch-delete="true" @update:storage-backend="handleStorageBackendChange" @update:upload-source="handleUploadSourceChange" @update:view-mode="viewMode = $event" @@ -430,7 +431,8 @@ watch(isAuthenticated, async (authed, prev) => { show-storage :items="items" :selected-keys="selectedKeys" - selectable + :selectable="true" + :allow-delete="true" @update:selected-keys="updateSelectedKeys" @preview="openPreview" @delete="(image) => requestDelete(image.key)" @@ -442,6 +444,7 @@ watch(isAuthenticated, async (authed, prev) => { :items="items" :selected-keys="selectedKeys" :selectable="true" + :show-delete="true" @update:selected-keys="updateSelectedKeys" @preview="openPreview" @delete="(image) => requestDelete(image.key)" @@ -452,6 +455,7 @@ watch(isAuthenticated, async (authed, prev) => { :image="previewImage" show-storage :deleting="previewImage ? deletingKeys.has(previewImage.key) : false" + :allow-delete="true" @delete="requestPreviewDelete" /> diff --git a/app/pages/index.vue b/app/pages/index.vue index c5cf2f8..1f73d79 100644 --- a/app/pages/index.vue +++ b/app/pages/index.vue @@ -206,6 +206,7 @@ function requestPreviewDelete() { v-model:open="previewOpen" :image="previewImage" :deleting="previewImage ? deletingKeys.has(previewImage.key) : false" + :allow-delete="true" @delete="requestPreviewDelete" /> diff --git a/app/pages/register.vue b/app/pages/register.vue index 83670f0..96918f8 100644 --- a/app/pages/register.vue +++ b/app/pages/register.vue @@ -2,9 +2,10 @@ import logoLight from '~/assets/image/logo-light.png' import logoDark from '~/assets/image/logo-dark.png' import { isPasswordValid } from '~/utils/password-strength' +import type { LoginVerificationPayload } from '~/types/auth' const router = useRouter() -const { register, fetchStatus } = useAuth() +const { register, fetchStatus, authStatus } = useAuth() const toast = useToast() const { t } = useI18n() @@ -12,14 +13,34 @@ const username = ref('') const password = ref('') const confirmPassword = ref('') const loading = ref(false) +const verificationRef = ref<{ reset: () => Promise } | null>(null) +const verificationPayload = ref(null) + +const loginVerification = computed(() => + authStatus.value?.loginVerification ?? { method: 'slider' as const } +) const canSubmit = computed(() => - Boolean(username.value && password.value && confirmPassword.value) + Boolean(verificationPayload.value) + && Boolean(username.value && password.value && confirmPassword.value) && password.value === confirmPassword.value && isPasswordValid(password.value) && 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 () => { const status = await fetchStatus() if (!status.initialized && !status.legacyMode) { @@ -32,7 +53,7 @@ onMounted(async () => { }) async function submit() { - if (!canSubmit.value || loading.value) return + if (!canSubmit.value || loading.value || !verificationPayload.value) return if (password.value !== confirmPassword.value) { toast.add({ title: t('auth.passwordMismatch'), color: 'error' }) @@ -41,11 +62,16 @@ async function submit() { loading.value = true try { - const result = await register(username.value.trim(), password.value) + const result = await register( + username.value.trim(), + password.value, + verificationPayload.value + ) if (result.ok) { toast.add({ title: t('auth.registerSuccess'), color: 'success' }) await router.replace('/') } else { + await resetVerification() toast.add({ title: result.error, color: 'error' }) } } finally { @@ -142,6 +168,15 @@ async function submit() { /> + + ('slider') +const turnstileSiteKeyDraft = ref('') +const turnstileSecretKeyDraft = ref('') +const capApiEndpointDraft = ref('') +const capSecretDraft = ref('') const domainSeparationDraft = ref(false) const disableDomainSeparationOpen = ref(false) const pendingDomainSeparationDisable = ref(false) @@ -120,6 +131,11 @@ const hasServerChanges = computed(() => { || hideFolderInUrlDraft.value !== settings.value.hideFolderInUrl || storageUseDatePathDraft.value !== settings.value.storageUseDatePath || 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) { @@ -150,6 +166,11 @@ function applySettings(data: SettingsResponse) { hideFolderInUrlDraft.value = data.hideFolderInUrl storageUseDatePathDraft.value = data.storageUseDatePath 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 } @@ -221,7 +242,12 @@ async function saveServerSettings() { imageBaseUrl: imageBaseUrlDraft.value, hideFolderInUrl: hideFolderInUrlDraft.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' }) } 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() { if (isAdmin.value) { - await Promise.all([loadSettings(), checkLatestRelease()]) + await loadSettings() + await checkLatestRelease() } } @@ -382,7 +426,10 @@ watch(isAdmin, () => { :title="t('settings.systemInfo')" :hint="t('settings.systemInfoHint')" > -