Compare commits
3
Commits
v1.2.6
..
b05d572221
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b05d572221 | ||
|
|
4789d19d06 | ||
|
|
430c4de0f4 |
No files matched your search
@@ -35,3 +35,6 @@
|
||||
|
||||
# 仅本地开发:设为 true 可绕过登录验证(跳过 /setup,一般无需开启)
|
||||
# DEV_BYPASS_ACCESS=false
|
||||
|
||||
# 登录人机验证(滑块 / Turnstile / Cap)在后台「设置 → 访问控制」配置,无环境变量。
|
||||
# 配置错误无法登录时:docker exec pichost slider(或 npm run slider)恢复为本地滑块。
|
||||
@@ -4,6 +4,31 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.2.8] - 2026-09-04
|
||||
|
||||
### 改进
|
||||
|
||||
- 双域名:保存或初始化时弹出风险确认(说明可能锁死及 `clear-domains` 恢复方式),不再强制校验当前 Host
|
||||
|
||||
### 新增
|
||||
|
||||
- 运维 CLI `clear-domains`:清除数据库中的网站域 / 图片域配置(`docker exec pichost clear-domains`)
|
||||
|
||||
### 文档
|
||||
|
||||
- FAQ、双域名、反向代理文档补充锁死原因与恢复步骤
|
||||
|
||||
## [1.2.7] - 2026-09-03
|
||||
|
||||
### 新增
|
||||
|
||||
- 登录/注册人机验证:本地滑块、Cloudflare Turnstile、Cap;管理员在「设置 → 访问控制」中切换
|
||||
- 运维 CLI `slider`:将登录验证重置为本地滑块(`docker exec pichost slider`)
|
||||
|
||||
### 改进
|
||||
|
||||
- 登出后登录页验证码可正常重新加载
|
||||
|
||||
## [1.2.6] - 2026-09-01
|
||||
|
||||
### 新增
|
||||
|
||||
@@ -25,6 +25,8 @@ 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 server/cli/clear-domains.mjs ./server/cli/clear-domains.mjs
|
||||
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
||||
RUN chmod +x /docker-entrypoint.sh
|
||||
|
||||
|
||||
+1
-1
@@ -42,7 +42,7 @@
|
||||
## Features
|
||||
|
||||
- **Drag, click, or Ctrl+V paste** — server-side WebP, Referer hotlink protection
|
||||
- **Multi-user** — login with slider captcha, optional registration; users see only their images
|
||||
- **Multi-user** — login with slider, Turnstile, or Cap verification; optional registration; users see only their images
|
||||
- **Multi-backend storage** — local disk + S3-compatible (R2 / COS / OSS / AWS); hybrid `proxy` / `public` URLs
|
||||
- **Gallery** — browse, search, filter by storage/source, grid/list views, batch delete; stats overview and source breakdown
|
||||
- **API & Twikoo** — global / per-user tokens; `POST /api/index.php` compatible
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
## 特性
|
||||
|
||||
- **拖拽 / 点击 / Ctrl+V 粘贴**上传;服务端 WebP 压缩、Referer 防盗链
|
||||
- **多用户**:账号密码登录(滑动验证)、可选开放注册;普通用户仅见自己的图片
|
||||
- **多用户**:账号密码登录(滑块 / Turnstile / Cap 人机验证)、可选开放注册;普通用户仅见自己的图片
|
||||
- **多后端存储**:本地磁盘 + S3 兼容(R2 / COS / OSS / AWS);混合直链 `proxy` / `public`
|
||||
- **图库**:浏览、搜索、按存储/来源筛选、网格/列表切换、批量删除;统计概览与来源分布
|
||||
- **API 与 Twikoo**:全局 / 个人 Token;`POST /api/index.php` 兼容
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import logoLight from '~/assets/image/logo-light.png'
|
||||
import logoDark from '~/assets/image/logo-dark.png'
|
||||
import type { LoginVerificationPayload } from '~/types/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const { login, fetchStatus, authStatus } = useAuth()
|
||||
@@ -11,45 +12,53 @@ const username = ref('')
|
||||
const password = ref('')
|
||||
const secret = ref('')
|
||||
const loading = ref(false)
|
||||
const captchaRef = ref<{ reset: () => Promise<void> } | null>(null)
|
||||
const captchaPayload = ref<{ captchaId: string, captchaPosition: number } | null>(null)
|
||||
const verificationRef = ref<{ reset: () => Promise<void> } | null>(null)
|
||||
const verificationPayload = ref<LoginVerificationPayload | null>(null)
|
||||
const verificationMountKey = ref(0)
|
||||
|
||||
const legacyMode = computed(() => authStatus.value?.legacyMode ?? false)
|
||||
const allowRegistration = computed(() => authStatus.value?.allowRegistration ?? false)
|
||||
const loginVerification = computed(() =>
|
||||
authStatus.value?.loginVerification ?? { method: 'slider' as const }
|
||||
)
|
||||
|
||||
const canSubmit = computed(() =>
|
||||
Boolean(captchaPayload.value)
|
||||
Boolean(verificationPayload.value)
|
||||
&& (legacyMode.value ? Boolean(secret.value) : Boolean(username.value && password.value))
|
||||
)
|
||||
|
||||
function onCaptchaVerified(payload: { id: string, positionPercent: number }) {
|
||||
captchaPayload.value = {
|
||||
captchaId: payload.id,
|
||||
captchaPosition: payload.positionPercent
|
||||
}
|
||||
function onVerificationVerified(payload: LoginVerificationPayload) {
|
||||
verificationPayload.value = payload
|
||||
}
|
||||
|
||||
async function resetCaptcha() {
|
||||
captchaPayload.value = null
|
||||
await captchaRef.value?.reset()
|
||||
function onVerificationCleared() {
|
||||
verificationPayload.value = null
|
||||
}
|
||||
|
||||
async function resetVerification() {
|
||||
verificationPayload.value = null
|
||||
await verificationRef.value?.reset()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
verificationPayload.value = null
|
||||
verificationMountKey.value += 1
|
||||
const status = await fetchStatus()
|
||||
if (!status.initialized && !status.legacyMode) {
|
||||
await router.replace('/setup')
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
async function submit() {
|
||||
if (!canSubmit.value || loading.value || !captchaPayload.value) return
|
||||
if (!canSubmit.value || loading.value || !verificationPayload.value) return
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const captcha = captchaPayload.value
|
||||
const verification = verificationPayload.value
|
||||
const result = legacyMode.value
|
||||
? await login({ secret: secret.value }, captcha)
|
||||
: await login({ username: username.value.trim(), password: password.value }, captcha)
|
||||
? await login({ secret: secret.value }, verification)
|
||||
: await login({ username: username.value.trim(), password: password.value }, verification)
|
||||
|
||||
if (result.ok) {
|
||||
if (result.needsMigration) {
|
||||
@@ -59,10 +68,10 @@ async function submit() {
|
||||
username.value = ''
|
||||
password.value = ''
|
||||
secret.value = ''
|
||||
await resetCaptcha()
|
||||
await resetVerification()
|
||||
toast.add({ title: t('auth.loginSuccess'), color: 'success' })
|
||||
} else {
|
||||
await resetCaptcha()
|
||||
await resetVerification()
|
||||
toast.add({
|
||||
title: result.error ?? (legacyMode.value ? t('auth.loginFailedSecret') : t('auth.loginFailedCredentials')),
|
||||
color: 'error'
|
||||
@@ -170,9 +179,14 @@ async function submit() {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<SliderCaptcha
|
||||
ref="captchaRef"
|
||||
@verified="onCaptchaVerified"
|
||||
<LoginVerification
|
||||
:key="verificationMountKey"
|
||||
ref="verificationRef"
|
||||
:method="loginVerification.method"
|
||||
:turnstile-site-key="loginVerification.turnstileSiteKey"
|
||||
:cap-api-endpoint="loginVerification.capApiEndpoint"
|
||||
@verified="onVerificationVerified"
|
||||
@cleared="onVerificationCleared"
|
||||
/>
|
||||
|
||||
<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
|
||||
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')"
|
||||
/>
|
||||
<UButton
|
||||
v-if="allowDelete"
|
||||
icon="i-lucide-trash-2"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
|
||||
@@ -12,13 +12,15 @@ const props = withDefaults(defineProps<{
|
||||
compact?: boolean
|
||||
/** 图库:5 列 × 2 行,缩略图略扁 */
|
||||
gallery?: boolean
|
||||
allowDelete?: boolean
|
||||
}>(), {
|
||||
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)"
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
<UButton
|
||||
v-if="allowDelete"
|
||||
:label="t('common.delete')"
|
||||
icon="i-lucide-trash-2"
|
||||
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'
|
||||
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) {
|
||||
</UButton>
|
||||
|
||||
<UButton
|
||||
v-if="showBatchDelete"
|
||||
type="button"
|
||||
icon="i-lucide-trash-2"
|
||||
size="sm"
|
||||
|
||||
@@ -7,9 +7,11 @@ const props = withDefaults(defineProps<{
|
||||
selectable?: boolean
|
||||
showStorage?: boolean
|
||||
emptyText?: string
|
||||
showDelete?: boolean
|
||||
}>(), {
|
||||
selectable: false,
|
||||
showStorage: false
|
||||
showStorage: false,
|
||||
showDelete: true
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -198,6 +200,7 @@ function typeLabel(image: ImageItem) {
|
||||
@click="emit('preview', image)"
|
||||
/>
|
||||
<UButton
|
||||
v-if="showDelete"
|
||||
icon="i-lucide-trash-2"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
|
||||
+37
-10
@@ -1,3 +1,5 @@
|
||||
import type { LoginVerificationPayload } from '~/types/auth'
|
||||
|
||||
export type AuthStatus = 'checking' | 'authenticated' | 'unauthenticated'
|
||||
|
||||
export type UserRole = 'admin' | 'user'
|
||||
@@ -8,11 +10,18 @@ export interface AuthUser {
|
||||
role: UserRole
|
||||
}
|
||||
|
||||
export interface LoginVerificationPublicConfig {
|
||||
method: 'slider' | 'turnstile' | 'cap'
|
||||
turnstileSiteKey?: string
|
||||
capApiEndpoint?: string
|
||||
}
|
||||
|
||||
export interface AuthStatusResponse {
|
||||
initialized: boolean
|
||||
allowRegistration: boolean
|
||||
legacyMode: boolean
|
||||
needsMigration: boolean
|
||||
loginVerification: LoginVerificationPublicConfig
|
||||
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 interface LoginCaptchaPayload {
|
||||
captchaId: string
|
||||
captchaPosition: number
|
||||
export type { LoginVerificationPayload }
|
||||
|
||||
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() {
|
||||
@@ -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<AuthActionResult> {
|
||||
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) {
|
||||
|
||||
+3
-1
@@ -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'
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
|
||||
@@ -206,6 +206,7 @@ function requestPreviewDelete() {
|
||||
v-model:open="previewOpen"
|
||||
:image="previewImage"
|
||||
:deleting="previewImage ? deletingKeys.has(previewImage.key) : false"
|
||||
:allow-delete="true"
|
||||
@delete="requestPreviewDelete"
|
||||
/>
|
||||
</AppShell>
|
||||
|
||||
+39
-4
@@ -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<void> } | null>(null)
|
||||
const verificationPayload = ref<LoginVerificationPayload | null>(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() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<LoginVerification
|
||||
ref="verificationRef"
|
||||
:method="loginVerification.method"
|
||||
:turnstile-site-key="loginVerification.turnstileSiteKey"
|
||||
:cap-api-endpoint="loginVerification.capApiEndpoint"
|
||||
@verified="onVerificationVerified"
|
||||
@cleared="onVerificationCleared"
|
||||
/>
|
||||
|
||||
<UButton
|
||||
type="submit"
|
||||
:label="t('auth.register')"
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import type { SettingsTab } from '~/types/settings'
|
||||
import { ADMIN_SETTINGS_TABS, USER_SETTINGS_TABS } from '~/types/settings'
|
||||
import { willActivateDomainSeparation } from '~/utils/domain-separation'
|
||||
|
||||
type SettingSource = 'env' | 'db' | 'none'
|
||||
type WebpQualitySource = 'env' | 'db' | 'default'
|
||||
type LoginVerificationMethod = 'slider' | 'turnstile' | 'cap'
|
||||
|
||||
interface SettingsResponse {
|
||||
apiUploadToken: string
|
||||
@@ -33,6 +35,11 @@ interface SettingsResponse {
|
||||
storageUseDatePath: boolean
|
||||
storageUseDatePathSource: SettingSource
|
||||
allowRegistration: boolean
|
||||
loginVerificationMethod: LoginVerificationMethod
|
||||
turnstileSiteKey: string
|
||||
turnstileSecretKey: string
|
||||
capApiEndpoint: string
|
||||
capSecret: string
|
||||
appVersion: string
|
||||
}
|
||||
|
||||
@@ -98,9 +105,15 @@ const imageBaseUrlDraft = ref('')
|
||||
const hideFolderInUrlDraft = ref(false)
|
||||
const storageUseDatePathDraft = ref(true)
|
||||
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 disableDomainSeparationOpen = ref(false)
|
||||
const pendingDomainSeparationDisable = ref(false)
|
||||
const domainSeparationSaveConfirmOpen = ref(false)
|
||||
|
||||
interface ReleaseCheckResponse {
|
||||
currentVersion: string
|
||||
@@ -120,8 +133,32 @@ 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
|
||||
})
|
||||
|
||||
const domainSeparationWouldActivate = computed(() =>
|
||||
willActivateDomainSeparation(
|
||||
domainSeparationDraft.value,
|
||||
siteBaseUrlDraft.value,
|
||||
imageBaseUrlDraft.value
|
||||
)
|
||||
)
|
||||
|
||||
const domainSeparationFieldsChanged = computed(() => {
|
||||
if (!settings.value) return false
|
||||
return domainSeparationDraft.value !== settings.value.domainSeparation
|
||||
|| siteBaseUrlDraft.value !== (settings.value.domainSeparation ? settings.value.siteBaseUrl : '')
|
||||
|| imageBaseUrlDraft.value !== settings.value.imageBaseUrl
|
||||
})
|
||||
|
||||
const needsDomainSeparationSaveConfirm = computed(() =>
|
||||
domainSeparationWouldActivate.value && domainSeparationFieldsChanged.value
|
||||
)
|
||||
|
||||
function sourceBadge(source: SettingSource | WebpQualitySource) {
|
||||
switch (source) {
|
||||
case 'env':
|
||||
@@ -150,6 +187,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
|
||||
}
|
||||
|
||||
@@ -212,6 +254,18 @@ async function saveServerSettings() {
|
||||
}
|
||||
}
|
||||
|
||||
if (needsDomainSeparationSaveConfirm.value) {
|
||||
domainSeparationSaveConfirmOpen.value = true
|
||||
return
|
||||
}
|
||||
|
||||
await performSaveServerSettings()
|
||||
}
|
||||
|
||||
async function performSaveServerSettings() {
|
||||
domainSeparationSaveConfirmOpen.value = false
|
||||
if (!isAuthenticated.value || !hasServerChanges.value) return
|
||||
|
||||
savingServer.value = true
|
||||
try {
|
||||
await patchSettings({
|
||||
@@ -221,7 +275,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 +338,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 +459,10 @@ watch(isAdmin, () => {
|
||||
:title="t('settings.systemInfo')"
|
||||
:hint="t('settings.systemInfoHint')"
|
||||
>
|
||||
<template #action>
|
||||
<template
|
||||
v-if="isAdmin"
|
||||
#action
|
||||
>
|
||||
<UButton
|
||||
:label="t('settings.checkUpdate')"
|
||||
icon="i-lucide-refresh-cw"
|
||||
@@ -449,12 +529,14 @@ watch(isAdmin, () => {
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p
|
||||
<UAlert
|
||||
v-if="domainSeparationDraft && settings.runtime.hostRole === 'unknown'"
|
||||
class="rounded-xl border border-error/25 bg-error/5 px-4 py-3 text-xs leading-relaxed text-error"
|
||||
>
|
||||
{{ t('settings.hostRoleUnknown') }}
|
||||
</p>
|
||||
color="warning"
|
||||
variant="subtle"
|
||||
icon="i-lucide-triangle-alert"
|
||||
:title="t('settings.hostRoleUnknown')"
|
||||
class="mb-4"
|
||||
/>
|
||||
|
||||
<SettingsSection
|
||||
:title="t('settings.pathAndLink')"
|
||||
@@ -712,6 +794,75 @@ watch(isAdmin, () => {
|
||||
</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>
|
||||
<div
|
||||
v-else
|
||||
@@ -760,6 +911,30 @@ watch(isAdmin, () => {
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
|
||||
<UModal
|
||||
:open="domainSeparationSaveConfirmOpen"
|
||||
:title="t('domainSeparationConfirm.title')"
|
||||
:description="t('domainSeparationConfirm.description')"
|
||||
@update:open="(v) => { if (!v) domainSeparationSaveConfirmOpen = false }"
|
||||
>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<UButton
|
||||
:label="t('common.cancel')"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
@click="() => { domainSeparationSaveConfirmOpen = false }"
|
||||
/>
|
||||
<UButton
|
||||
:label="t('domainSeparationConfirm.confirm')"
|
||||
color="warning"
|
||||
:loading="savingServer"
|
||||
@click="performSaveServerSettings"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
+47
-1
@@ -2,6 +2,7 @@
|
||||
import logoLight from '~/assets/image/logo-light.png'
|
||||
import logoDark from '~/assets/image/logo-dark.png'
|
||||
import { isPasswordValid } from '~/utils/password-strength'
|
||||
import { willActivateDomainSeparation } from '~/utils/domain-separation'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -26,6 +27,15 @@ const domainSeparation = ref(false)
|
||||
const siteBaseUrl = ref('')
|
||||
const imageBaseUrl = ref('')
|
||||
const loading = ref(false)
|
||||
const domainSeparationConfirmOpen = ref(false)
|
||||
|
||||
const domainSeparationWouldActivate = computed(() =>
|
||||
willActivateDomainSeparation(
|
||||
domainSeparation.value,
|
||||
siteBaseUrl.value,
|
||||
imageBaseUrl.value
|
||||
)
|
||||
)
|
||||
|
||||
const canSubmit = computed(() => {
|
||||
if (!username.value || !password.value || !confirmPassword.value) return false
|
||||
@@ -33,7 +43,7 @@ const canSubmit = computed(() => {
|
||||
if (!isPasswordValid(password.value)) return false
|
||||
if (!AUTH_USERNAME_PATTERN.test(username.value.trim())) return false
|
||||
if (domainSeparation.value) {
|
||||
return Boolean(siteBaseUrl.value.trim() && imageBaseUrl.value.trim())
|
||||
if (!siteBaseUrl.value.trim() || !imageBaseUrl.value.trim()) return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
@@ -63,6 +73,18 @@ async function submit() {
|
||||
return
|
||||
}
|
||||
|
||||
if (domainSeparationWouldActivate.value) {
|
||||
domainSeparationConfirmOpen.value = true
|
||||
return
|
||||
}
|
||||
|
||||
await performSubmit()
|
||||
}
|
||||
|
||||
async function performSubmit() {
|
||||
domainSeparationConfirmOpen.value = false
|
||||
if (!canSubmit.value || loading.value) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const payload = {
|
||||
@@ -248,5 +270,29 @@ async function submit() {
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<UModal
|
||||
:open="domainSeparationConfirmOpen"
|
||||
:title="t('domainSeparationConfirm.title')"
|
||||
:description="t('domainSeparationConfirm.description')"
|
||||
@update:open="(v) => { if (!v) domainSeparationConfirmOpen = false }"
|
||||
>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<UButton
|
||||
:label="t('common.cancel')"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
@click="() => { domainSeparationConfirmOpen = false }"
|
||||
/>
|
||||
<UButton
|
||||
:label="t('domainSeparationConfirm.confirm')"
|
||||
color="warning"
|
||||
:loading="loading"
|
||||
@click="performSubmit"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
export function hostnameFromBaseUrl(value: string): string | null {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return null
|
||||
try {
|
||||
return new URL(trimmed).hostname.toLowerCase()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** 勾选分离且网站域、图片域主机名不同 → 保存后将启用双域名中间件隔离 */
|
||||
export function willActivateDomainSeparation(
|
||||
enabled: boolean,
|
||||
siteBaseUrl: string,
|
||||
imageBaseUrl: string
|
||||
): boolean {
|
||||
if (!enabled) return false
|
||||
const site = siteBaseUrl.trim()
|
||||
const image = imageBaseUrl.trim()
|
||||
if (!site || !image) return false
|
||||
const siteHost = hostnameFromBaseUrl(site)
|
||||
const imageHost = hostnameFromBaseUrl(image)
|
||||
return Boolean(siteHost && imageHost && siteHost !== imageHost)
|
||||
}
|
||||
+1
-1
@@ -10,7 +10,7 @@ services:
|
||||
- "6892:6892"
|
||||
# 首次访问 Web 引导创建管理员,无需预先配置环境变量
|
||||
# 可选:environment 中设置 API_UPLOAD_TOKEN、ALLOWED_REFERER_HOSTS 等
|
||||
# 运维:docker exec pichost reset-password | migrate [--apply]
|
||||
# 运维:docker exec pichost reset-password | migrate [--apply] | slider | clear-domains [--site]
|
||||
volumes:
|
||||
# 冒号左边改成 NAS 上的实际存图目录(飞牛示例:/vol1/1000/pichost-data)
|
||||
- ./data:/data
|
||||
@@ -9,4 +9,12 @@ if [ "$1" = "migrate" ]; then
|
||||
exec node /app/server/cli/migrate-to-single-images.mjs "${@:2}"
|
||||
fi
|
||||
|
||||
if [ "$1" = "slider" ]; then
|
||||
exec node /app/server/cli/slider.mjs "${@:2}"
|
||||
fi
|
||||
|
||||
if [ "$1" = "clear-domains" ]; then
|
||||
exec node /app/server/cli/clear-domains.mjs "${@:2}"
|
||||
fi
|
||||
|
||||
exec node /app/.output/server/index.mjs "$@"
|
||||
@@ -4,6 +4,31 @@ Version history and notable changes. The full log is also in the repo root [CHAN
|
||||
|
||||
---
|
||||
|
||||
## [1.2.8] — 2026-09-04
|
||||
|
||||
### Improved
|
||||
|
||||
- **Dual-domain**: confirm dialog on save/setup with lockout risks and `clear-domains` recovery; no longer blocks save based on current Host
|
||||
|
||||
### Added
|
||||
|
||||
- **Ops CLI**: `clear-domains` clears site/image URL settings from DB (`docker exec pichost clear-domains`)
|
||||
|
||||
### Docs
|
||||
|
||||
- FAQ and dual-domain / reverse-proxy guides updated with recovery steps
|
||||
|
||||
## [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
|
||||
|
||||
### Added
|
||||
|
||||
@@ -13,6 +13,7 @@ Configure PicHost via environment variables (Docker / `.env`) or the in-app **Se
|
||||
| Short image links | SQLite → env → default false | No |
|
||||
| Date-based paths | SQLite → env → default true | No |
|
||||
| Global auto-delete days | SQLite → env → default 0 | No |
|
||||
| Login verification | SQLite (Settings → Access control) | No |
|
||||
|
||||
## Variables
|
||||
|
||||
@@ -64,6 +65,18 @@ Delete images older than N days globally; `0` disables. Affects **new uploads af
|
||||
|
||||
Local dev only: `true` bypasses login. Never use in production.
|
||||
|
||||
### Login verification (Settings only)
|
||||
|
||||
Configure under **Settings → Access control** (stored in SQLite; no env vars):
|
||||
|
||||
| Method | Notes |
|
||||
| ------ | ----- |
|
||||
| **Local slider** (default) | No third-party service |
|
||||
| **Cloudflare Turnstile** | Site key + secret key |
|
||||
| **Cap** | API endpoint + secret |
|
||||
|
||||
Login and registration share the same method. If misconfigured: `docker exec pichost slider` (or `npm run slider`) resets to the slider.
|
||||
|
||||
### Storage backends (optional)
|
||||
|
||||
Admins usually add backends in **Storage** UI; env vars such as `STORAGE_BACKEND=s3` and `S3_*` are also supported. See [Storage](./storage.md).
|
||||
@@ -99,5 +112,6 @@ Restart the container after changing env vars.
|
||||
| Date-based paths | `STORAGE_USE_DATE_PATH` / `STORAGE_LAYOUT` |
|
||||
| WebP quality | `WEBP_QUALITY` |
|
||||
| Auto-delete | `AUTO_DELETE_DAYS` |
|
||||
| Login verification | (Settings → Access control only) |
|
||||
|
||||
Template: [`.env.example`](https://github.com/O96u/PicHost/blob/main/.env.example) in the repo root.
|
||||
@@ -69,10 +69,21 @@ This is independent of hyphenated or long domain names. Older versions did not b
|
||||
|
||||
1. Configure `server_name` only for the site and image hosts; add a **default server** to reject other Host values and bare IP access (see Nginx example above; complements app-layer blocking)
|
||||
2. With Cloudflare **orange-cloud** DNS, restrict the origin firewall to **[Cloudflare IP ranges](https://www.cloudflare.com/ips/)** so traffic cannot bypass the CDN
|
||||
3. Sign in only on the **site hostname** from Settings; do not mix `localhost`, IP, or unlisted domains
|
||||
4. Do **not** put Cloudflare Pages / Workers in front of PicHost as a **full-site reverse proxy** (`IMAGE_BASE_URL` may point at a CDN URL, but the app itself should run on Docker/VPS)
|
||||
5. If you must proxy at the edge, preserve the client **`Host` or `X-Forwarded-Host`** or host isolation will fail
|
||||
6. PicHost **cannot** run as-is on Pages/Workers (needs `node-server`, SQLite, `sharp`, local `data/`). Keep Docker/VPS; use orange-cloud DNS and optional R2 storage on CF
|
||||
3. **Before saving**, read the risk confirmation in Settings / setup; saving via IP, LAN, or a proxy that does not forward Host correctly may lock you out — see **Recovery when locked out** below
|
||||
4. Sign in only on the **site hostname** from Settings; do not mix `localhost`, IP, or unlisted domains
|
||||
5. Do **not** put Cloudflare Pages / Workers in front of PicHost as a **full-site reverse proxy** (`IMAGE_BASE_URL` may point at a CDN URL, but the app itself should run on Docker/VPS)
|
||||
6. If you must proxy at the edge, preserve the client **`Host` or `X-Forwarded-Host`** (Lucky/NPM “force hostname” must match PicHost settings) or isolation fails or you get locked out after save
|
||||
7. PicHost **cannot** run as-is on Pages/Workers (needs `node-server`, SQLite, `sharp`, local `data/`). Keep Docker/VPS; use orange-cloud DNS and optional R2 storage on CF
|
||||
|
||||
### Recovery when locked out
|
||||
|
||||
If IP / LAN access returns 404 after a bad save:
|
||||
|
||||
```bash
|
||||
docker exec pichost clear-domains
|
||||
```
|
||||
|
||||
Local: `npm run clear-domains`. Then reconfigure using the site hostname and verify proxy `Host` headers.
|
||||
|
||||
### Cloudflare orange cloud (incl. preferred edge IPs)
|
||||
|
||||
|
||||
@@ -19,6 +19,24 @@ Local: `npm run reset-password`. See [Quick start](./getting-started.md#forgot-p
|
||||
|
||||
Check the **Referer whitelist** (`ALLOWED_REFERER_HOSTS` or Settings). Referring sites must be listed; PicHost’s own hosts and dual-domain pair are allowed automatically.
|
||||
|
||||
## Admin 404 / locked out after dual-domain setup?
|
||||
|
||||
**Cause:** With dual-domain enabled, PicHost returns **404 for every request** whose Host is not the configured site or image hostname. Common triggers:
|
||||
|
||||
1. Saving dual-domain URLs while browsing via IP or an internal address
|
||||
2. Reverse proxy “force hostname” or wrong `Host` header so PicHost sees a different host than configured
|
||||
|
||||
**Recovery (when locked out):**
|
||||
|
||||
```bash
|
||||
docker exec pichost clear-domains
|
||||
# Site URL only: docker exec pichost clear-domains --site
|
||||
```
|
||||
|
||||
Local dev: `npm run clear-domains`. Then reopen admin via your previous IP/internal URL and reconfigure using the [dual-domain guide](./domain-separation.md#middleware-isolation-and-third-hosts).
|
||||
|
||||
**Prevention:** Read the risk confirmation when saving dual-domain settings; proxy must send `Host $host` and `X-Forwarded-Proto`. See [Dual-domain separation](./domain-separation.md#middleware-isolation-and-third-hosts).
|
||||
|
||||
## Why can admin load on pages.dev / workers.dev / IP with dual-domain?
|
||||
|
||||
**From v1.2.2:** when dual-domain is on, PicHost returns **404** for Host values **other than** the configured site and image hostnames (`localhost` / `127.0.0.1` exempt in development). See [Changelog](./changelog.md#1-2-2-2026-08-30).
|
||||
|
||||
@@ -39,11 +39,23 @@ See `docker-compose.yml` in the repository.
|
||||
|
||||
## Sign in
|
||||
|
||||
Management pages (upload, gallery, settings, etc.) require login. Enter username and password, **drag the slider to align with the gap**, then click **Sign in**.
|
||||
Management pages (upload, gallery, settings, etc.) require login. Enter username and password, complete the verification challenge, then click **Sign in**.
|
||||
|
||||
The default is a **local slider** (align the thumb with the gap). Admins can switch to **Cloudflare Turnstile** or **Cap** under **Settings → Access control**, with the matching site key / secret or API endpoint.
|
||||
|
||||

|
||||
|
||||
If registration is enabled, create an account from the register page. Legacy deployments may still use a secret key (see the on-screen hint).
|
||||
If registration is enabled, create an account from the register page (same verification). Legacy deployments may still use a secret key (see the on-screen hint).
|
||||
|
||||
### Locked out of verification
|
||||
|
||||
If Turnstile / Cap is misconfigured and you cannot sign in, reset to the local slider on the server:
|
||||
|
||||
```bash
|
||||
docker exec pichost slider
|
||||
```
|
||||
|
||||
Local dev: `npm run slider`
|
||||
|
||||
## Forgot password
|
||||
|
||||
@@ -58,6 +70,16 @@ A random password is printed. Change it after login.
|
||||
|
||||
Local dev: `npm run reset-password` or `npm run reset-password -- <username>`
|
||||
|
||||
## Dual-domain lockout
|
||||
|
||||
If dual-domain is enabled and IP / LAN access returns 404:
|
||||
|
||||
```bash
|
||||
docker exec pichost clear-domains
|
||||
```
|
||||
|
||||
Local: `npm run clear-domains`. See [FAQ — Admin 404 after dual-domain setup](./faq.md#admin-404--locked-out-after-dual-domain-setup).
|
||||
|
||||
## Upgrading to v1.2.0
|
||||
|
||||
If `data/` has legacy top-level folders next to `images` (e.g. `blog/`, `twikoo/`), read the [v1.2 migration guide](./migration.md) first.
|
||||
|
||||
@@ -28,6 +28,8 @@ Open `http://localhost:3000/setup` to create the admin account. Data directory d
|
||||
| `npm run typecheck` | Type check |
|
||||
| `npm test` | Unit tests |
|
||||
| `npm run reset-password` | Reset password (optional username) |
|
||||
| `npm run slider` | Reset login verification to local slider |
|
||||
| `npm run clear-domains` | Clear site/image URL settings from DB (recover from dual-domain lockout) |
|
||||
| `npm run migrate` | Preview legacy dir migration (`-- --apply` to run) |
|
||||
|
||||
## Dev-only variables
|
||||
|
||||
@@ -57,12 +57,14 @@ See [Cloudflare deployment](./cloudflare-deployment.md).
|
||||
|
||||
## Origin hardening (dual-domain)
|
||||
|
||||
PicHost middleware does **not** reject unlisted Host values. Complement with:
|
||||
Complement PicHost middleware with:
|
||||
|
||||
1. **`server_name` allowlist** — site + image hosts only; `default_server` blocks bare IP and unknown Host
|
||||
2. **Do not expose 6892 on the public Internet** — only local reverse proxy; public 80/443 only
|
||||
3. **Orange cloud** — origin firewall limited to Cloudflare IP ranges
|
||||
|
||||
**From v1.2.2**, when dual-domain is active PicHost also returns **404 at the app layer** for Host values other than the configured site and image names (complementing, not replacing, a default server block). If misconfiguration locks you out, run `docker exec pichost clear-domains` — see [FAQ](./faq.md#admin-404--locked-out-after-dual-domain-setup).
|
||||
|
||||
## Caddy
|
||||
|
||||
```caddy
|
||||
|
||||
@@ -12,6 +12,7 @@ PicHost uses role-based access control (RBAC): **admin** and **user**. Enforceme
|
||||
| Storage management (`/storage`) | ✓ | — |
|
||||
| System settings (`/settings`) | ✓ | — |
|
||||
| Allow registration | ✓ | — |
|
||||
| Login verification (slider / Turnstile / Cap) | ✓ | — |
|
||||
| Global API token | ✓ | — |
|
||||
| Personal API token | ✓ | ✓ |
|
||||
| Gallery stats overview (incl. user count) | ✓ | partial |
|
||||
@@ -34,7 +35,8 @@ Regular users see **API**, **Gallery** in the nav; user menu: **Change password*
|
||||
|
||||
- By default only admins create users; **allow registration** is an admin setting
|
||||
- Passwords hashed with scrypt
|
||||
- Web login requires a slider captcha (align the thumb with the gap)
|
||||
- Web login/register requires verification: local slider by default; admins can switch to Turnstile or Cap under **Settings → Access control**
|
||||
- If verification is misconfigured: `docker exec pichost slider` resets to the local slider
|
||||
- Login rate limiting
|
||||
|
||||
## Upload preferences & auto-delete
|
||||
|
||||
@@ -4,6 +4,31 @@ PicHost 版本历史与重要变更说明。完整记录亦见仓库根目录 [C
|
||||
|
||||
---
|
||||
|
||||
## [1.2.8] — 2026-09-04
|
||||
|
||||
### 改进
|
||||
|
||||
- **双域名**:保存 / 初始化时弹出风险确认(含锁死说明与 `clear-domains` 恢复方式),不再强制校验当前 Host
|
||||
|
||||
### 新增
|
||||
|
||||
- **运维 CLI**:`clear-domains` 清除数据库域名配置(`docker exec pichost clear-domains`)
|
||||
|
||||
### 文档
|
||||
|
||||
- FAQ 与双域名、反代文档补充恢复步骤
|
||||
|
||||
## [1.2.7] — 2026-09-03
|
||||
|
||||
### 新增
|
||||
|
||||
- **登录验证**:登录/注册支持本地滑块、Cloudflare Turnstile、Cap;在「设置 → 访问控制」配置
|
||||
- **运维 CLI**:`slider` 将验证方式重置为本地滑块(`docker exec pichost slider`)
|
||||
|
||||
### 改进
|
||||
|
||||
- 登出后登录页验证码可正常重新加载
|
||||
|
||||
## [1.2.6] — 2026-09-01
|
||||
|
||||
### 新增
|
||||
|
||||
@@ -11,6 +11,7 @@ PicHost 支持通过环境变量(Docker / `.env`)与后台 **设置** 页配
|
||||
| 简短图片链接 | SQLite → 环境变量 → 默认 false | 否 |
|
||||
| 按年/月分组存储 | SQLite → 环境变量 → 默认 true | 否 |
|
||||
| 全局自动删除天数 | SQLite → 环境变量 → 默认 0 | 否 |
|
||||
| 登录人机验证 | SQLite(设置 → 访问控制) | 否 |
|
||||
|
||||
## 变量说明
|
||||
|
||||
@@ -62,6 +63,18 @@ PicHost 支持通过环境变量(Docker / `.env`)与后台 **设置** 页配
|
||||
|
||||
仅本地开发:`true` 绕过登录验证。生产环境勿用。
|
||||
|
||||
### 登录人机验证(仅后台设置)
|
||||
|
||||
在 **设置 → 访问控制** 配置,存入 SQLite,无对应环境变量:
|
||||
|
||||
| 方式 | 说明 |
|
||||
| ---- | ---- |
|
||||
| **本地滑块**(默认) | 无需第三方服务 |
|
||||
| **Cloudflare Turnstile** | 填写 Site Key 与 Secret Key |
|
||||
| **Cap** | 填写 API Endpoint 与 Secret |
|
||||
|
||||
登录与注册共用同一验证方式。若第三方配置错误导致无法登录,执行 `docker exec pichost slider`(或 `npm run slider`)恢复为本地滑块。
|
||||
|
||||
### 存储后端(可选)
|
||||
|
||||
管理员通常在 **存储** 页添加后端;也可用环境变量引导(如 `STORAGE_BACKEND=s3` 与 `S3_*`)。详见 [存储](./storage.md)。
|
||||
@@ -97,5 +110,6 @@ services:
|
||||
| 按年/月分组 | `STORAGE_USE_DATE_PATH` / `STORAGE_LAYOUT` |
|
||||
| WebP 质量 | `WEBP_QUALITY` |
|
||||
| 自动删除 | `AUTO_DELETE_DAYS` |
|
||||
| 登录验证 | (仅后台 **设置 → 访问控制**) |
|
||||
|
||||
完整模板见仓库根目录 [`.env.example`](https://github.com/O96u/PicHost/blob/main/.env.example)。
|
||||
@@ -72,10 +72,21 @@ PicHost 根据请求 **Host**(反代会转发为 `X-Forwarded-Host`,应用
|
||||
|
||||
1. 反代层仅为网站域、图片域配置 `server_name`,并增加 **default server** 拒绝其它 Host 与裸 IP(见上方 Nginx 示例,与应用层拦截互补)
|
||||
2. 使用 Cloudflare **橙云** 时,源站防火墙 **仅放行 [Cloudflare IP 段](https://www.cloudflare.com/ips/)**,避免绕过 CDN 直连源站
|
||||
3. **仅使用** 设置中的网站域名登录后台,不要用 `localhost`、IP 或未配置的域名混用
|
||||
4. **不要** 用 Cloudflare Pages / Workers 对 PicHost **整站反代**(会多出 `pages.dev` / `workers.dev` 等第三入口;`IMAGE_BASE_URL` 可填 CDN 公网地址,但应用本体应 Docker/VPS 部署)
|
||||
5. 若必须在边缘做反代,须保留客户端 **`Host` 或 `X-Forwarded-Host`**,否则 Host 隔离会失效
|
||||
6. PicHost **不能** 作为 Node 应用直接部署到 Pages/Workers(依赖 `node-server`、SQLite、`sharp` 与本地 `data/`);应用请继续 Docker/VPS 部署,CF 侧用橙云 DNS + 可选 R2 存储即可
|
||||
3. **保存前**阅读设置 / 初始化页的风险确认;若通过 IP、内网或未正确传递 Host 的反代保存,可能锁死后台;恢复见下方「已锁死时的恢复」
|
||||
4. **仅使用** 设置中的网站域名登录后台,不要用 `localhost`、IP 或未配置的域名混用
|
||||
5. **不要** 用 Cloudflare Pages / Workers 对 PicHost **整站反代**(会多出 `pages.dev` / `workers.dev` 等第三入口;`IMAGE_BASE_URL` 可填 CDN 公网地址,但应用本体应 Docker/VPS 部署)
|
||||
6. 若必须在边缘做反代,须保留客户端 **`Host` 或 `X-Forwarded-Host`**(Lucky/NPM「强制域名」须与 PicHost 配置一致),否则 Host 隔离会失效或保存后被锁死
|
||||
7. PicHost **不能** 作为 Node 应用直接部署到 Pages/Workers(依赖 `node-server`、SQLite、`sharp` 与本地 `data/`);应用请继续 Docker/VPS 部署,CF 侧用橙云 DNS + 可选 R2 存储即可
|
||||
|
||||
### 已锁死时的恢复
|
||||
|
||||
若已误保存导致 IP / 内网地址全站 404:
|
||||
|
||||
```bash
|
||||
docker exec pichost clear-domains
|
||||
```
|
||||
|
||||
本地:`npm run clear-domains`。清除后请用网站域名重新配置,并确认反代 `Host` 头正确。
|
||||
|
||||
### Cloudflare 橙云(含 DNS 优选)
|
||||
|
||||
|
||||
@@ -20,6 +20,24 @@ docker exec pichost reset-password 用户名
|
||||
|
||||
检查 **防盗链白名单**(`ALLOWED_REFERER_HOSTS` 或设置页)。引用图片的站点域名需在白名单中;PicHost 自身域名与双域名场景下的网站域、图片域会自动放行。
|
||||
|
||||
## 双域名配置后后台 404 / 进不去?
|
||||
|
||||
**原因:** 启用双域名后,PicHost 会对**未配置的网站域、图片域之外**的 Host 全站返回 404。常见触发方式:
|
||||
|
||||
1. 通过 IP 或内网地址打开设置,保存了公网网站域 / 图片域
|
||||
2. 反代「强制域名」或 Host 头配置错误,PicHost 收到的 Host 与设置中的网站域不一致
|
||||
|
||||
**恢复(已锁死时):**
|
||||
|
||||
```bash
|
||||
docker exec pichost clear-domains
|
||||
# 仅清除网站域:docker exec pichost clear-domains --site
|
||||
```
|
||||
|
||||
本地开发:`npm run clear-domains`。执行后用原来的 IP / 内网地址刷新即可重新进入后台,再按文档正确配置双域名。
|
||||
|
||||
**预防:** 保存双域名时注意确认框中的风险提示;反代须 `proxy_set_header Host $host;` 并保留 `X-Forwarded-Proto`。详见 [双域名分离](./domain-separation.md#中间件隔离与第三-host)。
|
||||
|
||||
## 双域名下为什么用 pages.dev / workers.dev / IP 也能进后台?
|
||||
|
||||
**v1.2.2 起:** 双域名开启时,PicHost 中间件会对**未配置的网站域、图片域之外**的 Host 返回 **404**(开发环境 `localhost` / `127.0.0.1` 例外)。详见 [更新日志](./changelog.md#1-2-2-2026-08-30)。
|
||||
|
||||
@@ -39,11 +39,23 @@ docker compose up -d
|
||||
|
||||
## 登录
|
||||
|
||||
访问上传、图库、设置等管理页面前需登录。在登录卡片中填写用户名与密码,**拖动滑块对齐缺口**完成验证后点击 **登录**。
|
||||
访问上传、图库、设置等管理页面前需登录。在登录卡片中填写用户名与密码,完成人机验证后点击 **登录**。
|
||||
|
||||
默认使用**本地滑块**(拖动滑块对齐缺口)。管理员可在 **设置 → 访问控制** 切换为 **Cloudflare Turnstile** 或 **Cap**,并填写对应 Site Key / Secret 或 API 端点。
|
||||
|
||||

|
||||
|
||||
若管理员开启了开放注册,可前往注册页创建账号。遗留部署仍可能使用密钥登录(见界面提示)。
|
||||
若管理员开启了开放注册,可前往注册页创建账号(同样需完成人机验证)。遗留部署仍可能使用密钥登录(见界面提示)。
|
||||
|
||||
### 验证方式被锁死时
|
||||
|
||||
若 Turnstile / Cap 配置错误导致无法登录,可在服务器上重置为本地滑块:
|
||||
|
||||
```bash
|
||||
docker exec pichost slider
|
||||
```
|
||||
|
||||
本地开发:`npm run slider`
|
||||
|
||||
## 忘记密码
|
||||
|
||||
@@ -61,6 +73,16 @@ docker exec pichost reset-password 用户名
|
||||
|
||||
本地开发:`npm run reset-password` 或 `npm run reset-password -- 用户名`
|
||||
|
||||
## 双域名配置锁死
|
||||
|
||||
若启用双域名后通过 IP / 内网地址无法进入后台:
|
||||
|
||||
```bash
|
||||
docker exec pichost clear-domains
|
||||
```
|
||||
|
||||
本地:`npm run clear-domains`。详见 [常见问题 — 双域名配置后后台 404](./faq.md#双域名配置后后台-404-进不去)。
|
||||
|
||||
## 升级到 v1.2.0
|
||||
|
||||
若 `data/` 下存在与 `images` 并列的遗留目录(如 `blog/`、`twikoo/`),升级前请阅读 [v1.2 迁移指南](./migration.md)。
|
||||
|
||||
@@ -28,6 +28,8 @@ npm run dev
|
||||
| `npm run typecheck` | 类型检查 |
|
||||
| `npm test` | 单元测试 |
|
||||
| `npm run reset-password` | 重置密码(可选用户名) |
|
||||
| `npm run slider` | 将登录验证重置为本地滑块 |
|
||||
| `npm run clear-domains` | 清除数据库中的网站域 / 图片域配置(双域名锁死时恢复) |
|
||||
| `npm run migrate` | 预览遗留目录迁移(加 `-- --apply` 执行) |
|
||||
|
||||
## 开发专用环境变量
|
||||
|
||||
@@ -63,7 +63,7 @@ IMAGE_BASE_URL=https://pic.example.com
|
||||
2. **禁止公网暴露 6892** — 仅本机或内网反代访问;公网只开 80/443
|
||||
3. **橙云时限制源站来源 IP** — 仅 Cloudflare 回源 IP 段
|
||||
|
||||
双域名场景下 PicHost **不会** 拒绝未配置的 Host;上述措施由运维层补齐。
|
||||
**v1.2.2 起**,双域名开启时 PicHost 应用层也会对**未配置的网站域、图片域之外**的 Host 全站返回 404(与反代 default server 互补,而非替代)。误配置导致锁死时执行 `docker exec pichost clear-domains` 恢复,见 [常见问题](./faq.md#双域名配置后后台-404-进不去)。
|
||||
|
||||
## Caddy 示例
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ PicHost 使用基于角色的访问控制(RBAC):**管理员(admin)**
|
||||
| 存储后端管理(`/storage`) | ✓ | — |
|
||||
| 系统设置(`/settings`) | ✓ | — |
|
||||
| 开放注册开关 | ✓ | — |
|
||||
| 登录人机验证方式(滑块 / Turnstile / Cap) | ✓ | — |
|
||||
| 全局 API Token 管理 | ✓ | — |
|
||||
| 个人 API Token | ✓ | ✓ |
|
||||
| 图库统计概览(含注册用户数) | ✓ | 部分 |
|
||||
@@ -34,7 +35,8 @@ PicHost 使用基于角色的访问控制(RBAC):**管理员(admin)**
|
||||
|
||||
- 默认仅管理员可创建用户;管理员可在设置中 **开放注册**
|
||||
- 密码使用 scrypt 哈希存储
|
||||
- Web 登录需先完成滑动拼图验证(拖动滑块对齐缺口)
|
||||
- Web 登录/注册需完成人机验证:默认本地滑块;管理员可在 **设置 → 访问控制** 切换 Turnstile 或 Cap
|
||||
- 验证配置错误无法登录时:`docker exec pichost slider` 重置为本地滑块
|
||||
- 登录失败有速率限制
|
||||
|
||||
## 上传偏好与自动删除
|
||||
|
||||
@@ -67,6 +67,11 @@
|
||||
"loginFailed": "Sign-in failed. Check username and password",
|
||||
"captchaHint": "Drag the slider to the slot",
|
||||
"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",
|
||||
"setupFailed": "Setup failed. Check your input",
|
||||
"migrateFailed": "Migration failed. Check your input",
|
||||
@@ -79,6 +84,11 @@
|
||||
"invalidPassword": "Password must be at least 8 characters"
|
||||
}
|
||||
},
|
||||
"domainSeparationConfirm": {
|
||||
"title": "Save dual-domain settings?",
|
||||
"description": "After saving, only the site URL can serve the admin UI and API, and only the image URL can serve public image links. Access via IP, an unlisted Host, or a reverse proxy that does not forward Host correctly may lock you out of the admin UI.\n\nIf locked out, run docker exec pichost clear-domains (local dev: npm run clear-domains), or disable domain separation in Settings when you can access it again.\n\nSave anyway?",
|
||||
"confirm": "Save anyway"
|
||||
},
|
||||
"setup": {
|
||||
"migrateTitle": "Migrate to username/password",
|
||||
"initTitle": "First run — create admin account",
|
||||
@@ -212,6 +222,22 @@
|
||||
"refererScenarioDesc": "Allow your main site and static site to embed images",
|
||||
"refererEnabled": "Enabled",
|
||||
"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",
|
||||
"systemInfoHint": "Current version and updates",
|
||||
"uploadConvenience": "Upload convenience",
|
||||
|
||||
@@ -67,6 +67,11 @@
|
||||
"loginFailed": "登录失败,请检查用户名和密码",
|
||||
"captchaHint": "拖动滑块对齐缺口",
|
||||
"captchaSuccess": "验证通过",
|
||||
"verificationMisconfigured": "人机验证未正确配置,请联系管理员",
|
||||
"capInitialState": "验证你是人类",
|
||||
"capVerifying": "验证中…",
|
||||
"capSolved": "验证通过",
|
||||
"capError": "验证失败",
|
||||
"registerFailed": "注册失败,请检查输入",
|
||||
"setupFailed": "初始化失败,请检查输入",
|
||||
"migrateFailed": "迁移失败,请检查输入",
|
||||
@@ -79,6 +84,11 @@
|
||||
"invalidPassword": "密码至少 8 位"
|
||||
}
|
||||
},
|
||||
"domainSeparationConfirm": {
|
||||
"title": "确认保存双域名配置?",
|
||||
"description": "保存后,仅网站域名可访问管理后台与 API,仅图片域名可提供图片直链。若通过 IP、未配置的 Host,或反代未正确传递 Host 访问,可能无法打开后台。\n\n若发生锁死,可执行 docker exec pichost clear-domains(本地开发:npm run clear-domains)清除域名配置,或在能访问时于设置中关闭「域名分离」。\n\n确定仍要保存吗?",
|
||||
"confirm": "确认保存"
|
||||
},
|
||||
"setup": {
|
||||
"migrateTitle": "迁移到账号密码登录",
|
||||
"initTitle": "首次使用,创建管理员账号",
|
||||
@@ -212,6 +222,22 @@
|
||||
"refererScenarioDesc": "允许主站与静态站点引用图片",
|
||||
"refererEnabled": "已开启",
|
||||
"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": "系统信息",
|
||||
"systemInfoHint": "当前系统版本与更新",
|
||||
"uploadConvenience": "上传便捷功能",
|
||||
|
||||
@@ -21,6 +21,12 @@ export default defineNuxtConfig({
|
||||
|
||||
css: ['~/assets/css/main.css'],
|
||||
|
||||
vue: {
|
||||
compilerOptions: {
|
||||
isCustomElement: tag => tag.startsWith('cap-')
|
||||
}
|
||||
},
|
||||
|
||||
colorMode: {
|
||||
preference: 'system',
|
||||
fallback: 'light'
|
||||
|
||||
Generated
+9
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "pichost",
|
||||
"version": "1.2.6",
|
||||
"version": "1.2.8",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "pichost",
|
||||
"version": "1.2.6",
|
||||
"version": "1.2.8",
|
||||
"hasInstallScript": true,
|
||||
"license": "GPL-3.0-only",
|
||||
"dependencies": {
|
||||
@@ -14,6 +14,7 @@
|
||||
"@iconify-json/lucide": "^1.2.114",
|
||||
"@nuxt/ui": "^4.9.0",
|
||||
"@nuxtjs/i18n": "^10.6.0",
|
||||
"cap-widget": "^0.1.57",
|
||||
"nuxt": "^4.4.8",
|
||||
"sharp": "^0.35.3",
|
||||
"tailwindcss": "^4.3.1"
|
||||
@@ -10704,6 +10705,12 @@
|
||||
],
|
||||
"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": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/ccount/-/ccount-2.0.1.tgz",
|
||||
|
||||
+4
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pichost",
|
||||
"version": "1.2.6",
|
||||
"version": "1.2.8",
|
||||
"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",
|
||||
"repository": {
|
||||
@@ -35,6 +35,8 @@
|
||||
"start": "node .output/server/index.mjs",
|
||||
"reset-password": "node server/cli/reset-password.mjs",
|
||||
"migrate": "node server/cli/migrate-to-single-images.mjs",
|
||||
"slider": "node server/cli/slider.mjs",
|
||||
"clear-domains": "node server/cli/clear-domains.mjs",
|
||||
"docs:prepare": "node docs-site/copy-screenshots.mjs",
|
||||
"docs:dev": "npm run docs:prepare && vitepress dev docs-site",
|
||||
"docs:build": "npm run docs:prepare && vitepress build docs-site",
|
||||
@@ -45,6 +47,7 @@
|
||||
"@iconify-json/lucide": "^1.2.114",
|
||||
"@nuxt/ui": "^4.9.0",
|
||||
"@nuxtjs/i18n": "^10.6.0",
|
||||
"cap-widget": "^0.1.57",
|
||||
"nuxt": "^4.4.8",
|
||||
"sharp": "^0.35.3",
|
||||
"tailwindcss": "^4.3.1"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { isAllowRegistration } from '../../utils/db'
|
||||
import { isAdminSecretConfigured, isApiUploadTokenConfigured } from '../../utils/env'
|
||||
import { getLoginVerificationPublicConfig, isAdminSecretConfigured, isApiUploadTokenConfigured } from '../../utils/env'
|
||||
import { isInitialized } from '../../utils/auth'
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
@@ -8,6 +8,7 @@ export default defineEventHandler((event) => {
|
||||
allowRegistration: isAllowRegistration(),
|
||||
legacyMode: !isInitialized() && isAdminSecretConfigured(event),
|
||||
adminSecretConfigured: isAdminSecretConfigured(event),
|
||||
apiUploadTokenConfigured: isApiUploadTokenConfigured(event)
|
||||
apiUploadTokenConfigured: isApiUploadTokenConfigured(event),
|
||||
loginVerification: getLoginVerificationPublicConfig(event)
|
||||
}
|
||||
})
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { H3Event } from 'h3'
|
||||
import {
|
||||
checkAdminSecret,
|
||||
setLegacyAuthCookie
|
||||
@@ -7,19 +6,18 @@ import { createApiError } from '../../utils/api-error'
|
||||
import {
|
||||
createSession,
|
||||
isInitialized,
|
||||
rowToAuthUser,
|
||||
verifyPassword
|
||||
} from '../../utils/auth'
|
||||
import { findUserByUsername } from '../../utils/db'
|
||||
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'
|
||||
|
||||
interface LoginBody {
|
||||
interface LoginBody extends VerificationBody {
|
||||
username?: string
|
||||
password?: string
|
||||
secret?: string
|
||||
captchaId?: string
|
||||
captchaPosition?: 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) => {
|
||||
const ip = clientIp(event)
|
||||
checkRateLimit(ip)
|
||||
|
||||
const body = await readBody<LoginBody>(event)
|
||||
verifyCaptchaOrFail(event, body ?? {})
|
||||
await verifyLoginVerification(event, body ?? {})
|
||||
const initialized = isInitialized()
|
||||
|
||||
if (!initialized) {
|
||||
@@ -94,6 +84,6 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
return {
|
||||
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 {
|
||||
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
|
||||
} from '../../utils/auth'
|
||||
import { isAllowRegistration } from '../../utils/db'
|
||||
import { verifyLoginVerification, type VerificationBody } from '../../utils/login-verification'
|
||||
import { clientIp, logInfo } from '../../utils/logger'
|
||||
|
||||
interface RegisterBody {
|
||||
interface RegisterBody extends VerificationBody {
|
||||
username?: string
|
||||
password?: string
|
||||
}
|
||||
@@ -22,6 +23,8 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
|
||||
const body = await readBody<RegisterBody>(event)
|
||||
await verifyLoginVerification(event, body ?? {})
|
||||
|
||||
const username = body?.username?.trim() ?? ''
|
||||
const password = body?.password ?? ''
|
||||
|
||||
@@ -38,7 +41,11 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
return {
|
||||
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) {
|
||||
if (error instanceof Error) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { isAllowRegistration } from '../../utils/db'
|
||||
import { isAdminSecretConfigured } from '../../utils/env'
|
||||
import { getLoginVerificationPublicConfig, isAdminSecretConfigured } from '../../utils/env'
|
||||
import {
|
||||
getCurrentUser,
|
||||
isInitialized
|
||||
@@ -17,8 +17,13 @@ export default defineEventHandler(async (event) => {
|
||||
allowRegistration: isAllowRegistration(),
|
||||
legacyMode,
|
||||
needsMigration,
|
||||
loginVerification: getLoginVerificationPublicConfig(event),
|
||||
user: user
|
||||
? { id: user.id, username: user.username, role: user.role }
|
||||
? {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
role: user.role
|
||||
}
|
||||
: null
|
||||
}
|
||||
})
|
||||
@@ -9,6 +9,11 @@ import {
|
||||
SETTINGS_IMAGE_BASE_URL,
|
||||
SETTINGS_SITE_BASE_URL,
|
||||
SETTINGS_WEBP_QUALITY,
|
||||
SETTINGS_LOGIN_VERIFICATION_METHOD,
|
||||
SETTINGS_TURNSTILE_SITE_KEY,
|
||||
SETTINGS_TURNSTILE_SECRET_KEY,
|
||||
SETTINGS_CAP_API_ENDPOINT,
|
||||
SETTINGS_CAP_SECRET,
|
||||
setAllowRegistration
|
||||
} from '../../utils/db'
|
||||
import {
|
||||
@@ -16,6 +21,8 @@ import {
|
||||
isValidImageBaseUrl,
|
||||
isValidRefererHost,
|
||||
isValidSiteBaseUrl,
|
||||
normalizeCapApiEndpoint,
|
||||
parseLoginVerificationMethod,
|
||||
MAX_AUTO_DELETE_DAYS,
|
||||
normalizeImageBaseUrl,
|
||||
normalizeRefererHosts,
|
||||
@@ -23,6 +30,7 @@ import {
|
||||
parseAutoDeleteDays,
|
||||
parseWebpQuality,
|
||||
setGlobalAutoDeletePolicy,
|
||||
validateLoginVerificationSettings,
|
||||
validateSettingsDomainPatch
|
||||
} from '../../utils/env'
|
||||
|
||||
@@ -36,6 +44,11 @@ interface SettingsPatchBody {
|
||||
storageUseDatePath?: unknown
|
||||
autoDeleteDays?: unknown
|
||||
allowRegistration?: unknown
|
||||
loginVerificationMethod?: unknown
|
||||
turnstileSiteKey?: unknown
|
||||
turnstileSecretKey?: unknown
|
||||
capApiEndpoint?: unknown
|
||||
capSecret?: unknown
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
@@ -161,5 +174,79 @@ export default defineEventHandler(async (event) => {
|
||||
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)
|
||||
})
|
||||
@@ -41,7 +41,7 @@ export default defineEventHandler(async (event) => {
|
||||
const userFilter = await getImageUserFilter(event)
|
||||
|
||||
try {
|
||||
if (user.role === 'admin' && userFilter === 'admin') {
|
||||
if (userFilter === 'admin') {
|
||||
const activity = getActivityStats()
|
||||
const [storedCount, byFolder, storageUsage] = await Promise.all([
|
||||
countImages(),
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
|
||||
const SITE_KEY = 'site_base_url'
|
||||
const IMAGE_KEY = 'image_base_url'
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
const siteOnly = args.includes('--site')
|
||||
const imageOnly = args.includes('--image')
|
||||
|
||||
if (args.some(arg => arg.startsWith('--') && arg !== '--site' && arg !== '--image')) {
|
||||
console.error('Usage: clear-domains [--site] [--image]')
|
||||
console.error(' (no flags) Clear both site and image URLs from settings')
|
||||
console.error(' --site Clear site URL only')
|
||||
console.error(' --image Clear image URL only')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (siteOnly && imageOnly) {
|
||||
console.error('Usage: specify at most one of --site or --image')
|
||||
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'))
|
||||
|
||||
function readSetting(key) {
|
||||
const row = db.prepare(`
|
||||
SELECT value
|
||||
FROM settings
|
||||
WHERE key = ?
|
||||
`).get(key)
|
||||
return row?.value ?? null
|
||||
}
|
||||
|
||||
const beforeSite = readSetting(SITE_KEY)
|
||||
const beforeImage = readSetting(IMAGE_KEY)
|
||||
|
||||
const keysToClear = siteOnly
|
||||
? [SITE_KEY]
|
||||
: imageOnly
|
||||
? [IMAGE_KEY]
|
||||
: [SITE_KEY, IMAGE_KEY]
|
||||
|
||||
for (const key of keysToClear) {
|
||||
db.prepare('DELETE FROM settings WHERE key = ?').run(key)
|
||||
}
|
||||
|
||||
db.close()
|
||||
|
||||
console.log('[PicHost] Domain settings cleared from database.')
|
||||
if (keysToClear.includes(SITE_KEY)) {
|
||||
console.log(` site_base_url: ${beforeSite ?? '(unset)'} → (removed)`)
|
||||
}
|
||||
if (keysToClear.includes(IMAGE_KEY)) {
|
||||
console.log(` image_base_url: ${beforeImage ?? '(unset)'} → (removed)`)
|
||||
}
|
||||
console.log('[PicHost] Restart is not required. Refresh the admin page or use your previous access URL.')
|
||||
@@ -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)
|
||||
return user.role === 'admin' ? 'admin' : user.id
|
||||
if (user.role === 'admin') {
|
||||
return 'admin'
|
||||
}
|
||||
return user.id
|
||||
}
|
||||
|
||||
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_ENABLED_AT = 'auto_delete_enabled_at'
|
||||
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 {
|
||||
return getSetting(SETTINGS_ALLOW_REGISTRATION) === 'true'
|
||||
|
||||
@@ -11,6 +11,11 @@ import {
|
||||
SETTINGS_IMAGE_BASE_URL,
|
||||
SETTINGS_SITE_BASE_URL,
|
||||
SETTINGS_WEBP_QUALITY,
|
||||
SETTINGS_LOGIN_VERIFICATION_METHOD,
|
||||
SETTINGS_TURNSTILE_SITE_KEY,
|
||||
SETTINGS_TURNSTILE_SECRET_KEY,
|
||||
SETTINGS_CAP_API_ENDPOINT,
|
||||
SETTINGS_CAP_SECRET,
|
||||
isAllowRegistration
|
||||
} from './db'
|
||||
|
||||
@@ -257,6 +262,93 @@ export function getApiUploadToken(event: H3Event): string {
|
||||
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 type WebpQualitySource = 'env' | 'db' | 'default'
|
||||
@@ -596,6 +688,11 @@ export function getSettingsPayload(event: H3Event) {
|
||||
autoDeleteDaysSource: getAutoDeleteDaysSource(),
|
||||
autoDeleteEnvFallback: parseAutoDeleteDays(getAutoDeleteDaysFromEnv()) ?? 0,
|
||||
allowRegistration: isAllowRegistration(),
|
||||
loginVerificationMethod: getLoginVerificationMethod(event),
|
||||
turnstileSiteKey: getTurnstileSiteKeyConfigured(event),
|
||||
turnstileSecretKey: getTurnstileSecretKey(event),
|
||||
capApiEndpoint: getCapApiEndpointConfigured(event),
|
||||
capSecret: getCapSecret(event),
|
||||
appVersion,
|
||||
env: {
|
||||
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