Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f17d79b236 | ||
|
|
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,49 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.3.0] - 2026-09-07
|
||||
|
||||
### 新增
|
||||
|
||||
- **站点备份**:存储页导出 `.phost.tar.gz`(含 `pichost.db` 与全部图片),支持下载与任务进度
|
||||
- **站点恢复**:存储页或 setup 未初始化时从备份包恢复,支持预检与跳过/覆盖冲突
|
||||
- **跨后端同步**:在已配置存储后端之间拷贝图片并更新索引;后端卡片快捷「迁入 / 迁出」
|
||||
- **任务中心**:`backup_jobs` 记录最近导出/恢复/同步任务,失败可重试;列表分页
|
||||
- 运维 CLI:`backup-export`、`backup-restore`、`storage-sync`(`docker exec pichost …`)
|
||||
|
||||
### 移除
|
||||
|
||||
- 遗留目录迁移 CLI `migrate` / `migrate-to-single-images.mjs`(由启动索引同步与整站备份恢复替代)
|
||||
|
||||
### 文档
|
||||
|
||||
- [存储](./docs-site/guide/storage.md) 补充备份与迁移说明;更新截图;移除 v1.2 迁移专页
|
||||
|
||||
## [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
|
||||
|
||||
### 新增
|
||||
@@ -94,7 +137,6 @@
|
||||
- CLI 迁移工具:`docker exec pichost migrate` / `migrate --apply`(`server/cli/migrate-to-single-images.mjs`)
|
||||
- 扫描 `data/` 下除 `images` 外所有顶层目录中的图片,输出 `data/mapping.json`
|
||||
- 启动时自动同步图片索引(扫描磁盘、归一化遗留 key、清理孤儿记录),并打印同步日志
|
||||
- 迁移文档:[文档站 · v1.2 迁移](https://o96u.github.io/PicHost/guide/migration)
|
||||
|
||||
### 改进
|
||||
|
||||
|
||||
@@ -23,8 +23,16 @@ ENV NODE_ENV=production \
|
||||
DATA_DIR=/data
|
||||
|
||||
COPY --from=build /app/.output ./.output
|
||||
COPY --from=build /app/server/utils ./server/utils
|
||||
COPY --from=build /app/node_modules ./node_modules
|
||||
COPY --from=build /app/package.json ./package.json
|
||||
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 server/cli/backup-cli-loader.mjs ./server/cli/backup-cli-loader.mjs
|
||||
COPY server/cli/backup-export.mjs ./server/cli/backup-export.mjs
|
||||
COPY server/cli/backup-restore.mjs ./server/cli/backup-restore.mjs
|
||||
COPY server/cli/storage-sync.mjs ./server/cli/storage-sync.mjs
|
||||
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
||||
RUN chmod +x /docker-entrypoint.sh
|
||||
|
||||
|
||||
@@ -31,20 +31,25 @@
|
||||
|
||||
**main** has no public demo. **cloudflare** branch live demo: [pic.roven.cc](https://pic.roven.cc)
|
||||
|
||||
| API | Gallery |
|
||||
| :--: | :-----: |
|
||||
| API | Gallery (detail) |
|
||||
| :--: | :--------------: |
|
||||
|  |  |
|
||||
|
||||
| Storage | Settings |
|
||||
| :-----: | :-------: |
|
||||
|  |  |
|
||||
|
||||
| First-time setup | Restore from backup |
|
||||
| :--------------: | :-----------------: |
|
||||
|  |  |
|
||||
|
||||
## 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
|
||||
- **Gallery** — browse, search, filter by storage/source, grid/list views, batch delete; stats overview and source breakdown; image detail modal with link formats
|
||||
- **Backup & migration** — full-site export/restore (`.phost.tar.gz`), cross-backend sync; restore from backup on uninitialized `/setup`
|
||||
- **API & Twikoo** — global / per-user tokens; `POST /api/index.php` compatible
|
||||
- **Zero-config Docker** — first-run web wizard, no secrets upfront
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
|
||||
**main** 分支暂无公网演示;**cloudflare** 分支在线体验:[pic.roven.cc](https://pic.roven.cc)
|
||||
|
||||
| API | 图库 |
|
||||
| API | 图库(详情) |
|
||||
| :--: | :--: |
|
||||
|  |  |
|
||||
|
||||
@@ -39,12 +39,17 @@
|
||||
| :--: | :--: |
|
||||
|  |  |
|
||||
|
||||
| 首次设置 | 从备份恢复 |
|
||||
| :--: | :--: |
|
||||
|  |  |
|
||||
|
||||
## 特性
|
||||
|
||||
- **拖拽 / 点击 / Ctrl+V 粘贴**上传;服务端 WebP 压缩、Referer 防盗链
|
||||
- **多用户**:账号密码登录(滑动验证)、可选开放注册;普通用户仅见自己的图片
|
||||
- **多用户**:账号密码登录(滑块 / Turnstile / Cap 人机验证)、可选开放注册;普通用户仅见自己的图片
|
||||
- **多后端存储**:本地磁盘 + S3 兼容(R2 / COS / OSS / AWS);混合直链 `proxy` / `public`
|
||||
- **图库**:浏览、搜索、按存储/来源筛选、网格/列表切换、批量删除;统计概览与来源分布
|
||||
- **图库**:浏览、搜索、按存储/来源筛选、网格/列表切换、批量删除;统计概览与来源分布;点击图片查看详情与多格式链接
|
||||
- **备份与迁移**:整站导出/恢复(`.phost.tar.gz`)、跨后端同步;setup 未初始化时可从备份恢复
|
||||
- **API 与 Twikoo**:全局 / 个人 Token;`POST /api/index.php` 兼容
|
||||
- **Docker 零配置**:首次访问 Web 引导创建管理员
|
||||
|
||||
@@ -74,7 +79,7 @@ docker run -d \
|
||||
|
||||
浏览器打开 `http://<主机IP>:6892`,按引导完成设置即可。
|
||||
|
||||
**详细说明**(环境变量、升级迁移、双域名、API、Twikoo、反向代理、本地开发等)见 **[用户文档](https://o96u.github.io/PicHost/)**。
|
||||
**详细说明**(环境变量、双域名、API、Twikoo、反向代理、本地开发等)见 **[用户文档](https://o96u.github.io/PicHost/)**。
|
||||
|
||||
| 分支 | 说明 |
|
||||
| ---- | ---- |
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1,444 @@
|
||||
<script setup lang="ts">
|
||||
import type { BackendSyncPreview, SyncDraft } from '~/types/backup'
|
||||
import type { StorageBackendItem } from '~/types/storage'
|
||||
|
||||
const props = defineProps<{
|
||||
backends: StorageBackendItem[]
|
||||
syncDraft: SyncDraft | null
|
||||
highlighted?: boolean
|
||||
busy: boolean
|
||||
disabled?: boolean
|
||||
activeJob?: { progress: number, total: number, message: string } | null
|
||||
previewSync: (body: { fromBackendId: string, toBackendId: string }) => Promise<BackendSyncPreview>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
sync: [body: {
|
||||
fromBackendId: string
|
||||
toBackendId: string
|
||||
deleteSource: boolean
|
||||
setDefault: boolean
|
||||
}]
|
||||
}>()
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
const { formatFileSize } = useFileSize()
|
||||
const toast = useToast()
|
||||
|
||||
const fromBackendId = ref('')
|
||||
const toBackendId = ref('')
|
||||
const deleteSource = ref(false)
|
||||
const setDefault = ref(false)
|
||||
const preview = ref<BackendSyncPreview | null>(null)
|
||||
const previewLoading = ref(false)
|
||||
const statsLoading = ref(false)
|
||||
const confirmOpen = ref(false)
|
||||
const userTouched = ref(false)
|
||||
|
||||
let statsTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const backendOptions = computed(() =>
|
||||
props.backends.map(backend => ({
|
||||
label: backend.name,
|
||||
value: backend.id
|
||||
}))
|
||||
)
|
||||
|
||||
const backendNameById = computed(() =>
|
||||
new Map(props.backends.map(backend => [backend.id, backend.name]))
|
||||
)
|
||||
|
||||
const needsMoreBackends = computed(() => props.backends.length < 2)
|
||||
|
||||
const syncDisabled = computed(() =>
|
||||
props.disabled || props.busy || needsMoreBackends.value
|
||||
)
|
||||
|
||||
function formatCount(value: number) {
|
||||
return value.toLocaleString(locale.value)
|
||||
}
|
||||
|
||||
function initDefaultSelection() {
|
||||
if (userTouched.value || props.syncDraft) return
|
||||
const defaultBackend = props.backends.find(backend => backend.isDefault)
|
||||
const target = props.backends.find(backend => !backend.isDefault && backend.enabled)
|
||||
if (defaultBackend && target) {
|
||||
fromBackendId.value = defaultBackend.id
|
||||
toBackendId.value = target.id
|
||||
return
|
||||
}
|
||||
if (props.backends.length >= 2) {
|
||||
fromBackendId.value = props.backends[0]!.id
|
||||
toBackendId.value = props.backends[1]!.id
|
||||
}
|
||||
}
|
||||
|
||||
function canLoadStats(): boolean {
|
||||
return !needsMoreBackends.value
|
||||
&& Boolean(fromBackendId.value)
|
||||
&& Boolean(toBackendId.value)
|
||||
&& fromBackendId.value !== toBackendId.value
|
||||
}
|
||||
|
||||
async function refreshStats(immediate = false) {
|
||||
if (!canLoadStats()) {
|
||||
preview.value = null
|
||||
return
|
||||
}
|
||||
if (immediate) {
|
||||
if (statsTimer) clearTimeout(statsTimer)
|
||||
await loadStats()
|
||||
return
|
||||
}
|
||||
scheduleStatsLoad()
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
if (needsMoreBackends.value || !fromBackendId.value || !toBackendId.value) {
|
||||
preview.value = null
|
||||
return
|
||||
}
|
||||
if (fromBackendId.value === toBackendId.value) {
|
||||
preview.value = null
|
||||
return
|
||||
}
|
||||
statsLoading.value = true
|
||||
try {
|
||||
preview.value = await props.previewSync({
|
||||
fromBackendId: fromBackendId.value,
|
||||
toBackendId: toBackendId.value
|
||||
})
|
||||
} catch {
|
||||
preview.value = null
|
||||
} finally {
|
||||
statsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleStatsLoad() {
|
||||
if (statsTimer) clearTimeout(statsTimer)
|
||||
statsTimer = setTimeout(() => {
|
||||
void loadStats()
|
||||
}, 400)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.backends,
|
||||
async () => {
|
||||
initDefaultSelection()
|
||||
await nextTick()
|
||||
await refreshStats(true)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.syncDraft,
|
||||
async (draft) => {
|
||||
if (!draft) return
|
||||
fromBackendId.value = draft.fromBackendId
|
||||
toBackendId.value = draft.toBackendId
|
||||
userTouched.value = true
|
||||
await refreshStats(true)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch([fromBackendId, toBackendId], () => {
|
||||
void refreshStats()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (statsTimer) clearTimeout(statsTimer)
|
||||
})
|
||||
|
||||
function applyDraft(draft: SyncDraft) {
|
||||
fromBackendId.value = draft.fromBackendId
|
||||
toBackendId.value = draft.toBackendId
|
||||
userTouched.value = true
|
||||
void refreshStats(true)
|
||||
}
|
||||
|
||||
defineExpose({ applyDraft })
|
||||
|
||||
function backendName(id: string) {
|
||||
return backendNameById.value.get(id) ?? id
|
||||
}
|
||||
|
||||
function validateSelection(): boolean {
|
||||
if (needsMoreBackends.value) {
|
||||
toast.add({ title: t('storage.backup.syncNeedTwoBackends'), color: 'warning' })
|
||||
return false
|
||||
}
|
||||
if (!fromBackendId.value || !toBackendId.value) {
|
||||
toast.add({ title: t('storage.backup.syncSelectRequired'), color: 'warning' })
|
||||
return false
|
||||
}
|
||||
if (fromBackendId.value === toBackendId.value) {
|
||||
toast.add({ title: t('storage.backup.syncSameBackend'), color: 'warning' })
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!validateSelection()) return
|
||||
previewLoading.value = true
|
||||
try {
|
||||
preview.value = await props.previewSync({
|
||||
fromBackendId: fromBackendId.value,
|
||||
toBackendId: toBackendId.value
|
||||
})
|
||||
confirmOpen.value = true
|
||||
} catch {
|
||||
toast.add({ title: t('storage.backup.previewFailed'), color: 'error' })
|
||||
} finally {
|
||||
previewLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function confirmSync() {
|
||||
emit('sync', {
|
||||
fromBackendId: fromBackendId.value,
|
||||
toBackendId: toBackendId.value,
|
||||
deleteSource: deleteSource.value,
|
||||
setDefault: setDefault.value
|
||||
})
|
||||
confirmOpen.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article
|
||||
class="flex h-full flex-col rounded-xl border bg-elevated p-4 transition-colors sm:p-5"
|
||||
:class="highlighted ? 'border-primary ring-2 ring-primary/25' : 'border-default'"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex size-10 shrink-0 items-center justify-center rounded-lg bg-info/10 text-info">
|
||||
<UIcon
|
||||
name="i-lucide-arrow-left-right"
|
||||
class="size-5"
|
||||
/>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<h3 class="text-base font-semibold">
|
||||
{{ t('storage.backup.syncTitle') }}
|
||||
</h3>
|
||||
<p class="mt-0.5 text-sm text-muted">
|
||||
{{ t('storage.backup.syncSubtitle') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-1 flex-col">
|
||||
<UAlert
|
||||
v-if="needsMoreBackends"
|
||||
color="warning"
|
||||
variant="subtle"
|
||||
icon="i-lucide-triangle-alert"
|
||||
:description="t('storage.backup.syncNeedTwoBackends')"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="flex flex-1 flex-col space-y-3"
|
||||
>
|
||||
<div class="grid gap-3 sm:grid-cols-[1fr_auto_1fr] sm:items-end">
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-xs text-muted">{{ t('storage.backup.syncFrom') }}</label>
|
||||
<USelect
|
||||
v-model="fromBackendId"
|
||||
:items="backendOptions"
|
||||
size="sm"
|
||||
class="w-full"
|
||||
:disabled="syncDisabled"
|
||||
@update:model-value="userTouched = true"
|
||||
/>
|
||||
</div>
|
||||
<UIcon
|
||||
name="i-lucide-arrow-right"
|
||||
class="hidden size-4 shrink-0 text-muted sm:mb-2.5 sm:block"
|
||||
/>
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-xs text-muted">{{ t('storage.backup.syncTo') }}</label>
|
||||
<USelect
|
||||
v-model="toBackendId"
|
||||
:items="backendOptions"
|
||||
size="sm"
|
||||
class="w-full"
|
||||
:disabled="syncDisabled"
|
||||
@update:model-value="userTouched = true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl class="space-y-1.5 rounded-lg bg-muted/25 px-3 py-2.5 text-sm">
|
||||
<div class="flex items-baseline justify-between gap-2">
|
||||
<dt class="text-muted">
|
||||
{{ t('storage.backup.syncPendingImages') }}
|
||||
</dt>
|
||||
<dd class="font-medium tabular-nums">
|
||||
<template v-if="statsLoading">
|
||||
…
|
||||
</template>
|
||||
<template v-else-if="preview">
|
||||
{{ formatCount(preview.imageCount) }}
|
||||
</template>
|
||||
<template v-else>
|
||||
—
|
||||
</template>
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex items-baseline justify-between gap-2">
|
||||
<dt class="text-muted">
|
||||
{{ t('storage.backup.syncPendingSize') }}
|
||||
</dt>
|
||||
<dd class="font-medium tabular-nums">
|
||||
<template v-if="statsLoading">
|
||||
…
|
||||
</template>
|
||||
<template v-else-if="preview">
|
||||
{{ formatFileSize(preview.imageBytes) }}
|
||||
</template>
|
||||
<template v-else>
|
||||
—
|
||||
</template>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div class="space-y-2 text-xs">
|
||||
<label class="flex cursor-pointer items-center gap-2">
|
||||
<UCheckbox
|
||||
v-model="deleteSource"
|
||||
:disabled="syncDisabled"
|
||||
/>
|
||||
<span class="text-muted">{{ t('storage.backup.syncDeleteSource') }}</span>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center gap-2">
|
||||
<UCheckbox
|
||||
v-model="setDefault"
|
||||
:disabled="syncDisabled"
|
||||
/>
|
||||
<span class="text-muted">{{ t('storage.backup.syncSetDefault') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="mt-auto rounded-lg bg-muted/15 px-3 py-2.5">
|
||||
<p class="text-xs font-medium text-default">
|
||||
{{ t('storage.backup.syncIncludesTitle') }}
|
||||
</p>
|
||||
<ul class="mt-2 space-y-1.5 text-xs leading-relaxed text-muted">
|
||||
<li class="flex items-start gap-2">
|
||||
<UIcon
|
||||
name="i-lucide-check"
|
||||
class="mt-0.5 size-3.5 shrink-0 text-info"
|
||||
/>
|
||||
<span>{{ t('storage.backup.syncIncludesCopy') }}</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<UIcon
|
||||
name="i-lucide-check"
|
||||
class="mt-0.5 size-3.5 shrink-0 text-info"
|
||||
/>
|
||||
<span>{{ t('storage.backup.syncIncludesIndex') }}</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<UIcon
|
||||
name="i-lucide-minus"
|
||||
class="mt-0.5 size-3.5 shrink-0 text-muted"
|
||||
/>
|
||||
<span>{{ t('storage.backup.syncIncludesConfig') }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<StorageBackupJobProgress
|
||||
v-if="busy && activeJob"
|
||||
class="mt-auto"
|
||||
:message="activeJob.message"
|
||||
:progress="activeJob.progress"
|
||||
:total="activeJob.total"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!needsMoreBackends"
|
||||
class="mt-auto border-t border-default pt-4"
|
||||
>
|
||||
<UButton
|
||||
:label="t('storage.backup.syncPreview')"
|
||||
icon="i-lucide-play"
|
||||
size="sm"
|
||||
:loading="previewLoading || busy"
|
||||
:disabled="syncDisabled"
|
||||
@click="submit"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<UModal
|
||||
v-model:open="confirmOpen"
|
||||
:ui="{ content: 'max-w-md' }"
|
||||
>
|
||||
<template #content>
|
||||
<div class="p-5 sm:p-6">
|
||||
<h2 class="text-base font-semibold">
|
||||
{{ t('storage.backup.syncConfirmTitle') }}
|
||||
</h2>
|
||||
<p class="mt-2 text-sm text-muted">
|
||||
{{ backendName(fromBackendId) }} → {{ backendName(toBackendId) }}
|
||||
</p>
|
||||
|
||||
<dl
|
||||
v-if="preview"
|
||||
class="mt-4 grid grid-cols-2 gap-3 text-sm"
|
||||
>
|
||||
<div>
|
||||
<dt class="text-xs text-muted">
|
||||
{{ t('storage.backup.previewImages') }}
|
||||
</dt>
|
||||
<dd class="mt-0.5 font-semibold tabular-nums">
|
||||
{{ formatCount(preview.imageCount) }}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs text-muted">
|
||||
{{ t('storage.backup.previewSize') }}
|
||||
</dt>
|
||||
<dd class="mt-0.5 font-semibold tabular-nums">
|
||||
{{ formatFileSize(preview.imageBytes) }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<UAlert
|
||||
v-if="deleteSource"
|
||||
class="mt-4"
|
||||
color="warning"
|
||||
variant="subtle"
|
||||
icon="i-lucide-triangle-alert"
|
||||
:description="t('storage.backup.syncDeleteWarning')"
|
||||
/>
|
||||
|
||||
<div class="mt-5 flex justify-end gap-2">
|
||||
<UButton
|
||||
:label="t('common.cancel')"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
color="neutral"
|
||||
@click="() => { confirmOpen = false }"
|
||||
/>
|
||||
<UButton
|
||||
:label="t('storage.backup.syncConfirm')"
|
||||
size="sm"
|
||||
icon="i-lucide-play"
|
||||
:loading="busy"
|
||||
@click="confirmSync"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
</article>
|
||||
</template>
|
||||
@@ -0,0 +1,141 @@
|
||||
<script setup lang="ts">
|
||||
import type { BackupEstimate } from '~/types/backup'
|
||||
|
||||
defineProps<{
|
||||
estimate: BackupEstimate | null
|
||||
loading: boolean
|
||||
busy: boolean
|
||||
disabled?: boolean
|
||||
activeJob?: { progress: number, total: number, message: string } | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
export: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { formatFileSize } = useFileSize()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article class="flex h-full flex-col rounded-xl border border-default bg-elevated p-4 sm:p-5">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex size-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<UIcon
|
||||
name="i-lucide-archive"
|
||||
class="size-5"
|
||||
/>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<h3 class="text-base font-semibold">
|
||||
{{ t('storage.backup.exportTitle') }}
|
||||
</h3>
|
||||
<p class="mt-0.5 text-sm text-muted">
|
||||
{{ t('storage.backup.exportSubtitle') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-1 flex-col space-y-2">
|
||||
<div
|
||||
v-if="loading"
|
||||
class="flex items-center gap-2 text-sm text-muted"
|
||||
>
|
||||
<UIcon
|
||||
name="i-lucide-loader-circle"
|
||||
class="size-4 animate-spin"
|
||||
/>
|
||||
{{ t('storage.backup.estimateLoading') }}
|
||||
</div>
|
||||
|
||||
<dl
|
||||
v-else-if="estimate"
|
||||
class="space-y-1.5 rounded-lg bg-muted/25 px-3 py-2.5 text-sm"
|
||||
>
|
||||
<div class="flex items-baseline justify-between gap-2">
|
||||
<dt class="text-muted">
|
||||
{{ t('storage.backup.estimateImageCountLabel') }}
|
||||
</dt>
|
||||
<dd class="font-medium tabular-nums">
|
||||
{{ estimate.imageCount }} · {{ formatFileSize(estimate.imageBytes) }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex items-baseline justify-between gap-2">
|
||||
<dt class="text-muted">
|
||||
{{ t('storage.backup.estimateDatabaseSnapshotLabel') }}
|
||||
</dt>
|
||||
<dd class="font-medium tabular-nums">
|
||||
{{ formatFileSize(estimate.databaseBytes) }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex items-baseline justify-between gap-2 border-t border-default/40 pt-1.5">
|
||||
<dt class="font-medium text-default">
|
||||
{{ t('storage.backup.estimatePackageSizeLabel') }}
|
||||
</dt>
|
||||
<dd class="font-semibold tabular-nums">
|
||||
{{ formatFileSize(estimate.totalBytes) }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex items-baseline justify-between gap-2 border-t border-default/40 pt-1.5">
|
||||
<dt class="text-muted">
|
||||
{{ t('storage.backup.estimateStoragePathLabel') }}
|
||||
</dt>
|
||||
<dd class="truncate font-mono text-xs font-medium">
|
||||
{{ t('storage.backup.estimateStoragePathValue') }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div
|
||||
v-if="estimate && !loading"
|
||||
class="mt-auto rounded-lg bg-muted/15 px-3 py-2.5"
|
||||
>
|
||||
<p class="text-xs font-medium text-default">
|
||||
{{ t('storage.backup.exportIncludesTitle') }}
|
||||
</p>
|
||||
<ul class="mt-2 space-y-1.5 text-xs leading-relaxed text-muted">
|
||||
<li class="flex items-start gap-2">
|
||||
<UIcon
|
||||
name="i-lucide-check"
|
||||
class="mt-0.5 size-3.5 shrink-0 text-primary"
|
||||
/>
|
||||
<span>{{ t('storage.backup.exportIncludesDb') }}</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<UIcon
|
||||
name="i-lucide-check"
|
||||
class="mt-0.5 size-3.5 shrink-0 text-primary"
|
||||
/>
|
||||
<span>{{ t('storage.backup.exportIncludesImages', { count: estimate.imageCount }) }}</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<UIcon
|
||||
name="i-lucide-minus"
|
||||
class="mt-0.5 size-3.5 shrink-0 text-muted"
|
||||
/>
|
||||
<span>{{ t('storage.backup.exportExcludesSecrets') }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<StorageBackupJobProgress
|
||||
v-if="busy && activeJob"
|
||||
class="mt-2"
|
||||
:message="activeJob.message"
|
||||
:progress="activeJob.progress"
|
||||
:total="activeJob.total"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-auto border-t border-default pt-4">
|
||||
<UButton
|
||||
:label="t('storage.backup.exportAction')"
|
||||
icon="i-lucide-download"
|
||||
size="sm"
|
||||
:loading="busy"
|
||||
:disabled="disabled || busy"
|
||||
@click="emit('export')"
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
@@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
message: string
|
||||
progress: number
|
||||
total: number
|
||||
}>()
|
||||
|
||||
const percent = computed(() => {
|
||||
if (!props.total) return 0
|
||||
return Math.min(100, Math.round((props.progress / props.total) * 100))
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-1.5">
|
||||
<div class="flex items-center justify-between gap-2 text-xs text-muted">
|
||||
<span class="truncate">{{ message }}</span>
|
||||
<span
|
||||
v-if="total > 0"
|
||||
class="shrink-0 tabular-nums"
|
||||
>
|
||||
{{ progress }} / {{ total }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="h-1 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
class="h-full rounded-full bg-primary transition-all duration-300"
|
||||
:style="{ width: `${percent}%` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,257 @@
|
||||
<script setup lang="ts">
|
||||
import type { BackupJobItem } from '~/types/backup'
|
||||
|
||||
defineProps<{
|
||||
jobs: BackupJobItem[]
|
||||
polling: boolean
|
||||
page: number
|
||||
totalPages: number
|
||||
total: number
|
||||
pageSize: number
|
||||
loading?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'retry': [jobId: string]
|
||||
'download': [jobId: string]
|
||||
'cancel': [jobId: string]
|
||||
'view': [job: BackupJobItem]
|
||||
'update:page': [page: number]
|
||||
'update:pageSize': [pageSize: number]
|
||||
}>()
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
|
||||
function typeLabel(type: BackupJobItem['type']): string {
|
||||
const map: Record<BackupJobItem['type'], string> = {
|
||||
full_export: t('storage.backup.jobTypeExport'),
|
||||
full_restore: t('storage.backup.jobTypeRestore'),
|
||||
backend_sync: t('storage.backup.jobTypeSync')
|
||||
}
|
||||
return map[type]
|
||||
}
|
||||
|
||||
function statusColor(status: BackupJobItem['status']): 'success' | 'error' | 'warning' | 'neutral' {
|
||||
if (status === 'done') return 'success'
|
||||
if (status === 'failed') return 'error'
|
||||
if (status === 'running') return 'warning'
|
||||
return 'neutral'
|
||||
}
|
||||
|
||||
function statusLabel(status: BackupJobItem['status']): string {
|
||||
const map: Record<BackupJobItem['status'], string> = {
|
||||
pending: t('storage.backup.statusPending'),
|
||||
running: t('storage.backup.statusRunning'),
|
||||
done: t('storage.backup.statusDone'),
|
||||
failed: t('storage.backup.statusFailed')
|
||||
}
|
||||
return map[status]
|
||||
}
|
||||
|
||||
function progressPercent(job: BackupJobItem): number {
|
||||
if (!job.total) return job.status === 'done' ? 100 : 0
|
||||
return Math.min(100, Math.round((job.progress / job.total) * 100))
|
||||
}
|
||||
|
||||
function jobMessage(job: BackupJobItem): string {
|
||||
if (job.status === 'failed') {
|
||||
return job.error || job.message || t('storage.backup.jobFailedUnknown')
|
||||
}
|
||||
if (job.status === 'running' || job.status === 'pending') {
|
||||
return job.message
|
||||
}
|
||||
if (job.status === 'done' && job.message) {
|
||||
return job.message
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function canRetry(job: BackupJobItem): boolean {
|
||||
return job.type === 'backend_sync' && job.status === 'failed'
|
||||
}
|
||||
|
||||
function canCancel(job: BackupJobItem): boolean {
|
||||
return job.status === 'running' || job.status === 'pending'
|
||||
}
|
||||
|
||||
function canView(job: BackupJobItem): boolean {
|
||||
return job.type === 'full_restore' && job.status === 'done' && Boolean(job.message)
|
||||
}
|
||||
|
||||
function formatTime(value: string) {
|
||||
if (!value) return '—'
|
||||
return new Date(value).toLocaleString(locale.value, { hour12: false })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="overflow-hidden rounded-xl border border-default bg-elevated">
|
||||
<header class="flex items-center justify-between gap-3 border-b border-default/50 px-4 py-4 sm:px-5">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold">
|
||||
{{ t('storage.backup.jobsTitle') }}
|
||||
</h3>
|
||||
<p class="mt-0.5 text-xs text-muted">
|
||||
{{ t('storage.backup.jobsSubtitle') }}
|
||||
</p>
|
||||
</div>
|
||||
<UIcon
|
||||
v-if="polling"
|
||||
name="i-lucide-loader-circle"
|
||||
class="size-4 shrink-0 animate-spin text-muted"
|
||||
/>
|
||||
</header>
|
||||
|
||||
<div
|
||||
v-if="!jobs.length && !loading"
|
||||
class="flex flex-col items-center gap-2 px-4 py-12 text-center sm:px-5"
|
||||
>
|
||||
<UIcon
|
||||
name="i-lucide-clipboard-list"
|
||||
class="size-7 text-muted"
|
||||
/>
|
||||
<p class="text-sm text-muted">
|
||||
{{ t('storage.backup.jobsEmpty') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="overflow-x-auto"
|
||||
>
|
||||
<table class="w-full min-w-[800px] text-left text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-default/50 text-xs text-muted">
|
||||
<th class="px-4 py-3 font-medium sm:px-5">
|
||||
{{ t('storage.backup.colTask') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 font-medium sm:px-5">
|
||||
{{ t('storage.backup.colStatus') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 font-medium sm:px-5">
|
||||
{{ t('storage.backup.colMessage') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 font-medium sm:px-5">
|
||||
{{ t('storage.backup.colProgress') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 font-medium sm:px-5">
|
||||
{{ t('storage.backup.colCreated') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 font-medium sm:px-5">
|
||||
{{ t('storage.backup.colActions') }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="job in jobs"
|
||||
:key="job.id"
|
||||
class="border-b border-default/40 last:border-0"
|
||||
>
|
||||
<td class="px-4 py-3.5 whitespace-nowrap font-medium sm:px-5">
|
||||
{{ typeLabel(job.type) }}
|
||||
</td>
|
||||
<td class="px-4 py-3.5 sm:px-5">
|
||||
<UBadge
|
||||
:color="statusColor(job.status)"
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
>
|
||||
{{ statusLabel(job.status) }}
|
||||
</UBadge>
|
||||
</td>
|
||||
<td class="max-w-xs px-4 py-3.5 sm:px-5">
|
||||
<p
|
||||
v-if="jobMessage(job)"
|
||||
class="line-clamp-2 text-xs leading-relaxed"
|
||||
:class="job.status === 'failed' ? 'text-error' : 'text-muted'"
|
||||
:title="jobMessage(job)"
|
||||
>
|
||||
{{ jobMessage(job) }}
|
||||
</p>
|
||||
<span
|
||||
v-else
|
||||
class="text-xs text-dimmed"
|
||||
>—</span>
|
||||
</td>
|
||||
<td class="min-w-32 px-4 py-3.5 sm:px-5">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="h-1 min-w-0 flex-1 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
class="h-full rounded-full bg-primary transition-all"
|
||||
:class="{ 'animate-pulse': job.status === 'running' }"
|
||||
:style="{ width: `${progressPercent(job)}%` }"
|
||||
/>
|
||||
</div>
|
||||
<span class="shrink-0 text-xs tabular-nums text-muted">
|
||||
<template v-if="job.total">{{ job.progress }}/{{ job.total }}</template>
|
||||
<template v-else-if="job.status === 'done'">100%</template>
|
||||
<template v-else>—</template>
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3.5 whitespace-nowrap text-xs text-muted sm:px-5">
|
||||
{{ formatTime(job.createdAt) }}
|
||||
</td>
|
||||
<td class="px-4 py-3.5 sm:px-5">
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<UButton
|
||||
v-if="job.downloadable"
|
||||
:label="t('storage.backup.download')"
|
||||
icon="i-lucide-download"
|
||||
size="xs"
|
||||
variant="outline"
|
||||
color="neutral"
|
||||
@click="emit('download', job.id)"
|
||||
/>
|
||||
<UButton
|
||||
v-if="canView(job)"
|
||||
:label="t('storage.backup.jobView')"
|
||||
icon="i-lucide-eye"
|
||||
size="xs"
|
||||
variant="outline"
|
||||
color="neutral"
|
||||
@click="emit('view', job)"
|
||||
/>
|
||||
<UButton
|
||||
v-if="canCancel(job)"
|
||||
:label="t('storage.backup.cancel')"
|
||||
icon="i-lucide-x"
|
||||
size="xs"
|
||||
variant="outline"
|
||||
color="warning"
|
||||
@click="emit('cancel', job.id)"
|
||||
/>
|
||||
<UButton
|
||||
v-if="canRetry(job)"
|
||||
:label="t('storage.backup.retry')"
|
||||
icon="i-lucide-rotate-ccw"
|
||||
size="xs"
|
||||
variant="outline"
|
||||
color="neutral"
|
||||
@click="emit('retry', job.id)"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="total > 0"
|
||||
class="border-t border-default/50 px-4 py-4 sm:px-5"
|
||||
>
|
||||
<PaginationBar
|
||||
:page="page"
|
||||
:total-pages="totalPages"
|
||||
:total="total"
|
||||
:page-size="pageSize"
|
||||
:loading="loading"
|
||||
:unit="t('storage.backup.jobsUnit')"
|
||||
@update:page="emit('update:page', $event)"
|
||||
@update:page-size="emit('update:pageSize', $event)"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,305 @@
|
||||
<script setup lang="ts">
|
||||
import type { BackupPackageItem, BackupRestorePreview } from '~/types/backup'
|
||||
|
||||
const props = defineProps<{
|
||||
packages: BackupPackageItem[]
|
||||
busy: boolean
|
||||
disabled?: boolean
|
||||
restoreBusy?: boolean
|
||||
activeJob?: { progress: number, total: number, message: string } | null
|
||||
previewRestore: (input: { file?: File, archiveName?: string }) => Promise<BackupRestorePreview>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
restore: [input: { file?: File, archiveName?: string, conflictMode: 'skip' | 'overwrite' }]
|
||||
}>()
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
const { formatFileSize } = useFileSize()
|
||||
const toast = useToast()
|
||||
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const selectedFile = ref<File | null>(null)
|
||||
const selectedPackage = ref<string>('')
|
||||
const conflictMode = ref<'skip' | 'overwrite'>('skip')
|
||||
const preview = ref<BackupRestorePreview | null>(null)
|
||||
const previewLoading = ref(false)
|
||||
const confirmOpen = ref(false)
|
||||
|
||||
const conflictItems = computed(() => [
|
||||
{ label: t('storage.backup.conflictSkip'), value: 'skip' },
|
||||
{ label: t('storage.backup.conflictOverwrite'), value: 'overwrite' }
|
||||
])
|
||||
|
||||
const selectMenuUi = {
|
||||
content: 'min-w-[var(--reka-select-trigger-width)] w-max max-w-[min(100vw-2rem,24rem)]',
|
||||
item: 'whitespace-normal',
|
||||
itemLabel: 'whitespace-normal'
|
||||
}
|
||||
|
||||
const packageOptions = computed(() =>
|
||||
props.packages.map(pkg => ({
|
||||
label: `${shortenName(pkg.name)} (${formatFileSize(pkg.bytes)})`,
|
||||
value: pkg.name
|
||||
}))
|
||||
)
|
||||
|
||||
function shortenName(name: string) {
|
||||
if (name.length <= 36) return name
|
||||
return `${name.slice(0, 22)}…${name.slice(-10)}`
|
||||
}
|
||||
|
||||
function formatTime(value: string) {
|
||||
if (!value) return '—'
|
||||
return new Date(value).toLocaleString(locale.value, { hour12: false })
|
||||
}
|
||||
|
||||
function pickFile() {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
function onFileChange(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0] ?? null
|
||||
if (!file) return
|
||||
selectedFile.value = file
|
||||
selectedPackage.value = ''
|
||||
preview.value = null
|
||||
if (input) input.value = ''
|
||||
}
|
||||
|
||||
function selectPackage(name: string | null) {
|
||||
if (!name) {
|
||||
selectedPackage.value = ''
|
||||
return
|
||||
}
|
||||
selectedPackage.value = name
|
||||
selectedFile.value = null
|
||||
preview.value = null
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!selectedFile.value && !selectedPackage.value) {
|
||||
toast.add({ title: t('storage.backup.restoreSelectRequired'), color: 'warning' })
|
||||
return
|
||||
}
|
||||
previewLoading.value = true
|
||||
try {
|
||||
preview.value = await props.previewRestore({
|
||||
file: selectedFile.value ?? undefined,
|
||||
archiveName: selectedPackage.value || undefined
|
||||
})
|
||||
confirmOpen.value = true
|
||||
} catch {
|
||||
toast.add({ title: t('storage.backup.previewFailed'), color: 'error' })
|
||||
} finally {
|
||||
previewLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function confirmRestore() {
|
||||
emit('restore', {
|
||||
file: selectedFile.value ?? undefined,
|
||||
archiveName: selectedPackage.value || undefined,
|
||||
conflictMode: conflictMode.value
|
||||
})
|
||||
confirmOpen.value = false
|
||||
selectedFile.value = null
|
||||
selectedPackage.value = ''
|
||||
preview.value = null
|
||||
}
|
||||
|
||||
const sourceLabel = computed(() => selectedFile.value?.name || selectedPackage.value || '')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article class="flex h-full flex-col rounded-xl border border-default bg-elevated p-4 sm:p-5">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex size-10 shrink-0 items-center justify-center rounded-lg bg-warning/10 text-warning">
|
||||
<UIcon
|
||||
name="i-lucide-upload"
|
||||
class="size-5"
|
||||
/>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<h3 class="text-base font-semibold">
|
||||
{{ t('storage.backup.restoreTitle') }}
|
||||
</h3>
|
||||
<p class="mt-0.5 text-sm text-muted">
|
||||
{{ t('storage.backup.restoreSubtitle') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-1 flex-col space-y-3">
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-xs text-muted">{{ t('storage.backup.restoreUploadLabel') }}</label>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full flex-col items-center gap-1.5 rounded-lg border-2 border-dashed px-3 py-2.5 text-center transition-colors disabled:cursor-not-allowed disabled:opacity-60"
|
||||
:class="selectedFile
|
||||
? 'border-primary/50 bg-primary/5'
|
||||
: 'border-warning/40 bg-warning/5 hover:border-warning/60 hover:bg-warning/10'"
|
||||
:disabled="disabled || busy"
|
||||
@click="pickFile"
|
||||
>
|
||||
<div
|
||||
class="flex size-8 items-center justify-center rounded-full"
|
||||
:class="selectedFile ? 'bg-primary/15 text-primary' : 'bg-warning/15 text-warning'"
|
||||
>
|
||||
<UIcon
|
||||
:name="selectedFile ? 'i-lucide-check' : 'i-lucide-upload'"
|
||||
class="size-4.5"
|
||||
/>
|
||||
</div>
|
||||
<span class="max-w-full truncate text-sm font-medium text-default">
|
||||
{{ selectedFile?.name ?? t('storage.backup.restoreUpload') }}
|
||||
</span>
|
||||
<span
|
||||
v-if="!selectedFile"
|
||||
class="text-xs text-muted"
|
||||
>
|
||||
{{ t('storage.backup.restoreUploadHint') }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="packages.length"
|
||||
class="space-y-1.5"
|
||||
>
|
||||
<label class="text-xs text-muted">{{ t('storage.backup.restoreServerPackages') }}</label>
|
||||
<USelect
|
||||
:model-value="selectedPackage || undefined"
|
||||
:items="packageOptions"
|
||||
size="sm"
|
||||
class="w-full"
|
||||
:ui="selectMenuUi"
|
||||
:placeholder="t('storage.backup.restoreServerSelectPlaceholder')"
|
||||
:disabled="disabled || busy"
|
||||
@update:model-value="selectPackage"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="text-xs text-muted">{{ t('storage.backup.conflictMode') }}</label>
|
||||
<URadioGroup
|
||||
v-model="conflictMode"
|
||||
:items="conflictItems"
|
||||
:disabled="disabled || busy"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<StorageBackupJobProgress
|
||||
v-if="restoreBusy && activeJob"
|
||||
class="mt-auto"
|
||||
:message="activeJob.message"
|
||||
:progress="activeJob.progress"
|
||||
:total="activeJob.total"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-auto border-t border-default pt-4">
|
||||
<UButton
|
||||
:label="t('storage.backup.restorePreviewConfirm')"
|
||||
icon="i-lucide-search"
|
||||
size="sm"
|
||||
:loading="previewLoading || restoreBusy"
|
||||
:disabled="disabled || busy"
|
||||
@click="submit"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept=".phost.tar.gz,.tar.gz"
|
||||
class="hidden"
|
||||
@change="onFileChange"
|
||||
>
|
||||
|
||||
<UModal
|
||||
v-model:open="confirmOpen"
|
||||
:ui="{ content: 'max-w-md' }"
|
||||
>
|
||||
<template #content>
|
||||
<div class="p-5 sm:p-6">
|
||||
<h2 class="text-base font-semibold">
|
||||
{{ t('storage.backup.restoreConfirmTitle') }}
|
||||
</h2>
|
||||
<p
|
||||
v-if="sourceLabel"
|
||||
class="mt-2 truncate font-mono text-xs text-muted"
|
||||
:title="sourceLabel"
|
||||
>
|
||||
{{ sourceLabel }}
|
||||
</p>
|
||||
|
||||
<dl
|
||||
v-if="preview"
|
||||
class="mt-4 grid grid-cols-2 gap-3 text-sm"
|
||||
>
|
||||
<div>
|
||||
<dt class="text-xs text-muted">
|
||||
{{ t('storage.backup.previewImages') }}
|
||||
</dt>
|
||||
<dd class="mt-0.5 font-semibold tabular-nums">
|
||||
{{ preview.imageCount }}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs text-muted">
|
||||
{{ t('storage.backup.previewNew') }}
|
||||
</dt>
|
||||
<dd class="mt-0.5 font-semibold tabular-nums">
|
||||
{{ preview.newImages }}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs text-muted">
|
||||
{{ t('storage.backup.previewConflicts') }}
|
||||
</dt>
|
||||
<dd class="mt-0.5 font-semibold tabular-nums">
|
||||
{{ preview.conflictingImages }}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs text-muted">
|
||||
{{ t('storage.backup.previewCreated') }}
|
||||
</dt>
|
||||
<dd class="mt-0.5 text-xs">
|
||||
{{ formatTime(preview.createdAt) }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<UAlert
|
||||
v-if="conflictMode === 'overwrite'"
|
||||
class="mt-4"
|
||||
color="warning"
|
||||
variant="subtle"
|
||||
icon="i-lucide-triangle-alert"
|
||||
:description="t('storage.backup.restoreOverwriteWarning')"
|
||||
/>
|
||||
|
||||
<div class="mt-5 flex justify-end gap-2">
|
||||
<UButton
|
||||
:label="t('common.cancel')"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
color="neutral"
|
||||
@click="() => { confirmOpen = false }"
|
||||
/>
|
||||
<UButton
|
||||
:label="t('storage.backup.restoreConfirm')"
|
||||
size="sm"
|
||||
color="warning"
|
||||
:loading="restoreBusy"
|
||||
@click="confirmRestore"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
</article>
|
||||
</template>
|
||||
@@ -5,6 +5,8 @@ const props = defineProps<{
|
||||
backend: StorageBackendItem
|
||||
envOverride?: boolean
|
||||
busy?: boolean
|
||||
showMigrateIn?: boolean
|
||||
showMigrateOut?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -13,6 +15,8 @@ const emit = defineEmits<{
|
||||
test: []
|
||||
toggleEnabled: []
|
||||
delete: []
|
||||
migrateIn: []
|
||||
migrateOut: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -129,6 +133,28 @@ function canDelete() {
|
||||
:disabled="actionsDisabled"
|
||||
@click="emit('setDefault')"
|
||||
/>
|
||||
<UButton
|
||||
v-if="showMigrateIn"
|
||||
:label="t('storage.backup.migrateIn')"
|
||||
:title="t('storage.backup.migrateInHint')"
|
||||
icon="i-lucide-import"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
color="neutral"
|
||||
:disabled="actionsDisabled"
|
||||
@click="emit('migrateIn')"
|
||||
/>
|
||||
<UButton
|
||||
v-if="showMigrateOut"
|
||||
:label="t('storage.backup.migrateOut')"
|
||||
:title="t('storage.backup.migrateOutHint')"
|
||||
icon="i-lucide-arrow-up-from-line"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
color="neutral"
|
||||
:disabled="actionsDisabled"
|
||||
@click="emit('migrateOut')"
|
||||
/>
|
||||
<UButton
|
||||
v-if="canEdit()"
|
||||
:label="t('common.edit')"
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
<script setup lang="ts">
|
||||
import type { BackupJobItem, SyncDraft } from '~/types/backup'
|
||||
import type { StorageBackendItem } from '~/types/storage'
|
||||
|
||||
const props = defineProps<{
|
||||
backends: StorageBackendItem[]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const {
|
||||
jobs,
|
||||
jobsPage,
|
||||
jobsPageSize,
|
||||
jobsTotal,
|
||||
jobsTotalPages,
|
||||
jobsLoading,
|
||||
estimate,
|
||||
packages,
|
||||
loadingEstimate,
|
||||
polling,
|
||||
hasActiveJob,
|
||||
getActiveJob,
|
||||
loadJobs,
|
||||
setJobsPage,
|
||||
setJobsPageSize,
|
||||
loadEstimate,
|
||||
startPolling,
|
||||
createExport,
|
||||
previewRestore,
|
||||
confirmRestore,
|
||||
previewSync,
|
||||
startSync,
|
||||
retryJob,
|
||||
cancelJob,
|
||||
downloadUrl
|
||||
} = useBackup()
|
||||
|
||||
const syncDraft = ref<SyncDraft | null>(null)
|
||||
const syncCardRef = ref<{ applyDraft: (draft: SyncDraft) => void } | null>(null)
|
||||
const panelRef = ref<HTMLElement | null>(null)
|
||||
const syncHighlighted = ref(false)
|
||||
|
||||
const activeJob = computed(() => getActiveJob())
|
||||
const exportBusy = computed(() => activeJob.value?.type === 'full_export')
|
||||
const restoreBusy = computed(() => activeJob.value?.type === 'full_restore')
|
||||
const syncBusy = computed(() => activeJob.value?.type === 'backend_sync')
|
||||
const anyJobBusy = computed(() => Boolean(activeJob.value))
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadJobs(), loadEstimate()])
|
||||
if (hasActiveJob()) {
|
||||
startPolling()
|
||||
}
|
||||
})
|
||||
|
||||
function applySyncDraft(draft: SyncDraft) {
|
||||
syncDraft.value = draft
|
||||
syncCardRef.value?.applyDraft(draft)
|
||||
syncHighlighted.value = true
|
||||
window.setTimeout(() => {
|
||||
syncHighlighted.value = false
|
||||
}, 2500)
|
||||
nextTick(() => {
|
||||
panelRef.value?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
})
|
||||
toast.add({ title: t('storage.backup.syncDraftApplied'), color: 'info' })
|
||||
}
|
||||
|
||||
defineExpose({ applySyncDraft })
|
||||
|
||||
async function onExport() {
|
||||
await createExport()
|
||||
}
|
||||
|
||||
async function onRestore(input: {
|
||||
file?: File
|
||||
archiveName?: string
|
||||
conflictMode: 'skip' | 'overwrite'
|
||||
}) {
|
||||
await confirmRestore(input)
|
||||
}
|
||||
|
||||
async function onCancel(jobId: string) {
|
||||
await cancelJob(jobId)
|
||||
}
|
||||
|
||||
async function onSync(body: {
|
||||
fromBackendId: string
|
||||
toBackendId: string
|
||||
deleteSource: boolean
|
||||
setDefault: boolean
|
||||
}) {
|
||||
await startSync(body)
|
||||
}
|
||||
|
||||
function onDownload(jobId: string) {
|
||||
window.open(downloadUrl(jobId), '_blank')
|
||||
}
|
||||
|
||||
async function onRetry(jobId: string) {
|
||||
await retryJob(jobId)
|
||||
}
|
||||
|
||||
function onViewJob(job: BackupJobItem) {
|
||||
toast.add({
|
||||
title: t('storage.backup.jobTypeRestore'),
|
||||
description: job.message,
|
||||
color: 'info'
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
ref="panelRef"
|
||||
class="scroll-mt-20 space-y-4 border-t border-default pt-5"
|
||||
>
|
||||
<header>
|
||||
<h2 class="text-base font-semibold">
|
||||
{{ t('storage.backup.sectionTitle') }}
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-muted">
|
||||
{{ t('storage.backup.sectionSubtitle') }}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="grid grid-cols-1 items-stretch gap-4 xl:grid-cols-3">
|
||||
<StorageBackupExportCard
|
||||
class="h-full"
|
||||
:estimate="estimate"
|
||||
:loading="loadingEstimate"
|
||||
:busy="exportBusy"
|
||||
:active-job="exportBusy ? activeJob : null"
|
||||
:disabled="anyJobBusy && !exportBusy"
|
||||
@export="onExport"
|
||||
/>
|
||||
<StorageBackupRestoreCard
|
||||
class="h-full"
|
||||
:packages="packages"
|
||||
:busy="anyJobBusy"
|
||||
:restore-busy="restoreBusy"
|
||||
:active-job="restoreBusy ? activeJob : null"
|
||||
:disabled="anyJobBusy && !restoreBusy"
|
||||
:preview-restore="previewRestore"
|
||||
@restore="onRestore"
|
||||
/>
|
||||
<StorageBackendSyncCard
|
||||
ref="syncCardRef"
|
||||
class="h-full"
|
||||
:backends="props.backends"
|
||||
:sync-draft="syncDraft"
|
||||
:highlighted="syncHighlighted"
|
||||
:busy="syncBusy"
|
||||
:active-job="syncBusy ? activeJob : null"
|
||||
:disabled="anyJobBusy && !syncBusy"
|
||||
:preview-sync="previewSync"
|
||||
@sync="onSync"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<StorageBackupJobTable
|
||||
:jobs="jobs"
|
||||
:polling="polling"
|
||||
:page="jobsPage"
|
||||
:total-pages="jobsTotalPages"
|
||||
:total="jobsTotal"
|
||||
:page-size="jobsPageSize"
|
||||
:loading="jobsLoading"
|
||||
@update:page="setJobsPage"
|
||||
@update:page-size="setJobsPageSize"
|
||||
@download="onDownload"
|
||||
@retry="onRetry"
|
||||
@cancel="onCancel"
|
||||
@view="onViewJob"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
import type {
|
||||
BackupEstimate,
|
||||
BackupJobItem,
|
||||
BackupPackageItem,
|
||||
BackupRestorePreview,
|
||||
BackendSyncPreview
|
||||
} from '~/types/backup'
|
||||
|
||||
const jobs = ref<BackupJobItem[]>([])
|
||||
const jobsPage = ref(1)
|
||||
const jobsPageSize = ref(10)
|
||||
const jobsTotal = ref(0)
|
||||
const jobsTotalPages = ref(1)
|
||||
const jobsLoading = ref(false)
|
||||
const activeJobState = ref<BackupJobItem | null>(null)
|
||||
const estimate = ref<BackupEstimate | null>(null)
|
||||
const packages = ref<BackupPackageItem[]>([])
|
||||
const loadingEstimate = ref(false)
|
||||
const polling = ref(false)
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
export function useBackup() {
|
||||
const toast = useToast()
|
||||
const { t } = useI18n()
|
||||
|
||||
function stopPolling() {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer)
|
||||
pollTimer = null
|
||||
}
|
||||
polling.value = false
|
||||
}
|
||||
|
||||
function hasActiveJob(): boolean {
|
||||
return activeJobState.value !== null
|
||||
}
|
||||
|
||||
function getActiveJob(): BackupJobItem | null {
|
||||
return activeJobState.value
|
||||
}
|
||||
|
||||
async function cancelJob(jobId: string) {
|
||||
try {
|
||||
await $fetch(`/api/admin/backup/jobs/${jobId}/cancel`, {
|
||||
method: 'POST',
|
||||
credentials: 'include'
|
||||
})
|
||||
await loadJobs()
|
||||
toast.add({ title: t('storage.backup.cancelDone'), color: 'success' })
|
||||
} catch (error: unknown) {
|
||||
handleError(error, t('storage.backup.cancelFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
async function loadJobs(options?: { page?: number, pageSize?: number }) {
|
||||
if (options?.page !== undefined) jobsPage.value = options.page
|
||||
if (options?.pageSize !== undefined) jobsPageSize.value = options.pageSize
|
||||
jobsLoading.value = true
|
||||
try {
|
||||
const data = await $fetch<{
|
||||
jobs: BackupJobItem[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
totalPages: number
|
||||
activeJob: BackupJobItem | null
|
||||
}>('/api/admin/backup/jobs', {
|
||||
credentials: 'include',
|
||||
query: {
|
||||
pageNum: jobsPage.value,
|
||||
pageSize: jobsPageSize.value
|
||||
}
|
||||
})
|
||||
jobs.value = data.jobs
|
||||
jobsTotal.value = data.total
|
||||
jobsPage.value = data.page
|
||||
jobsPageSize.value = data.pageSize
|
||||
jobsTotalPages.value = data.totalPages
|
||||
activeJobState.value = data.activeJob
|
||||
if (!data.activeJob) {
|
||||
stopPolling()
|
||||
void loadEstimate().catch(() => {})
|
||||
}
|
||||
return data.jobs
|
||||
} finally {
|
||||
jobsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function setJobsPage(page: number) {
|
||||
jobsPage.value = page
|
||||
return loadJobs()
|
||||
}
|
||||
|
||||
async function setJobsPageSize(pageSize: number) {
|
||||
jobsPageSize.value = pageSize
|
||||
jobsPage.value = 1
|
||||
return loadJobs()
|
||||
}
|
||||
|
||||
async function refreshJobsFirstPage() {
|
||||
jobsPage.value = 1
|
||||
return loadJobs()
|
||||
}
|
||||
|
||||
async function loadEstimate() {
|
||||
loadingEstimate.value = true
|
||||
try {
|
||||
const data = await $fetch<{
|
||||
estimate: BackupEstimate
|
||||
packages: BackupPackageItem[]
|
||||
backupsDir: string
|
||||
}>('/api/admin/backup/estimate', { credentials: 'include' })
|
||||
estimate.value = data.estimate
|
||||
packages.value = data.packages
|
||||
return data
|
||||
} finally {
|
||||
loadingEstimate.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
if (pollTimer) return
|
||||
polling.value = true
|
||||
pollTimer = setInterval(() => {
|
||||
void loadJobs().catch(() => {})
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
async function createExport() {
|
||||
try {
|
||||
const data = await $fetch<{ job: BackupJobItem, estimate: BackupEstimate }>(
|
||||
'/api/admin/backup/export',
|
||||
{ method: 'POST', credentials: 'include' }
|
||||
)
|
||||
estimate.value = data.estimate
|
||||
await refreshJobsFirstPage()
|
||||
startPolling()
|
||||
toast.add({ title: t('storage.backup.exportStarted'), color: 'success' })
|
||||
return data.job
|
||||
} catch (error: unknown) {
|
||||
handleError(error, t('storage.backup.exportFailed'))
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function previewRestore(input: {
|
||||
file?: File
|
||||
archiveName?: string
|
||||
}): Promise<BackupRestorePreview> {
|
||||
if (input.file) {
|
||||
const form = new FormData()
|
||||
form.append('file', input.file)
|
||||
form.append('dryRun', 'true')
|
||||
const data = await $fetch<{ preview: BackupRestorePreview }>(
|
||||
'/api/admin/backup/restore',
|
||||
{ method: 'POST', credentials: 'include', body: form }
|
||||
)
|
||||
return data.preview
|
||||
}
|
||||
|
||||
const data = await $fetch<{ preview: BackupRestorePreview }>(
|
||||
'/api/admin/backup/restore',
|
||||
{
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: {
|
||||
archiveName: input.archiveName,
|
||||
dryRun: true
|
||||
}
|
||||
}
|
||||
)
|
||||
return data.preview
|
||||
}
|
||||
|
||||
async function confirmRestore(input: {
|
||||
file?: File
|
||||
archiveName?: string
|
||||
conflictMode: 'skip' | 'overwrite'
|
||||
}) {
|
||||
try {
|
||||
if (input.file) {
|
||||
const form = new FormData()
|
||||
form.append('file', input.file)
|
||||
form.append('conflictMode', input.conflictMode)
|
||||
const data = await $fetch<{ job: BackupJobItem }>(
|
||||
'/api/admin/backup/restore',
|
||||
{ method: 'POST', credentials: 'include', body: form }
|
||||
)
|
||||
await refreshJobsFirstPage()
|
||||
startPolling()
|
||||
toast.add({ title: t('storage.backup.restoreStarted'), color: 'success' })
|
||||
return data.job
|
||||
}
|
||||
|
||||
const data = await $fetch<{ job: BackupJobItem }>(
|
||||
'/api/admin/backup/restore',
|
||||
{
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: {
|
||||
archiveName: input.archiveName,
|
||||
conflictMode: input.conflictMode,
|
||||
dryRun: false
|
||||
}
|
||||
}
|
||||
)
|
||||
await refreshJobsFirstPage()
|
||||
startPolling()
|
||||
toast.add({ title: t('storage.backup.restoreStarted'), color: 'success' })
|
||||
return data.job
|
||||
} catch (error: unknown) {
|
||||
handleError(error, t('storage.backup.restoreFailed'))
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function previewSync(body: {
|
||||
fromBackendId: string
|
||||
toBackendId: string
|
||||
}): Promise<BackendSyncPreview> {
|
||||
const data = await $fetch<{ preview: BackendSyncPreview }>(
|
||||
'/api/admin/storage/sync',
|
||||
{
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: { ...body, dryRun: true }
|
||||
}
|
||||
)
|
||||
return data.preview
|
||||
}
|
||||
|
||||
async function startSync(body: {
|
||||
fromBackendId: string
|
||||
toBackendId: string
|
||||
deleteSource?: boolean
|
||||
setDefault?: boolean
|
||||
}) {
|
||||
try {
|
||||
const data = await $fetch<{ job: BackupJobItem }>(
|
||||
'/api/admin/storage/sync',
|
||||
{
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body
|
||||
}
|
||||
)
|
||||
await refreshJobsFirstPage()
|
||||
startPolling()
|
||||
toast.add({ title: t('storage.backup.syncStarted'), color: 'success' })
|
||||
return data.job
|
||||
} catch (error: unknown) {
|
||||
handleError(error, t('storage.backup.syncFailed'))
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function retryJob(jobId: string) {
|
||||
try {
|
||||
await $fetch(`/api/admin/backup/jobs/${jobId}/retry`, {
|
||||
method: 'POST',
|
||||
credentials: 'include'
|
||||
})
|
||||
await refreshJobsFirstPage()
|
||||
startPolling()
|
||||
toast.add({ title: t('storage.backup.retryStarted'), color: 'success' })
|
||||
} catch (error: unknown) {
|
||||
handleError(error, t('storage.backup.retryFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
function downloadUrl(jobId: string): string {
|
||||
return `/api/admin/backup/jobs/${jobId}/download`
|
||||
}
|
||||
|
||||
function handleError(error: unknown, fallback: string) {
|
||||
const status = typeof error === 'object' && error !== null && 'statusCode' in error
|
||||
? (error as { statusCode: number }).statusCode
|
||||
: 0
|
||||
if (status === 409) {
|
||||
toast.add({ title: t('storage.backup.jobRunning'), color: 'warning' })
|
||||
return
|
||||
}
|
||||
toast.add({ title: fallback, color: 'error' })
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
stopPolling()
|
||||
})
|
||||
|
||||
return {
|
||||
jobs,
|
||||
jobsPage,
|
||||
jobsPageSize,
|
||||
jobsTotal,
|
||||
jobsTotalPages,
|
||||
jobsLoading,
|
||||
estimate,
|
||||
packages,
|
||||
loadingEstimate,
|
||||
polling,
|
||||
hasActiveJob,
|
||||
getActiveJob,
|
||||
loadJobs,
|
||||
setJobsPage,
|
||||
setJobsPageSize,
|
||||
loadEstimate,
|
||||
startPolling,
|
||||
stopPolling,
|
||||
createExport,
|
||||
previewRestore,
|
||||
confirmRestore,
|
||||
previewSync,
|
||||
startSync,
|
||||
retryJob,
|
||||
cancelJob,
|
||||
downloadUrl
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,102 @@ const domainSeparation = ref(false)
|
||||
const siteBaseUrl = ref('')
|
||||
const imageBaseUrl = ref('')
|
||||
const loading = ref(false)
|
||||
const domainSeparationConfirmOpen = ref(false)
|
||||
|
||||
const setupMode = ref<'init' | 'restore'>('init')
|
||||
const restoreFile = ref<File | null>(null)
|
||||
const restoreFileInput = ref<HTMLInputElement | null>(null)
|
||||
const restoreLoading = ref(false)
|
||||
const restoreJobId = ref<string | null>(null)
|
||||
const restoreProgress = ref({ progress: 0, total: 0, message: '' })
|
||||
let restorePollTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
function stopRestorePolling() {
|
||||
if (restorePollTimer) {
|
||||
clearInterval(restorePollTimer)
|
||||
restorePollTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function pickRestoreFile() {
|
||||
restoreFileInput.value?.click()
|
||||
}
|
||||
|
||||
function onRestoreFileChange(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
restoreFile.value = input.files?.[0] ?? null
|
||||
if (input) input.value = ''
|
||||
}
|
||||
|
||||
async function pollRestoreJob(jobId: string) {
|
||||
try {
|
||||
const data = await $fetch<{ job: { status: string, progress: number, total: number, message: string, error: string | null } }>(
|
||||
`/api/setup/restore/${jobId}`
|
||||
)
|
||||
restoreProgress.value = {
|
||||
progress: data.job.progress,
|
||||
total: data.job.total,
|
||||
message: data.job.message
|
||||
}
|
||||
if (data.job.status === 'done') {
|
||||
stopRestorePolling()
|
||||
restoreLoading.value = false
|
||||
toast.add({
|
||||
title: t('setup.restoreSuccess'),
|
||||
description: t('setup.restoreSecretsHint'),
|
||||
color: 'success'
|
||||
})
|
||||
await router.replace('/')
|
||||
return
|
||||
}
|
||||
if (data.job.status === 'failed') {
|
||||
stopRestorePolling()
|
||||
restoreLoading.value = false
|
||||
toast.add({
|
||||
title: t('setup.restoreFailed'),
|
||||
description: data.job.error ?? data.job.message,
|
||||
color: 'error'
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
stopRestorePolling()
|
||||
restoreLoading.value = false
|
||||
toast.add({ title: t('setup.restoreFailed'), color: 'error' })
|
||||
}
|
||||
}
|
||||
|
||||
async function submitRestore() {
|
||||
if (!restoreFile.value || restoreLoading.value) return
|
||||
restoreLoading.value = true
|
||||
try {
|
||||
const form = new FormData()
|
||||
form.append('file', restoreFile.value)
|
||||
const data = await $fetch<{ job: { id: string } }>('/api/setup/restore', {
|
||||
method: 'POST',
|
||||
body: form
|
||||
})
|
||||
restoreJobId.value = data.job.id
|
||||
restorePollTimer = setInterval(() => {
|
||||
void pollRestoreJob(data.job.id)
|
||||
}, 1500)
|
||||
await pollRestoreJob(data.job.id)
|
||||
} catch {
|
||||
restoreLoading.value = false
|
||||
toast.add({ title: t('setup.restoreFailed'), color: 'error' })
|
||||
}
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
stopRestorePolling()
|
||||
})
|
||||
|
||||
const domainSeparationWouldActivate = computed(() =>
|
||||
willActivateDomainSeparation(
|
||||
domainSeparation.value,
|
||||
siteBaseUrl.value,
|
||||
imageBaseUrl.value
|
||||
)
|
||||
)
|
||||
|
||||
const canSubmit = computed(() => {
|
||||
if (!username.value || !password.value || !confirmPassword.value) return false
|
||||
@@ -33,7 +130,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 +160,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 = {
|
||||
@@ -127,7 +236,28 @@ async function submit() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-6 flex rounded-lg border border-default p-1">
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 rounded-md px-3 py-2 text-sm font-medium transition-colors"
|
||||
:class="setupMode === 'init' ? 'bg-primary text-inverted' : 'text-muted hover:text-default'"
|
||||
@click="setupMode = 'init'"
|
||||
>
|
||||
{{ t('setup.modeInit') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="!isMigrate"
|
||||
type="button"
|
||||
class="flex-1 rounded-md px-3 py-2 text-sm font-medium transition-colors"
|
||||
:class="setupMode === 'restore' ? 'bg-primary text-inverted' : 'text-muted hover:text-default'"
|
||||
@click="setupMode = 'restore'"
|
||||
>
|
||||
{{ t('setup.modeRestore') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form
|
||||
v-if="setupMode === 'init'"
|
||||
class="flex w-full flex-col gap-5"
|
||||
@submit.prevent="submit"
|
||||
>
|
||||
@@ -247,6 +377,97 @@ async function submit() {
|
||||
:disabled="!canSubmit"
|
||||
/>
|
||||
</form>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="flex w-full flex-col gap-5"
|
||||
>
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-xs text-muted">{{ t('storage.backup.restoreUploadLabel') }}</label>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full flex-col items-center gap-1.5 rounded-lg border-2 border-dashed px-3 py-4 text-center transition-colors disabled:cursor-not-allowed disabled:opacity-60"
|
||||
:class="restoreFile
|
||||
? 'border-primary/50 bg-primary/5'
|
||||
: 'border-warning/40 bg-warning/5 hover:border-warning/60 hover:bg-warning/10'"
|
||||
:disabled="restoreLoading"
|
||||
@click="pickRestoreFile"
|
||||
>
|
||||
<div
|
||||
class="flex size-8 items-center justify-center rounded-full"
|
||||
:class="restoreFile ? 'bg-primary/15 text-primary' : 'bg-warning/15 text-warning'"
|
||||
>
|
||||
<UIcon
|
||||
:name="restoreFile ? 'i-lucide-check' : 'i-lucide-upload'"
|
||||
class="size-4.5"
|
||||
/>
|
||||
</div>
|
||||
<span class="max-w-full truncate text-sm font-medium text-default">
|
||||
{{ restoreFile?.name ?? t('storage.backup.restoreUpload') }}
|
||||
</span>
|
||||
<span
|
||||
v-if="!restoreFile"
|
||||
class="text-xs text-muted"
|
||||
>
|
||||
{{ t('storage.backup.restoreUploadHint') }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg bg-muted/15 px-3 py-2.5 text-xs leading-relaxed text-muted">
|
||||
{{ t('setup.restoreHint') }}
|
||||
</div>
|
||||
|
||||
<StorageBackupJobProgress
|
||||
v-if="restoreLoading"
|
||||
:message="restoreProgress.message || t('setup.restoreSubmit')"
|
||||
:progress="restoreProgress.progress"
|
||||
:total="restoreProgress.total"
|
||||
/>
|
||||
|
||||
<UButton
|
||||
:label="t('setup.restoreSubmit')"
|
||||
icon="i-lucide-upload"
|
||||
size="lg"
|
||||
block
|
||||
class="w-full"
|
||||
:loading="restoreLoading"
|
||||
:disabled="!restoreFile"
|
||||
@click="submitRestore"
|
||||
/>
|
||||
|
||||
<input
|
||||
ref="restoreFileInput"
|
||||
type="file"
|
||||
accept=".phost.tar.gz,.tar.gz"
|
||||
class="hidden"
|
||||
@change="onRestoreFileChange"
|
||||
>
|
||||
</div>
|
||||
</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>
|
||||
@@ -21,6 +21,53 @@ const deleteTarget = ref<StorageBackendItem | null>(null)
|
||||
const deleteConfirmName = ref('')
|
||||
const deleting = ref(false)
|
||||
|
||||
const backupPanelRef = ref<{ applySyncDraft: (draft: { fromBackendId: string, toBackendId: string }) => void } | null>(null)
|
||||
|
||||
function onMigrateIn(backend: StorageBackendItem) {
|
||||
const defaultBackend = storageData.value?.backends.find(item => item.isDefault)
|
||||
if (!defaultBackend || defaultBackend.id === backend.id) return
|
||||
backupPanelRef.value?.applySyncDraft({
|
||||
fromBackendId: defaultBackend.id,
|
||||
toBackendId: backend.id
|
||||
})
|
||||
}
|
||||
|
||||
function onMigrateOut(backend: StorageBackendItem) {
|
||||
const backends = storageData.value?.backends ?? []
|
||||
if (backend.isDefault) {
|
||||
const target = backends.find(item => !item.isDefault && item.enabled)
|
||||
if (!target) return
|
||||
backupPanelRef.value?.applySyncDraft({
|
||||
fromBackendId: backend.id,
|
||||
toBackendId: target.id
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const defaultBackend = backends.find(item => item.isDefault)
|
||||
if (!defaultBackend) return
|
||||
backupPanelRef.value?.applySyncDraft({
|
||||
fromBackendId: backend.id,
|
||||
toBackendId: defaultBackend.id
|
||||
})
|
||||
}
|
||||
|
||||
function showMigrateIn(backend: StorageBackendItem): boolean {
|
||||
const backends = storageData.value?.backends ?? []
|
||||
return !backend.isDefault
|
||||
&& backend.enabled
|
||||
&& backends.some(item => item.isDefault)
|
||||
}
|
||||
|
||||
function showMigrateOut(backend: StorageBackendItem): boolean {
|
||||
const backends = storageData.value?.backends ?? []
|
||||
if (backend.usage.count === 0) return false
|
||||
if (backend.isDefault) {
|
||||
return backends.some(item => !item.isDefault && item.enabled)
|
||||
}
|
||||
return backends.some(item => item.isDefault)
|
||||
}
|
||||
|
||||
const defaultBackendName = computed(() => {
|
||||
const backend = storageData.value?.backends.find(item => item.isDefault)
|
||||
return backend?.name ?? t('storage.backendLocal')
|
||||
@@ -297,14 +344,24 @@ watch(isAuthenticated, async (authed) => {
|
||||
:backend="backend"
|
||||
:env-override="storageData.envOverride"
|
||||
:busy="busyId === backend.id"
|
||||
:show-migrate-in="showMigrateIn(backend)"
|
||||
:show-migrate-out="showMigrateOut(backend)"
|
||||
@set-default="setDefault(backend)"
|
||||
@edit="openEdit(backend)"
|
||||
@test="testBackend(backend)"
|
||||
@toggle-enabled="toggleEnabled(backend)"
|
||||
@delete="confirmDelete(backend)"
|
||||
@migrate-in="onMigrateIn(backend)"
|
||||
@migrate-out="onMigrateOut(backend)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<StorageBackupPanel
|
||||
v-if="!storageData.envOverride"
|
||||
ref="backupPanelRef"
|
||||
:backends="storageData.backends"
|
||||
/>
|
||||
|
||||
<StorageSupportedBackends />
|
||||
</template>
|
||||
</section>
|
||||
|
||||
@@ -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,53 @@
|
||||
export type BackupJobType = 'full_export' | 'full_restore' | 'backend_sync'
|
||||
export type BackupJobStatus = 'pending' | 'running' | 'done' | 'failed'
|
||||
|
||||
export interface BackupJobItem {
|
||||
id: string
|
||||
type: BackupJobType
|
||||
status: BackupJobStatus
|
||||
progress: number
|
||||
total: number
|
||||
message: string
|
||||
payload: Record<string, unknown>
|
||||
outputPath: string | null
|
||||
archiveName: string | null
|
||||
downloadable: boolean
|
||||
error: string | null
|
||||
createdAt: string
|
||||
finishedAt: string | null
|
||||
}
|
||||
|
||||
export interface BackupEstimate {
|
||||
imageCount: number
|
||||
imageBytes: number
|
||||
databaseBytes: number
|
||||
totalBytes: number
|
||||
}
|
||||
|
||||
export interface BackupPackageItem {
|
||||
name: string
|
||||
bytes: number
|
||||
modifiedAt: string
|
||||
}
|
||||
|
||||
export interface BackupRestorePreview {
|
||||
imageCount: number
|
||||
imageBytes: number
|
||||
newImages: number
|
||||
conflictingImages: number
|
||||
formatVersion: number
|
||||
appVersion: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface BackendSyncPreview {
|
||||
imageCount: number
|
||||
imageBytes: number
|
||||
fromBackendId: string
|
||||
toBackendId: string
|
||||
}
|
||||
|
||||
export interface SyncDraft {
|
||||
fromBackendId: string
|
||||
toBackendId: 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)
|
||||
}
|
||||
@@ -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 | slider | clear-domains | backup-export | backup-restore | storage-sync
|
||||
volumes:
|
||||
# 冒号左边改成 NAS 上的实际存图目录(飞牛示例:/vol1/1000/pichost-data)
|
||||
- ./data:/data
|
||||
@@ -5,8 +5,24 @@ if [ "$1" = "reset-password" ]; then
|
||||
exec node /app/server/cli/reset-password.mjs "${@:2}"
|
||||
fi
|
||||
|
||||
if [ "$1" = "migrate" ]; then
|
||||
exec node /app/server/cli/migrate-to-single-images.mjs "${@:2}"
|
||||
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
|
||||
|
||||
if [ "$1" = "backup-export" ]; then
|
||||
exec node /app/server/cli/backup-export.mjs "${@:2}"
|
||||
fi
|
||||
|
||||
if [ "$1" = "backup-restore" ]; then
|
||||
exec node /app/server/cli/backup-restore.mjs "${@:2}"
|
||||
fi
|
||||
|
||||
if [ "$1" = "storage-sync" ]; then
|
||||
exec node /app/server/cli/storage-sync.mjs "${@:2}"
|
||||
fi
|
||||
|
||||
exec node /app/.output/server/index.mjs "$@"
|
||||
@@ -34,7 +34,6 @@ const zhSidebar = [
|
||||
text: '升级',
|
||||
items: [
|
||||
{ text: '更新日志', link: '/guide/changelog' },
|
||||
{ text: 'v1.2 迁移', link: '/guide/migration' },
|
||||
{ text: '常见问题', link: '/guide/faq' }
|
||||
]
|
||||
}
|
||||
@@ -71,7 +70,6 @@ const enSidebar = [
|
||||
text: 'Upgrade',
|
||||
items: [
|
||||
{ text: 'Changelog', link: '/en/guide/changelog' },
|
||||
{ text: 'v1.2 migration', link: '/en/guide/migration' },
|
||||
{ text: 'FAQ', link: '/en/guide/faq' }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -134,15 +159,12 @@ Version history and notable changes. The full log is also in the repo root [CHAN
|
||||
|
||||
### Changed
|
||||
|
||||
- All images under `data/images/`; legacy `blog/` / `twikoo/` need migration
|
||||
- All images under `data/images/`; parallel top-level folders beside `images` are no longer used
|
||||
|
||||
### Added
|
||||
|
||||
- CLI: `docker exec pichost migrate` / `migrate --apply`
|
||||
- Startup index sync (disk scan, legacy key normalization, orphan cleanup)
|
||||
|
||||
See [v1.2 migration](./migration.md).
|
||||
|
||||
---
|
||||
|
||||
## [1.1.x] summary
|
||||
|
||||
@@ -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).
|
||||
@@ -43,21 +61,21 @@ Recommended: run PicHost on origin; use **proxied DNS** (and optional **R2** sto
|
||||
- Always open admin on the **site hostname**, not mixed with `localhost`
|
||||
- See [Dual-domain separation](./domain-separation.md)
|
||||
|
||||
## Undeletable gallery items after v1.2 upgrade?
|
||||
## Undeletable gallery items?
|
||||
|
||||
Likely **orphan index** rows (no file on disk). Startup sync removes them on restart. See [v1.2 migration](./migration.md#step-2-startup-index-sync-automatic).
|
||||
Likely **orphan index** rows (no file on disk). Startup sync removes them on restart; you can also delete them from the gallery.
|
||||
|
||||
## Do I still need `data/blog/` after migration?
|
||||
## How do I back up or migrate the full site?
|
||||
|
||||
After `migrate --apply`, files live under `data/images/blog/`. Remove leftover empty dirs after backup if you like; dirs with non-image files are kept by the CLI.
|
||||
From v1.3.0, use **Storage → Backup & migration**, or CLI:
|
||||
|
||||
## Object storage only — run migrate?
|
||||
```bash
|
||||
docker exec pichost backup-export
|
||||
docker exec pichost backup-restore /data/backups/pichost-xxx.phost.tar.gz
|
||||
docker exec pichost storage-sync --from local --to s3-xxx --dry-run
|
||||
```
|
||||
|
||||
No. Skip the CLI; startup sync focuses on local `data/images/` scanning.
|
||||
|
||||
## Replace old image URLs in posts?
|
||||
|
||||
Use `data/mapping.json` from `migrate` preview. Keys are **storage paths**, not full legacy URLs. See [Migration](./migration.md#urls).
|
||||
Packages include the database and all images but **not** cloud storage secrets — re-enter them on the Storage page after restore. See [Storage → Backup & migration](./storage.md#backup--migration-v130).
|
||||
|
||||
## Skip login during development?
|
||||
|
||||
|
||||
@@ -33,17 +33,37 @@ See `docker-compose.yml` in the repository.
|
||||
## First-time setup
|
||||
|
||||
1. Open your instance URL (Docker default `http://<host>:6892`).
|
||||
2. Complete `/setup` with admin username and password.
|
||||
2. On `/setup`, choose **Create admin** or **Restore backup**:
|
||||
- **Create admin** — username, password, optional registration and dual-domain options.
|
||||
- **Restore backup** — upload a `.phost.tar.gz` full-site package (replaces current data; re-enter cloud storage secrets after restore).
|
||||
3. (Optional) Configure site URL, image URL, Referer rules — see [Environment variables](./configuration.md) and [Dual-domain separation](./domain-separation.md).
|
||||
4. Upload from the home page; use the top nav for **Gallery**, **Storage** (admin), **Settings**, and **API**.
|
||||
|
||||
| Create admin | Restore from backup |
|
||||
| :----------: | :-----------------: |
|
||||
|  |  |
|
||||
|
||||
For an already-initialized instance, use [Storage → Backup & migration](./storage.md#backup--migration-v130) instead of `/setup`.
|
||||
|
||||
## 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,11 +78,15 @@ A random password is printed. Change it after login.
|
||||
|
||||
Local dev: `npm run reset-password` or `npm run reset-password -- <username>`
|
||||
|
||||
## Upgrading to v1.2.0
|
||||
## Dual-domain lockout
|
||||
|
||||
If `data/` has legacy top-level folders next to `images` (e.g. `blog/`, `twikoo/`), read the [v1.2 migration guide](./migration.md) first.
|
||||
If dual-domain is enabled and IP / LAN access returns 404:
|
||||
|
||||
Object-storage-only or files already under `data/images/` — pull the new image and restart.
|
||||
```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).
|
||||
|
||||
## Next steps
|
||||
|
||||
|
||||
@@ -12,16 +12,20 @@ Welcome to the PicHost user guide. This site is the single source of truth; the
|
||||
|
||||
## Screenshots
|
||||
|
||||
**main** has no public demo. Below are current product screenshots (v1.2.5+).
|
||||
**main** has no public demo. Below are current product screenshots (v1.3.0+).
|
||||
|
||||
| API | Gallery |
|
||||
| :-: | :-----: |
|
||||
| API | Gallery (detail) |
|
||||
| :-: | :--------------: |
|
||||
|  |  |
|
||||
|
||||
| Storage | Settings |
|
||||
| :-----: | :-------: |
|
||||
|  |  |
|
||||
|
||||
| First-time setup | Restore from backup |
|
||||
| :--------------: | :-----------------: |
|
||||
|  |  |
|
||||
|
||||
| Activity log |
|
||||
| :----------: |
|
||||
|  |
|
||||
|
||||
@@ -28,7 +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 migrate` | Preview legacy dir migration (`-- --apply` to run) |
|
||||
| `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) |
|
||||
|
||||
## Dev-only variables
|
||||
|
||||
@@ -51,4 +52,4 @@ npm run docs:build
|
||||
npm run docs:preview
|
||||
```
|
||||
|
||||
Sources live in `docs-site/`; screenshot sources are in `docs/screenshots/` (including `login.png`), copied to `docs-site/public/screenshots/` before build. Live docs: <https://o96u.github.io/PicHost/>
|
||||
Sources live in `docs-site/`; screenshot sources are in `docs/screenshots/` (including `login.png`, `setup.png`, `setup-restore.png`, etc.), copied to `docs-site/public/screenshots/` before build. Live docs: <https://o96u.github.io/PicHost/>
|
||||
@@ -1,129 +0,0 @@
|
||||
# v1.2.0 local disk migration guide
|
||||
|
||||
From v1.2.0, **all local image files** live under `data/images/`; SQLite `keys` also start with `images/`. Legacy top-level folders next to `images` (e.g. `data/blog/`, `data/twikoo/`) are no longer used.
|
||||
|
||||
Migration is **two steps**: run the **CLI** to move files on disk, then let **startup sync** align the SQLite index.
|
||||
|
||||
---
|
||||
|
||||
## Migrating from other image hosts (EasyImage, Lsky, Chevereto, …)
|
||||
|
||||
There is **no** dedicated “pull from URLs” or “import via vendor API” tool. Moving from **legacy PicHost** or **another self-hosted** install uses the same **local file** workflow:
|
||||
|
||||
1. Locate the **image files** on the old server (copy the tree, not just the database).
|
||||
2. Place them under PicHost `data/` as a **top-level folder** next to `images` (any name — it becomes `images/{name}/...`).
|
||||
3. Run `migrate` / `migrate --apply`, then restart PicHost.
|
||||
|
||||
| Source | Typical path on old server | After copy into PicHost |
|
||||
| ------ | -------------------------- | ----------------------- |
|
||||
| PicHost v1.1.x | `data/blog/`, `data/twikoo/` | Already under `data/` → run `migrate` |
|
||||
| EasyImage(s) | `i/` or `upload/` | e.g. `data/easyimage/` → `migrate` |
|
||||
| Lsky | `public/uploads/` etc. | e.g. `data/lsky/` → `migrate` |
|
||||
| Chevereto etc. | `images/` or `content/` | e.g. `data/chevereto/` → `migrate` |
|
||||
| Custom | your folder name | `data/your-folder/` → `migrate` |
|
||||
|
||||
The CLI scans **every top-level directory under `data/` except `images`**, not only `blog` / `twikoo`.
|
||||
|
||||
**Limits:** files only — accounts, tokens, and old public URLs are not imported. `mapping.json` maps old **storage keys**, not full old-site URLs. For S3/R2-only legacy hosts, sync blobs to `data/…` or attach the bucket in PicHost **Storage** settings.
|
||||
|
||||
---
|
||||
|
||||
## Layout before and after
|
||||
|
||||
### Before (typical v1.1.x)
|
||||
|
||||
```
|
||||
/data
|
||||
├── pichost.db
|
||||
├── blog/
|
||||
│ └── 2026/08/TeKJ2fD6cB6j.webp
|
||||
├── twikoo/
|
||||
└── images/
|
||||
```
|
||||
|
||||
Index keys might look like `blog/2026/08/TeKJ2fD6cB6j.webp`.
|
||||
|
||||
### After (v1.2.0)
|
||||
|
||||
```
|
||||
/data
|
||||
├── pichost.db
|
||||
├── mapping.json
|
||||
└── images/
|
||||
├── blog/
|
||||
│ └── 2026/08/TeKJ2fD6cB6j.webp
|
||||
└── twikoo/
|
||||
```
|
||||
|
||||
Index key: `images/blog/2026/08/TeKJ2fD6cB6j.webp`.
|
||||
|
||||
---
|
||||
|
||||
## Step 1: CLI file migration
|
||||
|
||||
Run **before or after** upgrading the image; backup `data/` first.
|
||||
|
||||
```bash
|
||||
docker exec pichost migrate # preview + mapping.json
|
||||
docker exec pichost migrate --apply # move files
|
||||
```
|
||||
|
||||
Local: `npm run migrate` / `npm run migrate -- --apply`.
|
||||
|
||||
| Item | Behavior |
|
||||
| ---- | -------- |
|
||||
| Scan | Every top-level directory under `data/` **except `images`** |
|
||||
| Target | `data/images/{original-top-dir}/{relative-path}` |
|
||||
| Extensions | jpg, jpeg, png, webp, gif, svg, ico |
|
||||
| Sidecar | `.meta.json` next to an image is moved if present |
|
||||
| Other files | Not moved; parent directory kept if anything remains |
|
||||
| Mapping | `data/mapping.json` — old key → new key |
|
||||
|
||||
Script: `server/cli/migrate-to-single-images.mjs`.
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Startup index sync (automatic)
|
||||
|
||||
Runs on every start (`npm run dev`, Docker). No manual command.
|
||||
|
||||
```text
|
||||
[INFO] 开始同步图片索引:扫描本地磁盘 ...
|
||||
[INFO] 磁盘扫描完成:...
|
||||
[INFO] 开始归一化遗留图片路径 ...
|
||||
[INFO] 开始校验索引与磁盘一致性,清理无文件的孤儿记录
|
||||
[INFO] 图片索引同步结束
|
||||
```
|
||||
|
||||
| Step | Purpose |
|
||||
| ---- | ------- |
|
||||
| Scan `data/images/` | Insert missing index rows for files on disk |
|
||||
| Normalize legacy keys | `blog/...` → `images/blog/...` when files are in place |
|
||||
| Purge orphans | Remove index rows with no file on disk |
|
||||
| Repair Content-Type | Fix `application/octet-stream` entries |
|
||||
|
||||
The CLI only **moves files**. Index alignment runs at startup — **restart** after `migrate --apply`.
|
||||
|
||||
---
|
||||
|
||||
## Recommended upgrade flow
|
||||
|
||||
1. Backup `data/`.
|
||||
2. `docker exec pichost migrate` — review output and `mapping.json`.
|
||||
3. `docker exec pichost migrate --apply`.
|
||||
4. Deploy v1.2.0 and restart.
|
||||
5. Check logs for successful index sync.
|
||||
6. Use `mapping.json` to update external URLs if needed.
|
||||
|
||||
Skip step 1 if you only use object storage or everything is already under `data/images/`.
|
||||
|
||||
---
|
||||
|
||||
## URLs
|
||||
|
||||
| Old key | `blog/TeKJ2fD6cB6j.webp` |
|
||||
| New key | `images/blog/TeKJ2fD6cB6j.webp` |
|
||||
|
||||
With “hide folder” mode, short URLs like `/TeKJ2fD6cB6j.webp` may still resolve by filename. Use `mapping.json` for bulk replacements.
|
||||
|
||||
Chinese version: [v1.2 迁移指南](/guide/migration)
|
||||
@@ -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
|
||||
|
||||
@@ -54,13 +54,48 @@ Copied links use **IMAGE_BASE_URL**. “Hide folder prefix” may shorten URLs;
|
||||
## Usage & gallery filter
|
||||
|
||||
- Per-backend usage on the Storage page
|
||||
- Gallery filters by **storage backend** and **upload source**; grid/list view toggle
|
||||
- Gallery filters by **storage backend** and **upload source**; grid/list view toggle; click a thumbnail for the detail modal (dimensions, storage path, link formats)
|
||||
|
||||
## Backup
|
||||
## Backup & migration (v1.3.0+)
|
||||
|
||||
Back up `data/images/`, `data/pichost.db`, and bucket objects.
|
||||
The **Backup & migration** panel has three action cards and a paginated **Recent jobs** table below:
|
||||
|
||||
Upgrading from v1.1.x with parallel folders: [v1.2 migration](./migration.md).
|
||||
| Feature | Description |
|
||||
| ------- | ------------- |
|
||||
| **Create site backup** | Export `.phost.tar.gz` with `pichost.db` and all images (including objects on cloud backends); shows estimated size and package contents |
|
||||
| **Restore from backup** | Upload locally or pick an existing package under `data/backups/`, preview, then confirm; skip or overwrite conflicting images |
|
||||
| **Cross-backend sync** | Copy images between configured backends and update the index; shows pending count/size, dry-run, optional delete-source and set-default |
|
||||
|
||||

|
||||
|
||||
- Exports are saved under `data/backups/`; only one backup/migration job may run at a time
|
||||
- Job list supports pagination; download exports, view restore summaries, retry failed sync jobs
|
||||
- Packages **do not** include cloud storage secrets — re-enter them on the Storage page after restore
|
||||
- Uninitialized instances can use the **Restore backup** tab on `/setup` (always overwrite mode)
|
||||
|
||||

|
||||
|
||||
### CLI
|
||||
|
||||
```bash
|
||||
docker exec pichost backup-export
|
||||
docker exec pichost backup-restore /data/backups/pichost-xxx.phost.tar.gz
|
||||
docker exec pichost backup-restore /data/backups/pichost-xxx.phost.tar.gz --overwrite
|
||||
docker exec pichost storage-sync --from local --to s3-xxx --dry-run
|
||||
docker exec pichost storage-sync --from local --to s3-xxx --set-default
|
||||
```
|
||||
|
||||
Local development (project root):
|
||||
|
||||
```bash
|
||||
npm run backup-export
|
||||
npm run backup-restore -- data/backups/pichost-xxx.phost.tar.gz
|
||||
npm run storage-sync -- --from local --to s3-xxx --dry-run
|
||||
```
|
||||
|
||||
### Manual backup
|
||||
|
||||
You can still back up `data/images/`, `data/pichost.db`, and bucket objects directly; prefer the full-site export above.
|
||||
|
||||
## See also
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
### 新增
|
||||
@@ -134,15 +159,12 @@ PicHost 版本历史与重要变更说明。完整记录亦见仓库根目录 [C
|
||||
|
||||
### 变更
|
||||
|
||||
- 图片统一存放在 `data/images/`;遗留 `blog/`、`twikoo/` 等并列目录需迁移
|
||||
- 图片统一存放在 `data/images/`;不再使用与 `images` 并列的顶层目录
|
||||
|
||||
### 新增
|
||||
|
||||
- CLI:`docker exec pichost migrate` / `migrate --apply`
|
||||
- 启动时自动同步图片索引(扫描磁盘、归一化遗留 key、清理孤儿记录)
|
||||
|
||||
详见 [v1.2 迁移](./migration.md)。
|
||||
|
||||
---
|
||||
|
||||
## [1.1.x] 摘要
|
||||
|
||||
@@ -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)。
|
||||
@@ -44,21 +62,21 @@ docker exec pichost reset-password 用户名
|
||||
- 后台请用 **网站域名** 打开,不要用 `localhost` 混用
|
||||
- 见 [双域名分离](./domain-separation.md)
|
||||
|
||||
## 升级 v1.2 后图库有删不掉的记录?
|
||||
## 图库有删不掉的记录?
|
||||
|
||||
可能是 **孤儿索引**(有记录无文件)。重启后启动同步会自动清理;也可手动删除仅索引项。见 [v1.2 迁移](./migration.md#第二步启动时自动同步索引)。
|
||||
可能是 **孤儿索引**(有记录无文件)。重启后启动同步会自动清理;也可在图库中手动删除。
|
||||
|
||||
## 遗留 `data/blog/` 目录还要吗?
|
||||
## 如何备份与迁移整站?
|
||||
|
||||
执行 `migrate --apply` 后文件已迁入 `data/images/blog/`。若原目录只剩空壳或非图片文件,可自行备份后删除;迁移脚本不会删除仍含非图片文件的目录。
|
||||
v1.3.0 起在 **存储** 页使用「备份与迁移」面板,或 CLI:
|
||||
|
||||
## 只用对象存储需要跑 migrate 吗?
|
||||
```bash
|
||||
docker exec pichost backup-export
|
||||
docker exec pichost backup-restore /data/backups/pichost-xxx.phost.tar.gz
|
||||
docker exec pichost storage-sync --from local --to s3-xxx --dry-run
|
||||
```
|
||||
|
||||
不需要。图片在桶内时跳过 CLI;启动同步主要面向本地 `data/images/` 磁盘扫描。
|
||||
|
||||
## 如何替换博客里的旧图片 URL?
|
||||
|
||||
使用 `data/mapping.json`(`migrate` 预览时生成)批量替换旧 key。注意映射的是 **存储 key**,不是完整旧站 URL。见 [迁移指南](./migration.md#外链与直链)。
|
||||
备份包含数据库与全部图片,**不含** 云存储密钥;恢复后请在存储页重新填写。详见 [存储 → 备份与迁移](./storage.md#备份与迁移v130)。
|
||||
|
||||
## 开发时如何跳过登录?
|
||||
|
||||
|
||||
@@ -33,17 +33,37 @@ docker compose up -d
|
||||
## 首次设置
|
||||
|
||||
1. 访问实例 URL(Docker 默认 `http://<主机>:6892`)。
|
||||
2. 在 `/setup` 填写管理员用户名与密码。
|
||||
2. 在 `/setup` 选择 **创建管理员** 或 **从备份恢复**:
|
||||
- **创建管理员**:填写用户名与密码,可选开放注册、双域名等。
|
||||
- **从备份恢复**:上传 `.phost.tar.gz` 整站备份包(覆盖当前数据;云存储密钥需恢复后重新填写)。
|
||||
3. (可选)配置网站域名、图片域名、防盗链等 — 详见 [环境变量](./configuration.md) 与 [双域名分离](./domain-separation.md)。
|
||||
4. 登录后可在首页上传图片;顶栏可进入 **图库**、**存储**(管理员)、**设置**、**API**。
|
||||
|
||||
| 创建管理员 | 从备份恢复 |
|
||||
| :--------: | :--------: |
|
||||
|  |  |
|
||||
|
||||
已初始化实例请使用 [存储 → 备份与迁移](./storage.md#备份与迁移v130) 恢复,而非 setup 页。
|
||||
|
||||
## 登录
|
||||
|
||||
访问上传、图库、设置等管理页面前需登录。在登录卡片中填写用户名与密码,**拖动滑块对齐缺口**完成验证后点击 **登录**。
|
||||
访问上传、图库、设置等管理页面前需登录。在登录卡片中填写用户名与密码,完成人机验证后点击 **登录**。
|
||||
|
||||
默认使用**本地滑块**(拖动滑块对齐缺口)。管理员可在 **设置 → 访问控制** 切换为 **Cloudflare Turnstile** 或 **Cap**,并填写对应 Site Key / Secret 或 API 端点。
|
||||
|
||||

|
||||
|
||||
若管理员开启了开放注册,可前往注册页创建账号。遗留部署仍可能使用密钥登录(见界面提示)。
|
||||
若管理员开启了开放注册,可前往注册页创建账号(同样需完成人机验证)。遗留部署仍可能使用密钥登录(见界面提示)。
|
||||
|
||||
### 验证方式被锁死时
|
||||
|
||||
若 Turnstile / Cap 配置错误导致无法登录,可在服务器上重置为本地滑块:
|
||||
|
||||
```bash
|
||||
docker exec pichost slider
|
||||
```
|
||||
|
||||
本地开发:`npm run slider`
|
||||
|
||||
## 忘记密码
|
||||
|
||||
@@ -61,11 +81,15 @@ docker exec pichost reset-password 用户名
|
||||
|
||||
本地开发:`npm run reset-password` 或 `npm run reset-password -- 用户名`
|
||||
|
||||
## 升级到 v1.2.0
|
||||
## 双域名配置锁死
|
||||
|
||||
若 `data/` 下存在与 `images` 并列的遗留目录(如 `blog/`、`twikoo/`),升级前请阅读 [v1.2 迁移指南](./migration.md)。
|
||||
若启用双域名后通过 IP / 内网地址无法进入后台:
|
||||
|
||||
仅对象存储、或图片已在 `data/images/` 下时,拉取新镜像并重启即可。
|
||||
```bash
|
||||
docker exec pichost clear-domains
|
||||
```
|
||||
|
||||
本地:`npm run clear-domains`。详见 [常见问题 — 双域名配置后后台 404](./faq.md#双域名配置后后台-404-进不去)。
|
||||
|
||||
## 下一步
|
||||
|
||||
|
||||
@@ -12,9 +12,9 @@
|
||||
|
||||
## 截图预览
|
||||
|
||||
**main** 分支暂无公网演示,以下为当前产品界面(v1.2.5+)。
|
||||
**main** 分支暂无公网演示,以下为当前产品界面(v1.3.0+)。
|
||||
|
||||
| API | 图库 |
|
||||
| API | 图库(详情) |
|
||||
| :-: | :-: |
|
||||
|  |  |
|
||||
|
||||
@@ -22,6 +22,10 @@
|
||||
| :------: | :--------: |
|
||||
|  |  |
|
||||
|
||||
| 首次设置 | 从备份恢复 |
|
||||
| :------: | :--------: |
|
||||
|  |  |
|
||||
|
||||
| 操作日志 |
|
||||
| :------: |
|
||||
|  |
|
||||
|
||||
@@ -28,7 +28,8 @@ npm run dev
|
||||
| `npm run typecheck` | 类型检查 |
|
||||
| `npm test` | 单元测试 |
|
||||
| `npm run reset-password` | 重置密码(可选用户名) |
|
||||
| `npm run migrate` | 预览遗留目录迁移(加 `-- --apply` 执行) |
|
||||
| `npm run slider` | 将登录验证重置为本地滑块 |
|
||||
| `npm run clear-domains` | 清除数据库中的网站域 / 图片域配置(双域名锁死时恢复) |
|
||||
|
||||
## 开发专用环境变量
|
||||
|
||||
@@ -51,4 +52,4 @@ npm run docs:build # 构建静态站点
|
||||
npm run docs:preview # 预览构建结果
|
||||
```
|
||||
|
||||
文档源码在 `docs-site/`;截图源文件在 `docs/screenshots/`(含 `login.png` 登录页),构建前会复制到 `docs-site/public/screenshots/`。在线文档:<https://o96u.github.io/PicHost/>
|
||||
文档源码在 `docs-site/`;截图源文件在 `docs/screenshots/`(含 `login.png`、`setup.png`、`setup-restore.png` 等),构建前会复制到 `docs-site/public/screenshots/`。在线文档:<https://o96u.github.io/PicHost/>
|
||||
@@ -1,176 +0,0 @@
|
||||
# v1.2.0 本地磁盘迁移指南
|
||||
|
||||
v1.2.0 起,**所有本地图片文件**统一放在 `data/images/` 下;SQLite `images` 表中的 `key` 也以 `images/` 开头。旧版在 `data/blog/`、`data/twikoo/` 等与 `images` **并列的顶层目录**不再符合新模型。
|
||||
|
||||
迁移分 **两步**:先用手动 CLI **搬文件**,再由服务 **启动时自动同步索引**。
|
||||
|
||||
---
|
||||
|
||||
## 从其它图床迁入(EasyImage / 兰空 / Chevereto 等)
|
||||
|
||||
**没有**单独的「一键从外链拉取」或「对接某某图床 API」工具。从 **PicHost 旧版** 或其它自托管图床迁入,走的是同一套 **本地文件迁移**:
|
||||
|
||||
1. 在旧图床服务器上找到图片**物理目录**(整目录拷贝,不要只拷数据库)。
|
||||
2. 把该目录放到 PicHost 的 `data/` 下,作为与 `images` **并列的顶层文件夹**(文件夹名任意,会保留为 `images/{文件夹名}/...`)。
|
||||
3. 执行 `migrate` / `migrate --apply`,再重启 PicHost。
|
||||
|
||||
### 常见来源示例
|
||||
|
||||
| 来源 | 典型图片目录(在旧服务器上) | 放入 PicHost 后示例 |
|
||||
| ---- | ---------------------------- | ------------------- |
|
||||
| PicHost v1.1.x | `data/blog/`、`data/twikoo/` | 已在 `data/` 下,直接 `migrate` |
|
||||
| EasyImage / EasyImages | `i/` 或安装目录下的 `upload/` | 拷到 `data/easyimage/` 再 `migrate` |
|
||||
| 兰空图床 (Lsky) | `public/uploads/` 等 | 拷到 `data/lsky/` 再 `migrate` |
|
||||
| Chevereto 等 | `images/` 或 `content/` | 拷到 `data/chevereto/` 再 `migrate` |
|
||||
| 任意自定义目录 | 你的顶层文件夹名 | 拷到 `data/你的目录名/` 再 `migrate` |
|
||||
|
||||
脚本会扫描 `data/` 下**除 `images` 外所有顶层目录**里的图片,不限于 `blog` / `twikoo`。子目录结构会原样保留在 `images/{顶层名}/` 下。
|
||||
|
||||
### 操作示例(Docker)
|
||||
|
||||
```bash
|
||||
# 在宿主机:把从旧图床打包的目录放进挂载卷(名称自定)
|
||||
# 例如:./data/easyimage/2026/01/photo.jpg
|
||||
|
||||
docker exec pichost migrate # 预览
|
||||
docker exec pichost migrate --apply
|
||||
# 重启容器 → 启动日志会补全 SQLite 索引
|
||||
```
|
||||
|
||||
### 说明与限制
|
||||
|
||||
- **只迁移文件**:旧图床的账号、Token、外链 URL **不会**自动导入;`mapping.json` 里的 key 是「旧磁盘相对 key → 新 key」,**不是**旧站完整 URL,替换外链需自行对照。
|
||||
- **对象存储**:若旧图床图片在 S3/R2 桶里,请用桶工具同步到本地 `data/某目录/`,或直接在 PicHost **存储**页配置同一桶(不经过本 CLI)。
|
||||
- **已直接在 `data/images/` 下**:无需 `migrate`,重启后启动同步会自动建索引。
|
||||
|
||||
---
|
||||
|
||||
## 存储结构对比
|
||||
|
||||
### 迁移前(v1.1.x 常见)
|
||||
|
||||
```
|
||||
/data
|
||||
├── pichost.db
|
||||
├── blog/
|
||||
│ └── 2026/08/TeKJ2fD6cB6j.webp
|
||||
├── twikoo/
|
||||
│ └── xxx.webp
|
||||
└── images/ # 可能已有部分新图
|
||||
└── ...
|
||||
```
|
||||
|
||||
SQLite 中 `key` 可能是 `blog/2026/08/TeKJ2fD6cB6j.webp`(与磁盘顶层目录一致)。
|
||||
|
||||
### 迁移后(v1.2.0)
|
||||
|
||||
```
|
||||
/data
|
||||
├── pichost.db
|
||||
├── mapping.json # CLI 生成:旧 key → 新 key(供替换外链)
|
||||
└── images/
|
||||
├── blog/
|
||||
│ └── 2026/08/TeKJ2fD6cB6j.webp
|
||||
├── twikoo/
|
||||
│ └── xxx.webp
|
||||
└── ... # 新上传:images/随机ID.webp 或 images/年/月/随机ID.webp
|
||||
```
|
||||
|
||||
对应 `key`:`images/blog/2026/08/TeKJ2fD6cB6j.webp`。
|
||||
|
||||
---
|
||||
|
||||
## 第一步:CLI 迁移磁盘文件
|
||||
|
||||
在 **升级镜像之前或之后**均可执行;建议在升级前完成,并先备份整个 `data/` 目录。
|
||||
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
# 预览:列出待迁移文件,写入 data/mapping.json(旧 key → 新 key)
|
||||
docker exec pichost migrate
|
||||
|
||||
# 确认无误后执行文件移动
|
||||
docker exec pichost migrate --apply
|
||||
```
|
||||
|
||||
### 本地开发
|
||||
|
||||
```bash
|
||||
npm run migrate # 预览
|
||||
npm run migrate -- --apply # 执行
|
||||
```
|
||||
|
||||
### 脚本行为
|
||||
|
||||
| 项目 | 说明 |
|
||||
| ---- | ---- |
|
||||
| 扫描范围 | `data/` 下**除 `images` 外所有顶层目录**(不限于 blog/twikoo) |
|
||||
| 迁移目标 | `data/images/{原顶层目录名}/{相对路径}` |
|
||||
| 图片类型 | `.jpg` `.jpeg` `.png` `.webp` `.gif` `.svg` `.ico` |
|
||||
| 伴随文件 | 若存在同名的 `.meta.json` 会一并移动 |
|
||||
| 非图片文件 | 不移动;若目录内仍有其它文件,**保留原目录**不删除 |
|
||||
| 映射表 | `data/mapping.json`,例如 `"blog/foo.webp": "images/blog/foo.webp"` |
|
||||
|
||||
脚本路径:`server/cli/migrate-to-single-images.mjs`。
|
||||
|
||||
---
|
||||
|
||||
## 第二步:启动时自动同步索引
|
||||
|
||||
每次 PicHost 启动(`npm run dev`、Docker 容器启动)会自动执行,**无需手动命令**。日志示例:
|
||||
|
||||
```text
|
||||
[INFO] 开始同步图片索引:扫描本地磁盘 {"path":".../data/images/"}
|
||||
[INFO] 磁盘扫描完成:已补全缺失索引 {"inserted":3}
|
||||
[INFO] 开始归一化遗留图片路径(如 blog/ → images/blog/)
|
||||
[INFO] 遗留路径归一化完成:无需调整
|
||||
[INFO] 开始校验索引与磁盘一致性,清理无文件的孤儿记录
|
||||
[INFO] 孤儿索引清理完成:未发现孤儿记录
|
||||
[INFO] 开始修复存量 Content-Type 元数据
|
||||
[INFO] Content-Type 修复完成:无需修复
|
||||
[INFO] 图片索引同步结束
|
||||
```
|
||||
|
||||
| 步骤 | 作用 |
|
||||
| ---- | ---- |
|
||||
| 扫描 `data/images/` | 磁盘上有、索引里没有 → **补全** `images` 表 |
|
||||
| 归一化遗留 key | 将 `blog/...` 等旧 key 更新为 `images/blog/...`(文件已在正确路径时) |
|
||||
| 清理孤儿索引 | 索引里有记录、磁盘无文件 → **删除**幽灵记录(避免图库出现删不掉的项) |
|
||||
| 修复 Content-Type | 修正错误的 `application/octet-stream` |
|
||||
|
||||
CLI 只负责**搬文件**;索引对齐、遗留 key、孤儿记录由启动同步处理。执行 `migrate --apply` 后**重启一次**服务即可。
|
||||
|
||||
---
|
||||
|
||||
## 推荐升级流程
|
||||
|
||||
1. **备份** `data/`(含 `pichost.db` 与各目录)。
|
||||
2. `docker exec pichost migrate` 预览,检查输出与 `mapping.json`。
|
||||
3. `docker exec pichost migrate --apply` 执行迁移。
|
||||
4. 拉取 / 部署 **v1.2.0** 镜像并重启容器。
|
||||
5. 查看启动日志,确认「图片索引同步结束」无报错。
|
||||
6. 用 `mapping.json` 批量替换博客、Twikoo 等外链中的旧路径(若需要)。
|
||||
|
||||
仅使用对象存储、或图片早已全部在 `data/images/` 下时,**跳过第一步**;第二步仍会在启动时运行,通常几秒内完成且无变更日志。
|
||||
|
||||
---
|
||||
|
||||
## 外链与直链
|
||||
|
||||
| 场景 | 说明 |
|
||||
| ---- | ---- |
|
||||
| 旧 key | `blog/TeKJ2fD6cB6j.webp` |
|
||||
| 新 key | `images/blog/TeKJ2fD6cB6j.webp` |
|
||||
| 隐藏目录模式 | 若开启「隐藏 images 前缀」,短链可能为 `/TeKJ2fD6cB6j.webp`;服务按文件名反查索引 |
|
||||
| 替换建议 | 以 `mapping.json` 为准;或重新在图库复制新链接 |
|
||||
|
||||
---
|
||||
|
||||
## 相关命令
|
||||
|
||||
| 命令 | 说明 |
|
||||
| ---- | ---- |
|
||||
| `docker exec pichost migrate` | 预览磁盘迁移 |
|
||||
| `docker exec pichost migrate --apply` | 执行磁盘迁移 |
|
||||
| `docker exec pichost reset-password` | 重置密码(与迁移无关) |
|
||||
@@ -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 示例
|
||||
|
||||
|
||||
@@ -56,17 +56,53 @@ PicHost 支持 **本地磁盘** 与 **S3 兼容对象存储**(Cloudflare R2、
|
||||
## 用量与筛选
|
||||
|
||||
- 存储页展示各后端用量(本地扫描目录,对象存储查询 API)
|
||||
- 图库可按 **存储后端**、**上传来源** 筛选,支持网格/列表视图切换
|
||||
- 图库可按 **存储后端**、**上传来源** 筛选,支持网格/列表视图切换;点击图片打开详情弹窗(尺寸、存储路径、多格式链接复制)
|
||||
|
||||
## 备份
|
||||
## 备份与迁移(v1.3.0+)
|
||||
|
||||
请同时备份:
|
||||
存储页 **备份与迁移** 面板包含三张功能卡与下方 **最近任务** 列表:
|
||||
|
||||
- `data/images/`(本地后端)
|
||||
- `data/pichost.db`
|
||||
- 各云桶中的对象
|
||||
| 能力 | 说明 |
|
||||
| ---- | ---- |
|
||||
| **创建站点备份** | 导出 `.phost.tar.gz`,含 `pichost.db` 与全部图片(含云存储中的对象);展示预计体积与备份包含项 |
|
||||
| **从备份恢复** | 本地上传或选择 `data/backups/` 内已有包,预检后确认;可选跳过/覆盖冲突图片 |
|
||||
| **跨后端同步** | 在已配置后端间拷贝图片并更新索引;展示待迁移数量与体积,支持预检、删源、设默认 |
|
||||
|
||||
从 v1.1.x 升级且存在 `data/blog/` 等并列目录时,见 [v1.2 迁移](./migration.md)。
|
||||

|
||||
|
||||
- 导出文件保存在 `data/backups/`;后端同一时间仅允许一个进行中的备份/迁移任务
|
||||
- 任务列表支持分页,可下载导出包、查看恢复结果、重试失败的同步任务
|
||||
- 备份包 **不含** 云存储 Access Key / Secret Key,恢复后请在存储页重新填写
|
||||
- 未初始化实例可在 **setup** 页 **从备份恢复** 标签上传整站包(始终覆盖模式)
|
||||
|
||||

|
||||
|
||||
### CLI
|
||||
|
||||
```bash
|
||||
# 导出整站备份
|
||||
docker exec pichost backup-export
|
||||
|
||||
# 从备份恢复(默认跳过冲突图片)
|
||||
docker exec pichost backup-restore /data/backups/pichost-xxx.phost.tar.gz
|
||||
docker exec pichost backup-restore /data/backups/pichost-xxx.phost.tar.gz --overwrite
|
||||
|
||||
# 跨后端同步
|
||||
docker exec pichost storage-sync --from local --to s3-xxx --dry-run
|
||||
docker exec pichost storage-sync --from local --to s3-xxx --set-default
|
||||
```
|
||||
|
||||
本地开发(项目根目录):
|
||||
|
||||
```bash
|
||||
npm run backup-export
|
||||
npm run backup-restore -- data/backups/pichost-xxx.phost.tar.gz
|
||||
npm run storage-sync -- --from local --to s3-xxx --dry-run
|
||||
```
|
||||
|
||||
### 手动备份
|
||||
|
||||
仍可同时备份 `data/images/`(本地后端)、`data/pichost.db` 与各云桶中的对象;推荐使用上文整站导出。
|
||||
|
||||
## 相关
|
||||
|
||||
|
||||
@@ -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` 重置为本地滑块
|
||||
- 登录失败有速率限制
|
||||
|
||||
## 上传偏好与自动删除
|
||||
|
||||
|
Before Width: | Height: | Size: 151 KiB After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 90 KiB |
@@ -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",
|
||||
@@ -100,7 +110,18 @@
|
||||
"migrateSubmit": "Complete migration",
|
||||
"initSubmit": "Create and continue",
|
||||
"migrateSuccess": "Migration complete",
|
||||
"initSuccess": "Setup complete"
|
||||
"initSuccess": "Setup complete",
|
||||
"restoreTitle": "Restore from backup",
|
||||
"restoreSubtitle": "Upload a .phost.tar.gz to restore the full site",
|
||||
"restoreHint": "Restore replaces all current data. Cloud storage secrets are not in the backup—re-enter them on the Storage page after sign-in.",
|
||||
"restoreUpload": "Choose backup file",
|
||||
"restoreSubmit": "Start restore",
|
||||
"restoreProgress": "Restoring… {progress}/{total}",
|
||||
"restoreSuccess": "Restore complete. Sign in with the admin account from the backup.",
|
||||
"restoreFailed": "Restore failed",
|
||||
"restoreSecretsHint": "Cloud storage secrets are not in the backup. Re-enter them on the Storage page after sign-in.",
|
||||
"modeInit": "Create admin",
|
||||
"modeRestore": "Restore backup"
|
||||
},
|
||||
"register": {
|
||||
"title": "Register",
|
||||
@@ -212,6 +233,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",
|
||||
@@ -362,7 +399,125 @@
|
||||
"deleteConfirmBody": "Delete “{name}”? This cannot be undone.",
|
||||
"secretsRequired": "Access Key and Secret Key are required for new storage",
|
||||
"manageStorage": "Manage object storage",
|
||||
"manageStorageHint": "Configure R2 / S3 / COS backends and URL serving"
|
||||
"manageStorageHint": "Configure R2 / S3 / COS backends and URL serving",
|
||||
"backup": {
|
||||
"sectionTitle": "Backup & migration",
|
||||
"sectionSubtitle": "Full-site backup, restore, and cross-backend image migration",
|
||||
"siteTitle": "Site backup & restore",
|
||||
"siteSubtitle": "For migration, disaster recovery, and full-site restore",
|
||||
"siteFooterHint": "Backups include the database and images, not plain-text secret_json.",
|
||||
"exportTitle": "Create site backup",
|
||||
"exportSubtitle": "Package the database and all images as .phost.tar.gz",
|
||||
"exportAction": "Start backup",
|
||||
"exportStarted": "Backup job started",
|
||||
"exportFailed": "Failed to start backup",
|
||||
"exportPathHint": "Exports are saved under data/backups/",
|
||||
"exportIncludesTitle": "Package includes",
|
||||
"exportIncludesDb": "Database snapshot (pichost.db)",
|
||||
"exportIncludesImages": "All image files ({count})",
|
||||
"exportExcludesSecrets": "Excludes plain-text secret.json for cloud storage",
|
||||
"exportConcurrencyHint": "Only one backup or migration job can run at a time",
|
||||
"estimateLoading": "Estimating size…",
|
||||
"estimateImageCountLabel": "Images",
|
||||
"estimatePackageSizeLabel": "Est. package",
|
||||
"estimateDatabaseSnapshotLabel": "Database snapshot",
|
||||
"estimateStoragePathLabel": "Save location",
|
||||
"estimateStoragePathValue": "data/backups/",
|
||||
"estimateImages": "{count} images · {size}",
|
||||
"estimateDatabase": "Database ~{size}",
|
||||
"estimateTotal": "Total ~{size}",
|
||||
"restoreTitle": "Restore from backup",
|
||||
"restoreSubtitle": "Upload or pick a .phost.tar.gz on the server, preview, then confirm",
|
||||
"restoreUpload": "Choose backup file",
|
||||
"restoreUploadLabel": "Upload from device",
|
||||
"restoreUploadHint": "Click to select a .phost.tar.gz backup",
|
||||
"restoreUploadAction": "Upload & restore",
|
||||
"restoreServerAction": "Server backup",
|
||||
"restoreServerPackages": "Server backups",
|
||||
"restoreServerSelectPlaceholder": "Select a backup",
|
||||
"restorePreview": "Preview",
|
||||
"restorePreviewConfirm": "Preview & confirm",
|
||||
"restoreConfirm": "Confirm restore",
|
||||
"restoreConfirmTitle": "Confirm site restore",
|
||||
"restoreConfirmSource": "Source: {name}",
|
||||
"restoreOverwriteWarning": "Overwrite mode replaces conflicting images and the database. Ensure you have a current backup.",
|
||||
"restoreSelectRequired": "Select or upload a backup file",
|
||||
"restoreStarted": "Restore job started",
|
||||
"restoreFailed": "Restore failed",
|
||||
"conflictMode": "Conflict policy",
|
||||
"conflictSkip": "Skip existing images",
|
||||
"conflictOverwrite": "Overwrite existing images and database",
|
||||
"syncTitle": "Cross-backend sync",
|
||||
"syncMigrateTitle": "Cross-backend migration",
|
||||
"syncMigrateSubtitle": "Migrate images between configured backends",
|
||||
"syncSubtitle": "Copy images between configured backends and update the index",
|
||||
"syncFrom": "Source backend",
|
||||
"syncTo": "Target backend",
|
||||
"syncPendingImages": "Images to migrate",
|
||||
"syncPendingSize": "Est. size",
|
||||
"syncIncludesTitle": "What sync does",
|
||||
"syncIncludesCopy": "Copy image files from source to target backend",
|
||||
"syncIncludesIndex": "Update storage_backend_id in the index",
|
||||
"syncIncludesConfig": "Site settings and other backends are unchanged",
|
||||
"syncDryRun": "Dry run only",
|
||||
"syncSetDefaultAfter": "Set target as default after migration",
|
||||
"syncStartMigrate": "Start migration",
|
||||
"syncPreviewOnly": "Preview",
|
||||
"syncFooterHint": "Only image files and index are migrated; site settings are unchanged.",
|
||||
"syncDeleteSource": "Delete source files after sync",
|
||||
"syncSetDefault": "Set target as default after sync",
|
||||
"syncPreview": "Preview & start",
|
||||
"syncConfirm": "Start sync",
|
||||
"syncConfirmTitle": "Confirm cross-backend sync",
|
||||
"syncDeleteWarning": "Source files will be deleted. This cannot be undone.",
|
||||
"syncSelectRequired": "Select source and target backends",
|
||||
"syncSameBackend": "Source and target must differ",
|
||||
"syncStarted": "Sync job started",
|
||||
"syncFailed": "Sync failed",
|
||||
"previewFailed": "Preview failed",
|
||||
"previewImages": "Images",
|
||||
"previewNew": "New images",
|
||||
"previewConflicts": "Conflicts",
|
||||
"previewCreated": "Backup created",
|
||||
"previewSize": "Data size",
|
||||
"jobsTitle": "Recent jobs",
|
||||
"jobsSubtitle": "Track backup, restore, and migration progress",
|
||||
"jobsEmpty": "No backup or migration jobs yet",
|
||||
"jobsEmptyHint": "Progress appears here after export, restore, or sync starts",
|
||||
"jobsUnit": "jobs",
|
||||
"colTask": "Task",
|
||||
"colType": "Type",
|
||||
"colStatus": "Status",
|
||||
"colProgress": "Progress",
|
||||
"colMessage": "Message",
|
||||
"colCreated": "Created",
|
||||
"colActions": "Actions",
|
||||
"jobTypeExport": "Site export",
|
||||
"jobTypeRestore": "Site restore",
|
||||
"jobTypeSync": "Cross-backend sync",
|
||||
"statusPending": "Pending",
|
||||
"statusRunning": "Running",
|
||||
"statusDone": "Done",
|
||||
"statusFailed": "Failed",
|
||||
"jobFailedUnknown": "Unknown error",
|
||||
"download": "Download",
|
||||
"jobView": "View",
|
||||
"retry": "Retry",
|
||||
"retryStarted": "Retry started",
|
||||
"retryFailed": "Retry failed",
|
||||
"jobRunning": "Another job is already running. Try again later.",
|
||||
"migrateIn": "Migrate in",
|
||||
"migrateOut": "Migrate out",
|
||||
"migrateInHint": "Copy images from the default backend to this one",
|
||||
"migrateOutHint": "Move images out of this backend",
|
||||
"syncDraftApplied": "Sync form prefilled — review and start when ready",
|
||||
"syncNeedTwoBackends": "At least two storage backends are required to sync",
|
||||
"selectBackend": "Select backend",
|
||||
"syncConfirmRoute": "{from} → {to}",
|
||||
"cancel": "Cancel",
|
||||
"cancelDone": "Job cancelled",
|
||||
"cancelFailed": "Failed to cancel job"
|
||||
}
|
||||
},
|
||||
"stats": {
|
||||
"title": "Gallery",
|
||||
|
||||
@@ -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": "首次使用,创建管理员账号",
|
||||
@@ -100,7 +110,18 @@
|
||||
"migrateSubmit": "完成迁移",
|
||||
"initSubmit": "创建并进入",
|
||||
"migrateSuccess": "账号迁移完成",
|
||||
"initSuccess": "初始化完成"
|
||||
"initSuccess": "初始化完成",
|
||||
"restoreTitle": "从备份包恢复",
|
||||
"restoreSubtitle": "上传 .phost.tar.gz 恢复整站数据",
|
||||
"restoreHint": "恢复将覆盖当前全部数据。云存储密钥未包含在备份中,登录后请在存储页重新填写。",
|
||||
"restoreUpload": "选择备份文件",
|
||||
"restoreSubmit": "开始恢复",
|
||||
"restoreProgress": "恢复中… {progress}/{total}",
|
||||
"restoreSuccess": "恢复完成,请使用备份中的管理员账号登录",
|
||||
"restoreFailed": "恢复失败",
|
||||
"restoreSecretsHint": "云存储密钥未包含在备份中,登录后请在存储页重新填写",
|
||||
"modeInit": "创建管理员",
|
||||
"modeRestore": "从备份恢复"
|
||||
},
|
||||
"register": {
|
||||
"title": "注册账号",
|
||||
@@ -212,6 +233,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": "上传便捷功能",
|
||||
@@ -362,7 +399,125 @@
|
||||
"deleteConfirmBody": "确定删除「{name}」?此操作不可撤销。",
|
||||
"secretsRequired": "新建存储需填写 Access Key 与 Secret Key",
|
||||
"manageStorage": "管理对象存储",
|
||||
"manageStorageHint": "配置 R2 / S3 / COS 等多后端与直链策略"
|
||||
"manageStorageHint": "配置 R2 / S3 / COS 等多后端与直链策略",
|
||||
"backup": {
|
||||
"sectionTitle": "备份与迁移",
|
||||
"sectionSubtitle": "整站备份恢复与跨后端图片迁移",
|
||||
"siteTitle": "站点备份与恢复",
|
||||
"siteSubtitle": "用于换机、灾备与整站恢复",
|
||||
"siteFooterHint": "备份包含数据库与图片,不包含 secret_json 明文。",
|
||||
"exportTitle": "创建站点备份",
|
||||
"exportSubtitle": "打包数据库与全部图片为 .phost.tar.gz",
|
||||
"exportAction": "开始备份",
|
||||
"exportStarted": "备份任务已开始",
|
||||
"exportFailed": "创建备份失败",
|
||||
"exportPathHint": "导出文件保存在 data/backups/",
|
||||
"exportIncludesTitle": "备份包含",
|
||||
"exportIncludesDb": "数据库快照(pichost.db)",
|
||||
"exportIncludesImages": "全部图片文件({count} 张)",
|
||||
"exportExcludesSecrets": "不含云存储 secret.json 明文",
|
||||
"exportConcurrencyHint": "同时仅可运行一个备份或迁移任务",
|
||||
"estimateLoading": "正在估算体积…",
|
||||
"estimateImageCountLabel": "图片总数",
|
||||
"estimatePackageSizeLabel": "预计备份包",
|
||||
"estimateDatabaseSnapshotLabel": "数据库快照",
|
||||
"estimateStoragePathLabel": "保存位置",
|
||||
"estimateStoragePathValue": "data/backups/",
|
||||
"estimateImages": "图片 {count} 张 · {size}",
|
||||
"estimateDatabase": "数据库约 {size}",
|
||||
"estimateTotal": "合计约 {size}",
|
||||
"restoreTitle": "从备份恢复",
|
||||
"restoreSubtitle": "上传或选择服务器上的 .phost.tar.gz,预检后确认恢复",
|
||||
"restoreUpload": "选择备份文件",
|
||||
"restoreUploadLabel": "本地上传",
|
||||
"restoreUploadHint": "点击选择 .phost.tar.gz 备份包",
|
||||
"restoreUploadAction": "上传备份恢复",
|
||||
"restoreServerAction": "选择服务器备份",
|
||||
"restoreServerPackages": "服务器已有备份",
|
||||
"restoreServerSelectPlaceholder": "选择备份包",
|
||||
"restorePreview": "预检",
|
||||
"restorePreviewConfirm": "预检并确认",
|
||||
"restoreConfirm": "确认恢复",
|
||||
"restoreConfirmTitle": "确认恢复站点",
|
||||
"restoreConfirmSource": "来源:{name}",
|
||||
"restoreOverwriteWarning": "覆盖模式将替换冲突图片与数据库,请确认已做好当前站点备份",
|
||||
"restoreSelectRequired": "请选择或上传备份文件",
|
||||
"restoreStarted": "恢复任务已开始",
|
||||
"restoreFailed": "恢复失败",
|
||||
"conflictMode": "冲突策略",
|
||||
"conflictSkip": "跳过已存在图片",
|
||||
"conflictOverwrite": "覆盖已存在图片与数据库",
|
||||
"syncTitle": "跨后端同步",
|
||||
"syncMigrateTitle": "跨后端迁移",
|
||||
"syncMigrateSubtitle": "在已配置后端之间迁移图片",
|
||||
"syncSubtitle": "在已配置的后端之间拷贝图片并更新索引",
|
||||
"syncFrom": "源后端",
|
||||
"syncTo": "目标后端",
|
||||
"syncPendingImages": "待迁移图片",
|
||||
"syncPendingSize": "预计体积",
|
||||
"syncIncludesTitle": "同步说明",
|
||||
"syncIncludesCopy": "拷贝源端图片文件到目标后端",
|
||||
"syncIncludesIndex": "更新索引中的 storage_backend_id",
|
||||
"syncIncludesConfig": "不修改站点配置与其它后端数据",
|
||||
"syncDryRun": "仅预览 Dry Run",
|
||||
"syncSetDefaultAfter": "迁移后设为默认后端",
|
||||
"syncStartMigrate": "开始迁移",
|
||||
"syncPreviewOnly": "预览",
|
||||
"syncFooterHint": "仅迁移图片文件与索引,不影响站点配置。",
|
||||
"syncDeleteSource": "同步后删除源端文件",
|
||||
"syncSetDefault": "同步后将目标设为默认",
|
||||
"syncPreview": "预检并开始",
|
||||
"syncConfirm": "开始同步",
|
||||
"syncConfirmTitle": "确认跨后端同步",
|
||||
"syncDeleteWarning": "将删除源后端上的图片文件,此操作不可撤销",
|
||||
"syncSelectRequired": "请选择源与目标后端",
|
||||
"syncSameBackend": "源与目标不能相同",
|
||||
"syncStarted": "同步任务已开始",
|
||||
"syncFailed": "同步失败",
|
||||
"previewFailed": "预检失败",
|
||||
"previewImages": "图片数量",
|
||||
"previewNew": "新增图片",
|
||||
"previewConflicts": "冲突图片",
|
||||
"previewCreated": "备份时间",
|
||||
"previewSize": "数据量",
|
||||
"jobsTitle": "最近任务",
|
||||
"jobsSubtitle": "查看备份、恢复与迁移进度",
|
||||
"jobsEmpty": "暂无备份或迁移任务",
|
||||
"jobsEmptyHint": "导出、恢复或同步开始后,进度会显示在这里",
|
||||
"jobsUnit": "条任务",
|
||||
"colTask": "任务",
|
||||
"colType": "类型",
|
||||
"colStatus": "状态",
|
||||
"colProgress": "进度",
|
||||
"colMessage": "说明",
|
||||
"colCreated": "创建时间",
|
||||
"colActions": "操作",
|
||||
"jobTypeExport": "站点导出",
|
||||
"jobTypeRestore": "站点恢复",
|
||||
"jobTypeSync": "跨后端同步",
|
||||
"statusPending": "等待中",
|
||||
"statusRunning": "进行中",
|
||||
"statusDone": "已完成",
|
||||
"statusFailed": "失败",
|
||||
"jobFailedUnknown": "未知错误",
|
||||
"download": "下载",
|
||||
"jobView": "查看",
|
||||
"retry": "重试",
|
||||
"retryStarted": "重试任务已开始",
|
||||
"retryFailed": "重试失败",
|
||||
"jobRunning": "已有任务正在运行,请稍后再试",
|
||||
"migrateIn": "迁入",
|
||||
"migrateOut": "迁出",
|
||||
"migrateInHint": "从默认后端拷贝图片到此后端",
|
||||
"migrateOutHint": "将图片从此后端迁出",
|
||||
"syncDraftApplied": "已填入同步表单,请确认后开始",
|
||||
"syncNeedTwoBackends": "至少需要两个存储后端才能同步",
|
||||
"selectBackend": "选择后端",
|
||||
"syncConfirmRoute": "{from} → {to}",
|
||||
"cancel": "取消",
|
||||
"cancelDone": "任务已取消",
|
||||
"cancelFailed": "取消任务失败"
|
||||
}
|
||||
},
|
||||
"stats": {
|
||||
"title": "图库",
|
||||
|
||||
@@ -21,6 +21,12 @@ export default defineNuxtConfig({
|
||||
|
||||
css: ['~/assets/css/main.css'],
|
||||
|
||||
vue: {
|
||||
compilerOptions: {
|
||||
isCustomElement: tag => tag.startsWith('cap-')
|
||||
}
|
||||
},
|
||||
|
||||
colorMode: {
|
||||
preference: 'system',
|
||||
fallback: 'light'
|
||||
|
||||
@@ -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,13 +14,16 @@
|
||||
"@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"
|
||||
"tailwindcss": "^4.3.1",
|
||||
"tar": "^7.5.22"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nuxt/eslint": "^1.16.0",
|
||||
"@types/node": "^22.15.0",
|
||||
"@types/tar": "^6.1.13",
|
||||
"eslint": "^10.6.0",
|
||||
"mermaid": "^11.17.2",
|
||||
"typescript": "^6.0.3",
|
||||
@@ -8771,6 +8774,27 @@
|
||||
"integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/tar": {
|
||||
"version": "6.1.13",
|
||||
"resolved": "https://registry.npmmirror.com/@types/tar/-/tar-6.1.13.tgz",
|
||||
"integrity": "sha512-IznnlmU5f4WcGTh2ltRu/Ijpmk8wiWXfF0VA4s+HPjHZgvFggk1YaIkbo5krX/zUCzWF8N/l4+W/LNxnvAJ8nw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"minipass": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/tar/node_modules/minipass": {
|
||||
"version": "4.2.8",
|
||||
"resolved": "https://registry.npmmirror.com/minipass/-/minipass-4.2.8.tgz",
|
||||
"integrity": "sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/trusted-types": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmmirror.com/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
@@ -10704,6 +10728,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",
|
||||
@@ -18790,9 +18820,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tar": {
|
||||
"version": "7.5.20",
|
||||
"resolved": "https://registry.npmmirror.com/tar/-/tar-7.5.20.tgz",
|
||||
"integrity": "sha512-9FcyK4PA6+WbzlTM9WhQm6vB5W7cP7dUiPsv1g7YDwEQnQ1CGpK3MGlKk/ITVWMk05kHZuBhmVhiv8LZoy/PFQ==",
|
||||
"version": "7.5.22",
|
||||
"resolved": "https://registry.npmmirror.com/tar/-/tar-7.5.22.tgz",
|
||||
"integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"@isaacs/fs-minipass": "^4.0.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pichost",
|
||||
"version": "1.2.6",
|
||||
"version": "1.3.0",
|
||||
"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": {
|
||||
@@ -34,7 +34,11 @@
|
||||
"test:watch": "vitest",
|
||||
"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",
|
||||
"backup-export": "node server/cli/backup-export.mjs",
|
||||
"backup-restore": "node server/cli/backup-restore.mjs",
|
||||
"storage-sync": "node server/cli/storage-sync.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,13 +49,16 @@
|
||||
"@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"
|
||||
"tailwindcss": "^4.3.1",
|
||||
"tar": "^7.5.22"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nuxt/eslint": "^1.16.0",
|
||||
"@types/node": "^22.15.0",
|
||||
"@types/tar": "^6.1.13",
|
||||
"eslint": "^10.6.0",
|
||||
"mermaid": "^11.17.2",
|
||||
"typescript": "^6.0.3",
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { requireAdminAuth } from '../../../utils/access'
|
||||
import { getBackupExportEstimate } from '../../../utils/backup/stats'
|
||||
import { getBackupsDir, isSafeBackupFilename } from '../../../utils/backup/paths'
|
||||
import { promises as fs } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await requireAdminAuth(event)
|
||||
const estimate = await getBackupExportEstimate()
|
||||
|
||||
const packages = await (async () => {
|
||||
try {
|
||||
await fs.mkdir(getBackupsDir(), { recursive: true })
|
||||
const entries = await fs.readdir(getBackupsDir(), { withFileTypes: true })
|
||||
const items = await Promise.all(
|
||||
entries
|
||||
.filter(entry => entry.isFile() && isSafeBackupFilename(entry.name))
|
||||
.map(async (entry) => {
|
||||
const full = join(getBackupsDir(), entry.name)
|
||||
const fileStat = await fs.stat(full)
|
||||
return {
|
||||
name: entry.name,
|
||||
bytes: fileStat.size,
|
||||
modifiedAt: fileStat.mtime.toISOString()
|
||||
}
|
||||
})
|
||||
)
|
||||
items.sort((a, b) => b.modifiedAt.localeCompare(a.modifiedAt))
|
||||
return items
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
})()
|
||||
|
||||
return {
|
||||
estimate,
|
||||
backupsDir: getBackupsDir(),
|
||||
packages
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
import { requireAdminAuth } from '../../../utils/access'
|
||||
import { createApiError } from '../../../utils/api-error'
|
||||
import { serializeBackupJob } from '../../../utils/backup/api'
|
||||
import { getBackupExportEstimate } from '../../../utils/backup/stats'
|
||||
import { startFullExportJob } from '../../../utils/backup/runner'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await requireAdminAuth(event)
|
||||
const config = useRuntimeConfig(event)
|
||||
const appVersion = String(config.appVersion ?? '0.0.0')
|
||||
|
||||
const estimate = await getBackupExportEstimate()
|
||||
|
||||
try {
|
||||
const job = startFullExportJob(appVersion)
|
||||
return {
|
||||
job: serializeBackupJob(job),
|
||||
estimate
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'BACKUP_JOB_RUNNING') {
|
||||
createApiError(event, 'CONFLICT', '已有备份或迁移任务正在运行', 409)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
import { requireAdminAuth } from '../../../../utils/access'
|
||||
import { createApiError } from '../../../../utils/api-error'
|
||||
import { serializeBackupJob } from '../../../../utils/backup/api'
|
||||
import { getBackupJob } from '../../../../utils/backup/jobs'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await requireAdminAuth(event)
|
||||
const id = getRouterParam(event, 'id')
|
||||
if (!id) {
|
||||
createApiError(event, 'INVALID_REQUEST', '缺少任务 ID', 400)
|
||||
}
|
||||
|
||||
const job = getBackupJob(id)
|
||||
if (!job) {
|
||||
createApiError(event, 'INVALID_REQUEST', '任务不存在', 404)
|
||||
}
|
||||
|
||||
return { job: serializeBackupJob(job) }
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
import { requireAdminAuth } from '../../../../../utils/access'
|
||||
import { createApiError } from '../../../../../utils/api-error'
|
||||
import { serializeBackupJob } from '../../../../../utils/backup/api'
|
||||
import { getBackupJob } from '../../../../../utils/backup/jobs'
|
||||
import { cancelBackupJob } from '../../../../../utils/backup/runner'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await requireAdminAuth(event)
|
||||
const id = getRouterParam(event, 'id')
|
||||
if (!id) {
|
||||
createApiError(event, 'INVALID_REQUEST', '缺少任务 ID', 400)
|
||||
}
|
||||
|
||||
if (!cancelBackupJob(id)) {
|
||||
createApiError(event, 'INVALID_REQUEST', '只能取消进行中的任务', 400)
|
||||
}
|
||||
|
||||
const job = getBackupJob(id)
|
||||
return { job: job ? serializeBackupJob(job) : null }
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { basename } from 'node:path'
|
||||
import { requireAdminAuth } from '../../../../../utils/access'
|
||||
import { createApiError } from '../../../../../utils/api-error'
|
||||
import { getBackupJob } from '../../../../../utils/backup/jobs'
|
||||
import { BACKUP_ARCHIVE_EXT } from '../../../../../utils/backup/paths'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await requireAdminAuth(event)
|
||||
const id = getRouterParam(event, 'id')
|
||||
if (!id) {
|
||||
createApiError(event, 'INVALID_REQUEST', '缺少任务 ID', 400)
|
||||
}
|
||||
|
||||
const job = getBackupJob(id)
|
||||
if (!job || job.type !== 'full_export' || job.status !== 'done' || !job.outputPath) {
|
||||
createApiError(event, 'INVALID_REQUEST', '可下载的备份不存在', 404)
|
||||
}
|
||||
|
||||
const filename = basename(job.outputPath)
|
||||
if (!filename.endsWith(BACKUP_ARCHIVE_EXT)) {
|
||||
createApiError(event, 'INVALID_REQUEST', '备份文件无效', 404)
|
||||
}
|
||||
|
||||
setResponseHeader(event, 'Content-Type', 'application/gzip')
|
||||
setResponseHeader(event, 'Content-Disposition', `attachment; filename="${filename}"`)
|
||||
|
||||
return sendStream(event, createReadStream(job.outputPath))
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
import { requireAdminAuth } from '../../../../../utils/access'
|
||||
import { createApiError } from '../../../../../utils/api-error'
|
||||
import { retryFailedBackendSync } from '../../../../../utils/backup/backend-sync'
|
||||
import { serializeBackupJob } from '../../../../../utils/backup/api'
|
||||
import { getBackupJob } from '../../../../../utils/backup/jobs'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await requireAdminAuth(event)
|
||||
const id = getRouterParam(event, 'id')
|
||||
if (!id) {
|
||||
createApiError(event, 'INVALID_REQUEST', '缺少任务 ID', 400)
|
||||
}
|
||||
|
||||
const job = getBackupJob(id)
|
||||
if (!job || job.type !== 'backend_sync') {
|
||||
createApiError(event, 'INVALID_REQUEST', '任务不存在', 404)
|
||||
}
|
||||
|
||||
try {
|
||||
await retryFailedBackendSync(id)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '重试失败'
|
||||
createApiError(event, 'INVALID_REQUEST', message, 400)
|
||||
}
|
||||
|
||||
const updated = getBackupJob(id)
|
||||
return { job: updated ? serializeBackupJob(updated) : null }
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
import { requireAdminAuth } from '../../../../utils/access'
|
||||
import { serializeBackupJob } from '../../../../utils/backup/api'
|
||||
import {
|
||||
getActiveBackupJob,
|
||||
getRunningBackupJob,
|
||||
listBackupJobsPage,
|
||||
recoverInterruptedBackupJobs
|
||||
} from '../../../../utils/backup/jobs'
|
||||
import { isBackupRunnerBusy } from '../../../../utils/backup/runner'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await requireAdminAuth(event)
|
||||
if (getRunningBackupJob() && !isBackupRunnerBusy()) {
|
||||
recoverInterruptedBackupJobs()
|
||||
}
|
||||
const query = getQuery(event)
|
||||
const pageSize = Math.min(50, Math.max(1, Number(query.pageSize) || 10))
|
||||
const pageNum = Math.max(1, Number(query.pageNum) || Number(query.page) || 1)
|
||||
const { jobs, total } = listBackupJobsPage(pageNum, pageSize)
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize))
|
||||
const page = total > 0 ? Math.min(pageNum, totalPages) : 1
|
||||
const slice = page === pageNum
|
||||
? jobs
|
||||
: listBackupJobsPage(page, pageSize).jobs
|
||||
|
||||
const activeJob = getActiveBackupJob()
|
||||
|
||||
return {
|
||||
jobs: slice.map(serializeBackupJob),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages,
|
||||
activeJob: activeJob ? serializeBackupJob(activeJob) : null
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { H3Event } from 'h3'
|
||||
import { requireAdminAuth } from '../../../utils/access'
|
||||
import { createApiError } from '../../../utils/api-error'
|
||||
import { serializeBackupJob } from '../../../utils/backup/api'
|
||||
import { previewFullRestore } from '../../../utils/backup/full-restore'
|
||||
import { startFullRestoreJob } from '../../../utils/backup/runner'
|
||||
import {
|
||||
BACKUP_ARCHIVE_EXT,
|
||||
getBackupsDir,
|
||||
getBackupStagingDir,
|
||||
isSafeBackupFilename
|
||||
} from '../../../utils/backup/paths'
|
||||
import { join } from 'node:path'
|
||||
import { promises as fs } from 'node:fs'
|
||||
|
||||
interface RestoreBody {
|
||||
archiveName?: string
|
||||
conflictMode?: 'skip' | 'overwrite'
|
||||
dryRun?: boolean
|
||||
}
|
||||
|
||||
async function resolveRestoreInput(event: H3Event): Promise<{
|
||||
archivePath: string
|
||||
body: RestoreBody
|
||||
cleanup?: () => Promise<void>
|
||||
}> {
|
||||
const contentType = getHeader(event, 'content-type') ?? ''
|
||||
|
||||
if (contentType.includes('multipart/form-data')) {
|
||||
const form = await readMultipartFormData(event)
|
||||
const filePart = form?.find(part => part.name === 'file' && part.data?.length)
|
||||
if (!filePart?.data) {
|
||||
createApiError(event, 'INVALID_REQUEST', '请上传备份文件', 400)
|
||||
}
|
||||
|
||||
const conflictField = form?.find(part => part.name === 'conflictMode')?.data?.toString('utf8')
|
||||
const dryRunField = form?.find(part => part.name === 'dryRun')?.data?.toString('utf8')
|
||||
const dryRun = dryRunField === 'true'
|
||||
|
||||
if (dryRun) {
|
||||
const tempDir = getBackupStagingDir(`upload-preview-${Date.now()}`)
|
||||
await fs.mkdir(tempDir, { recursive: true })
|
||||
const archivePath = join(tempDir, 'package.phost.tar.gz')
|
||||
await fs.writeFile(archivePath, filePart.data)
|
||||
return {
|
||||
archivePath,
|
||||
body: {
|
||||
conflictMode: conflictField === 'skip' ? 'skip' : 'overwrite',
|
||||
dryRun: true
|
||||
},
|
||||
cleanup: async () => {
|
||||
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await fs.mkdir(getBackupsDir(), { recursive: true })
|
||||
const safeName = `upload-${Date.now()}${BACKUP_ARCHIVE_EXT}`
|
||||
const archivePath = join(getBackupsDir(), safeName)
|
||||
await fs.writeFile(archivePath, filePart.data)
|
||||
return {
|
||||
archivePath,
|
||||
body: {
|
||||
conflictMode: conflictField === 'skip' ? 'skip' : 'overwrite',
|
||||
dryRun: false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const body = await readBody<RestoreBody>(event)
|
||||
if (!body?.archiveName || !isSafeBackupFilename(body.archiveName)) {
|
||||
createApiError(event, 'INVALID_REQUEST', '请选择有效的备份文件', 400)
|
||||
}
|
||||
const archivePath = join(getBackupsDir(), body.archiveName)
|
||||
try {
|
||||
await fs.access(archivePath)
|
||||
} catch {
|
||||
createApiError(event, 'INVALID_REQUEST', '备份文件不存在', 400)
|
||||
}
|
||||
return { archivePath, body }
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await requireAdminAuth(event)
|
||||
|
||||
const { archivePath, body, cleanup } = await resolveRestoreInput(event)
|
||||
|
||||
const conflictMode = body.conflictMode === 'skip' ? 'skip' : 'overwrite'
|
||||
|
||||
if (body.dryRun) {
|
||||
try {
|
||||
const preview = await previewFullRestore(archivePath)
|
||||
return { preview }
|
||||
} finally {
|
||||
await cleanup?.()
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const job = startFullRestoreJob(archivePath, conflictMode)
|
||||
return { job: serializeBackupJob(job) }
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'BACKUP_JOB_RUNNING') {
|
||||
createApiError(event, 'CONFLICT', '已有备份或迁移任务正在运行', 409)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
import { requireAdminAuth } from '../../../utils/access'
|
||||
import { createApiError } from '../../../utils/api-error'
|
||||
import { serializeBackupJob } from '../../../utils/backup/api'
|
||||
import { previewBackendSync } from '../../../utils/backup/backend-sync'
|
||||
import { startBackendSyncJob } from '../../../utils/backup/runner'
|
||||
|
||||
interface SyncBody {
|
||||
fromBackendId?: string
|
||||
toBackendId?: string
|
||||
deleteSource?: boolean
|
||||
setDefault?: boolean
|
||||
dryRun?: boolean
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await requireAdminAuth(event)
|
||||
const body = await readBody<SyncBody>(event)
|
||||
|
||||
const fromBackendId = body?.fromBackendId?.trim() ?? ''
|
||||
const toBackendId = body?.toBackendId?.trim() ?? ''
|
||||
if (!fromBackendId || !toBackendId) {
|
||||
createApiError(event, 'INVALID_REQUEST', '请选择源与目标存储后端', 400)
|
||||
}
|
||||
|
||||
const options = {
|
||||
fromBackendId,
|
||||
toBackendId,
|
||||
deleteSource: Boolean(body?.deleteSource),
|
||||
setDefault: Boolean(body?.setDefault),
|
||||
dryRun: Boolean(body?.dryRun)
|
||||
}
|
||||
|
||||
try {
|
||||
if (options.dryRun) {
|
||||
const preview = previewBackendSync(options)
|
||||
return { preview }
|
||||
}
|
||||
|
||||
const job = startBackendSyncJob(options)
|
||||
return { job: serializeBackupJob(job) }
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error.message === 'BACKUP_JOB_RUNNING') {
|
||||
createApiError(event, 'CONFLICT', '已有备份或迁移任务正在运行', 409)
|
||||
}
|
||||
createApiError(event, 'INVALID_REQUEST', error.message, 400)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
import { createApiError } from '../../utils/api-error'
|
||||
import { isInitialized } from '../../utils/auth'
|
||||
import { serializeBackupJob } from '../../utils/backup/api'
|
||||
import { startFullRestoreJob } from '../../utils/backup/runner'
|
||||
import { BACKUP_ARCHIVE_EXT, getBackupsDir } from '../../utils/backup/paths'
|
||||
import { join } from 'node:path'
|
||||
import { promises as fs } from 'node:fs'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
if (isInitialized()) {
|
||||
createApiError(event, 'FORBIDDEN', '系统已初始化,请在存储页恢复备份', 403)
|
||||
}
|
||||
|
||||
const form = await readMultipartFormData(event)
|
||||
const filePart = form?.find(part => part.name === 'file' && part.data?.length)
|
||||
if (!filePart?.data) {
|
||||
createApiError(event, 'INVALID_REQUEST', '请上传备份文件', 400)
|
||||
}
|
||||
|
||||
await fs.mkdir(getBackupsDir(), { recursive: true })
|
||||
const archivePath = join(getBackupsDir(), `setup-restore-${Date.now()}${BACKUP_ARCHIVE_EXT}`)
|
||||
await fs.writeFile(archivePath, filePart.data)
|
||||
|
||||
try {
|
||||
const job = startFullRestoreJob(archivePath, 'overwrite')
|
||||
return {
|
||||
job: serializeBackupJob(job),
|
||||
message: '恢复任务已开始,完成后请使用备份中的管理员账号登录'
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'BACKUP_JOB_RUNNING') {
|
||||
createApiError(event, 'CONFLICT', '已有备份或迁移任务正在运行', 409)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
import { createApiError } from '../../../utils/api-error'
|
||||
import { serializeBackupJob } from '../../../utils/backup/api'
|
||||
import { getBackupJob } from '../../../utils/backup/jobs'
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
const id = getRouterParam(event, 'id')
|
||||
if (!id) {
|
||||
createApiError(event, 'INVALID_REQUEST', '缺少任务 ID', 400)
|
||||
}
|
||||
|
||||
const job = getBackupJob(id)
|
||||
if (!job || job.type !== 'full_restore') {
|
||||
createApiError(event, 'INVALID_REQUEST', '任务不存在', 404)
|
||||
}
|
||||
|
||||
return { job: serializeBackupJob(job) }
|
||||
})
|
||||
@@ -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,14 @@
|
||||
import { createJiti } from 'jiti'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
|
||||
export const jiti = createJiti(import.meta.url, {
|
||||
interopDefault: true,
|
||||
moduleCache: false
|
||||
})
|
||||
|
||||
export function loadServerModule(path) {
|
||||
return jiti(join(root, path))
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Export a full PicHost site backup (.phost.tar.gz).
|
||||
*
|
||||
* Usage:
|
||||
* node server/cli/backup-export.mjs
|
||||
*
|
||||
* Docker:
|
||||
* docker exec pichost backup-export
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { loadServerModule } from './backup-cli-loader.mjs'
|
||||
|
||||
const { ensureBackupSchema } = loadServerModule('utils/backup/schema.ts')
|
||||
const { createBackupJob, getBackupJob } = loadServerModule('utils/backup/jobs.ts')
|
||||
const { runFullExportJob } = loadServerModule('utils/backup/full-export.ts')
|
||||
|
||||
function readAppVersion() {
|
||||
try {
|
||||
const pkg = JSON.parse(readFileSync(join(process.cwd(), 'package.json'), 'utf8'))
|
||||
return String(pkg.version ?? '0.0.0')
|
||||
} catch {
|
||||
return '0.0.0'
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
ensureBackupSchema()
|
||||
const job = createBackupJob({
|
||||
type: 'full_export',
|
||||
message: 'CLI 导出'
|
||||
})
|
||||
|
||||
await runFullExportJob(job.id, readAppVersion())
|
||||
const done = getBackupJob(job.id)
|
||||
if (!done || done.status !== 'done') {
|
||||
console.error(`[PicHost] Export failed: ${done?.error ?? done?.message ?? 'unknown error'}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log(`[PicHost] Export complete: ${done.outputPath}`)
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(`[PicHost] Export error: ${error instanceof Error ? error.message : String(error)}`)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Restore PicHost from a .phost.tar.gz backup.
|
||||
*
|
||||
* Usage:
|
||||
* node server/cli/backup-restore.mjs <archive-path> [--overwrite]
|
||||
*
|
||||
* Docker:
|
||||
* docker exec pichost backup-restore /app/data/backups/pichost-xxx.phost.tar.gz
|
||||
*/
|
||||
import { access } from 'node:fs/promises'
|
||||
import { resolve } from 'node:path'
|
||||
import { loadServerModule } from './backup-cli-loader.mjs'
|
||||
|
||||
const { ensureBackupSchema } = loadServerModule('utils/backup/schema.ts')
|
||||
const { createBackupJob, getBackupJob } = loadServerModule('utils/backup/jobs.ts')
|
||||
const { previewFullRestore } = loadServerModule('utils/backup/full-restore.ts')
|
||||
const { runFullRestoreJob } = loadServerModule('utils/backup/full-restore.ts')
|
||||
|
||||
function usage() {
|
||||
console.error('Usage: backup-restore <archive-path> [--overwrite]')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const args = process.argv.slice(2).filter(arg => arg !== '--')
|
||||
if (!args.length) usage()
|
||||
|
||||
const overwrite = args.includes('--overwrite')
|
||||
const archiveArg = args.find(arg => arg !== '--overwrite')
|
||||
if (!archiveArg) usage()
|
||||
|
||||
const archivePath = resolve(archiveArg)
|
||||
const conflictMode = overwrite ? 'overwrite' : 'skip'
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
await access(archivePath)
|
||||
} catch {
|
||||
console.error(`[PicHost] Backup file not found: ${archivePath}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
ensureBackupSchema()
|
||||
const preview = await previewFullRestore(archivePath)
|
||||
console.log(`[PicHost] Preview: ${preview.imageCount} images, ${preview.newImages} new, ${preview.conflictingImages} conflicts`)
|
||||
|
||||
const job = createBackupJob({
|
||||
type: 'full_restore',
|
||||
message: 'CLI 恢复',
|
||||
payload: { archivePath, conflictMode }
|
||||
})
|
||||
|
||||
await runFullRestoreJob(job.id, archivePath, { conflictMode })
|
||||
const done = getBackupJob(job.id)
|
||||
if (!done || done.status !== 'done') {
|
||||
console.error(`[PicHost] Restore failed: ${done?.error ?? done?.message ?? 'unknown error'}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('[PicHost] Restore complete. Restart PicHost if it is running.')
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(`[PicHost] Restore error: ${error instanceof Error ? error.message : String(error)}`)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -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.')
|
||||
@@ -1,166 +0,0 @@
|
||||
/**
|
||||
* 将 data/ 下与 images 并列的顶层目录中的图片迁入 data/images/{原目录名}/...
|
||||
*
|
||||
* 用法:
|
||||
* node server/cli/migrate-to-single-images.mjs # dry-run
|
||||
* node server/cli/migrate-to-single-images.mjs --apply # 执行迁移
|
||||
*
|
||||
* Docker:
|
||||
* docker exec pichost migrate
|
||||
* docker exec pichost migrate --apply
|
||||
*
|
||||
* 输出 data/mapping.json(旧 key → 新 key)供替换外链。执行后重启 PicHost。
|
||||
*/
|
||||
import { promises as fs } from 'node:fs'
|
||||
import { join, relative, dirname } from 'node:path'
|
||||
|
||||
const DATA_DIR = process.env.DATA_DIR || join(process.cwd(), 'data')
|
||||
const IMAGES_DIR = join(DATA_DIR, 'images')
|
||||
|
||||
const IMAGE_EXT = /\.(jpg|jpeg|png|webp|gif|svg|ico)$/i
|
||||
const SKIP_TOP_LEVEL_DIRS = new Set(['images'])
|
||||
|
||||
const apply = process.argv.includes('--apply')
|
||||
|
||||
async function exists(path) {
|
||||
try {
|
||||
await fs.access(path)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function collectImageFiles(dir) {
|
||||
const files = []
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true })
|
||||
for (const entry of entries) {
|
||||
const full = join(dir, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...await collectImageFiles(full))
|
||||
} else if (
|
||||
entry.isFile()
|
||||
&& IMAGE_EXT.test(entry.name)
|
||||
&& !entry.name.endsWith('.meta.json')
|
||||
) {
|
||||
files.push(full)
|
||||
}
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
async function dirHasAnyFiles(dir) {
|
||||
let entries
|
||||
try {
|
||||
entries = await fs.readdir(dir, { withFileTypes: true })
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (entry.isFile()) return true
|
||||
if (entry.isDirectory() && await dirHasAnyFiles(join(dir, entry.name))) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (!await exists(DATA_DIR)) {
|
||||
console.error(`数据目录不存在: ${DATA_DIR}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const topEntries = await fs.readdir(DATA_DIR, { withFileTypes: true })
|
||||
const sourceDirs = topEntries
|
||||
.filter(entry => entry.isDirectory() && !SKIP_TOP_LEVEL_DIRS.has(entry.name))
|
||||
.map(entry => ({ name: entry.name, path: join(DATA_DIR, entry.name) }))
|
||||
|
||||
if (!sourceDirs.length) {
|
||||
console.log('未发现需迁移的顶层目录(data/ 下除 images 外无其它目录)')
|
||||
return
|
||||
}
|
||||
|
||||
const mapping = {}
|
||||
const plan = []
|
||||
|
||||
for (const { name, path } of sourceDirs) {
|
||||
const files = await collectImageFiles(path)
|
||||
for (const filePath of files) {
|
||||
const rel = relative(path, filePath).replace(/\\/g, '/')
|
||||
const newKey = `images/${name}/${rel}`
|
||||
const newDisk = join(IMAGES_DIR, name, rel)
|
||||
const oldKey = `${name}/${rel}`
|
||||
mapping[oldKey] = newKey
|
||||
plan.push({ from: filePath, to: newDisk, oldKey, newKey, sourceName: name })
|
||||
}
|
||||
}
|
||||
|
||||
if (!plan.length) {
|
||||
console.log('未发现需迁移的图片(已扫描 data/ 下除 images 外的顶层目录)')
|
||||
return
|
||||
}
|
||||
|
||||
const scanned = sourceDirs.map(dir => dir.name).join(', ')
|
||||
console.log(`已扫描顶层目录: ${scanned}`)
|
||||
console.log(`模式: ${apply ? 'APPLY' : 'DRY-RUN'}`)
|
||||
console.log(`待迁移 ${plan.length} 个文件:`)
|
||||
for (const item of plan.slice(0, 20)) {
|
||||
console.log(` ${item.oldKey} → ${item.newKey}`)
|
||||
}
|
||||
if (plan.length > 20) {
|
||||
console.log(` … 另有 ${plan.length - 20} 个`)
|
||||
}
|
||||
|
||||
const mappingPath = join(DATA_DIR, 'mapping.json')
|
||||
await fs.writeFile(mappingPath, JSON.stringify(mapping, null, 2))
|
||||
console.log(`已写入 ${mappingPath}`)
|
||||
|
||||
if (!apply) {
|
||||
console.log('未执行文件移动。确认后运行:')
|
||||
console.log(' docker exec pichost migrate --apply')
|
||||
console.log(' 或 npm run migrate -- --apply')
|
||||
return
|
||||
}
|
||||
|
||||
await fs.mkdir(IMAGES_DIR, { recursive: true })
|
||||
|
||||
for (const item of plan) {
|
||||
await fs.mkdir(dirname(item.to), { recursive: true })
|
||||
try {
|
||||
await fs.rename(item.from, item.to)
|
||||
} catch (error) {
|
||||
if (error?.code === 'EXDEV') {
|
||||
await fs.copyFile(item.from, item.to)
|
||||
await fs.unlink(item.from)
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
const metaFrom = item.from + '.meta.json'
|
||||
const metaTo = item.to + '.meta.json'
|
||||
try {
|
||||
await fs.rename(metaFrom, metaTo)
|
||||
} catch {
|
||||
// optional meta
|
||||
}
|
||||
}
|
||||
|
||||
const sourceNames = [...new Set(plan.map(item => item.sourceName))]
|
||||
for (const name of sourceNames) {
|
||||
const path = join(DATA_DIR, name)
|
||||
if (await dirHasAnyFiles(path)) {
|
||||
console.log(`保留 ${name}/(仍有未迁移文件)`)
|
||||
continue
|
||||
}
|
||||
await fs.rm(path, { recursive: true, force: true })
|
||||
console.log(`已移除空目录 ${name}/`)
|
||||
}
|
||||
|
||||
console.log('迁移完成。请重启 PicHost。')
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -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.')
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Sync images between storage backends.
|
||||
*
|
||||
* Usage:
|
||||
* node server/cli/storage-sync.mjs --from <id> --to <id> [--delete-source] [--set-default] [--dry-run]
|
||||
*
|
||||
* Docker:
|
||||
* docker exec pichost storage-sync --from local --to s3-xxx
|
||||
*/
|
||||
import { loadServerModule } from './backup-cli-loader.mjs'
|
||||
|
||||
const { ensureBackupSchema } = loadServerModule('utils/backup/schema.ts')
|
||||
const { createBackupJob, getBackupJob } = loadServerModule('utils/backup/jobs.ts')
|
||||
const { previewBackendSync, runBackendSyncJob } = loadServerModule('utils/backup/backend-sync.ts')
|
||||
|
||||
function usage() {
|
||||
console.error('Usage: storage-sync --from <backend-id> --to <backend-id> [--delete-source] [--set-default] [--dry-run]')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
function readArg(name) {
|
||||
const index = process.argv.indexOf(name)
|
||||
if (index < 0 || index + 1 >= process.argv.length) return ''
|
||||
return process.argv[index + 1]?.trim() ?? ''
|
||||
}
|
||||
|
||||
const fromBackendId = readArg('--from')
|
||||
const toBackendId = readArg('--to')
|
||||
const deleteSource = process.argv.includes('--delete-source')
|
||||
const setDefault = process.argv.includes('--set-default')
|
||||
const dryRun = process.argv.includes('--dry-run')
|
||||
|
||||
if (!fromBackendId || !toBackendId) usage()
|
||||
|
||||
const options = {
|
||||
fromBackendId,
|
||||
toBackendId,
|
||||
deleteSource,
|
||||
setDefault,
|
||||
dryRun
|
||||
}
|
||||
|
||||
async function main() {
|
||||
ensureBackupSchema()
|
||||
|
||||
if (dryRun) {
|
||||
const preview = previewBackendSync(options)
|
||||
console.log(`[PicHost] Dry run: ${preview.imageCount} images, ${preview.imageBytes} bytes`)
|
||||
console.log(`[PicHost] From ${preview.fromBackendId} → ${preview.toBackendId}`)
|
||||
return
|
||||
}
|
||||
|
||||
const job = createBackupJob({
|
||||
type: 'backend_sync',
|
||||
message: 'CLI 同步',
|
||||
payload: options
|
||||
})
|
||||
|
||||
await runBackendSyncJob(job.id, options)
|
||||
const done = getBackupJob(job.id)
|
||||
if (!done || done.status !== 'done') {
|
||||
console.error(`[PicHost] Sync failed: ${done?.error ?? done?.message ?? 'unknown error'}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log(`[PicHost] Sync complete: ${done.progress}/${done.total}`)
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(`[PicHost] Sync error: ${error instanceof Error ? error.message : String(error)}`)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
import { recoverInterruptedBackupJobs } from '../utils/backup/jobs'
|
||||
|
||||
export default defineNitroPlugin(() => {
|
||||
const recovered = recoverInterruptedBackupJobs()
|
||||
if (recovered > 0) {
|
||||
console.log(`[PicHost] Recovered ${recovered} interrupted backup job(s)`)
|
||||
}
|
||||
})
|
||||
@@ -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(
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { BackupJob } from '../../utils/backup/jobs'
|
||||
import { BACKUP_ARCHIVE_EXT } from '../../utils/backup/paths'
|
||||
|
||||
export function serializeBackupJob(job: BackupJob) {
|
||||
const archiveName = job.outputPath
|
||||
? job.outputPath.split(/[/\\]/).pop()
|
||||
: typeof job.payload.archiveName === 'string'
|
||||
? job.payload.archiveName
|
||||
: null
|
||||
|
||||
return {
|
||||
id: job.id,
|
||||
type: job.type,
|
||||
status: job.status,
|
||||
progress: job.progress,
|
||||
total: job.total,
|
||||
message: job.message,
|
||||
payload: job.payload,
|
||||
outputPath: job.outputPath,
|
||||
archiveName,
|
||||
downloadable: Boolean(
|
||||
job.type === 'full_export'
|
||||
&& job.status === 'done'
|
||||
&& job.outputPath
|
||||
&& archiveName?.endsWith(BACKUP_ARCHIVE_EXT)
|
||||
),
|
||||
error: job.error,
|
||||
createdAt: job.createdAt,
|
||||
finishedAt: job.finishedAt
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import { promises as fs } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
createBackendInstance,
|
||||
getStorageBackendRow,
|
||||
setDefaultBackend
|
||||
} from '../storage-backends'
|
||||
import {
|
||||
getImageIndexAsStored,
|
||||
listImageIndexRowsByBackend,
|
||||
updateImageBackendId
|
||||
} from '../image-index'
|
||||
import { createImageStream } from '../storage'
|
||||
import { getBackupJob, markJobDone, markJobFailed, updateBackupJob } from './jobs'
|
||||
import { getBackupStagingDir } from './paths'
|
||||
|
||||
export interface BackendSyncOptions {
|
||||
fromBackendId: string
|
||||
toBackendId: string
|
||||
deleteSource?: boolean
|
||||
setDefault?: boolean
|
||||
dryRun?: boolean
|
||||
}
|
||||
|
||||
export interface BackendSyncPreview {
|
||||
imageCount: number
|
||||
imageBytes: number
|
||||
fromBackendId: string
|
||||
toBackendId: string
|
||||
}
|
||||
|
||||
export function previewBackendSync(options: BackendSyncOptions): BackendSyncPreview {
|
||||
const fromRow = getStorageBackendRow(options.fromBackendId)
|
||||
const toRow = getStorageBackendRow(options.toBackendId)
|
||||
if (!fromRow) {
|
||||
throw new Error('源存储后端不存在')
|
||||
}
|
||||
if (!toRow) {
|
||||
throw new Error('目标存储后端不存在')
|
||||
}
|
||||
if (!toRow.enabled) {
|
||||
throw new Error('目标存储后端未启用')
|
||||
}
|
||||
if (options.fromBackendId === options.toBackendId) {
|
||||
throw new Error('源与目标存储后端不能相同')
|
||||
}
|
||||
|
||||
const rows = listImageIndexRowsByBackend(options.fromBackendId)
|
||||
const imageBytes = rows.reduce((sum, row) => sum + row.size, 0)
|
||||
return {
|
||||
imageCount: rows.length,
|
||||
imageBytes,
|
||||
fromBackendId: options.fromBackendId,
|
||||
toBackendId: options.toBackendId
|
||||
}
|
||||
}
|
||||
|
||||
async function streamToBuffer(stream: NodeJS.ReadableStream): Promise<Buffer> {
|
||||
const chunks: Buffer[] = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
|
||||
}
|
||||
return Buffer.concat(chunks)
|
||||
}
|
||||
|
||||
export async function runBackendSyncJob(
|
||||
jobId: string,
|
||||
options: BackendSyncOptions
|
||||
): Promise<void> {
|
||||
const preview = previewBackendSync(options)
|
||||
if (options.dryRun) {
|
||||
markJobDone(jobId, {
|
||||
message: '预检完成',
|
||||
payload: { preview }
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const fromRow = getStorageBackendRow(options.fromBackendId)!
|
||||
const toRow = getStorageBackendRow(options.toBackendId)!
|
||||
const fromBackend = createBackendInstance(fromRow)
|
||||
const toBackend = createBackendInstance(toRow)
|
||||
|
||||
const rows = listImageIndexRowsByBackend(options.fromBackendId)
|
||||
const failedKeys: string[] = []
|
||||
const failedLogPath = join(getBackupStagingDir(jobId), 'failed_keys.jsonl')
|
||||
|
||||
updateBackupJob(jobId, {
|
||||
total: rows.length,
|
||||
progress: 0,
|
||||
message: '正在同步图片…'
|
||||
})
|
||||
|
||||
let processed = 0
|
||||
for (const row of rows) {
|
||||
try {
|
||||
const stream = await createImageStream(row.key)
|
||||
const bytes = await streamToBuffer(stream)
|
||||
await toBackend.put(row.key, bytes, {
|
||||
originalName: row.original_name,
|
||||
uploadedAt: row.uploaded_at,
|
||||
contentType: row.content_type,
|
||||
size: row.size,
|
||||
userId: row.user_id
|
||||
})
|
||||
|
||||
const head = await toBackend.head(row.key)
|
||||
if (!head) {
|
||||
throw new Error('目标后端写入校验失败')
|
||||
}
|
||||
|
||||
updateImageBackendId(row.key, options.toBackendId)
|
||||
|
||||
if (options.deleteSource) {
|
||||
await fromBackend.delete(row.key)
|
||||
}
|
||||
} catch (error) {
|
||||
failedKeys.push(row.key)
|
||||
await fs.mkdir(join(failedLogPath, '..'), { recursive: true }).catch(() => {})
|
||||
await fs.appendFile(
|
||||
failedLogPath,
|
||||
`${row.key}\t${error instanceof Error ? error.message : 'unknown'}\n`,
|
||||
'utf8'
|
||||
).catch(() => {})
|
||||
}
|
||||
|
||||
processed += 1
|
||||
updateBackupJob(jobId, {
|
||||
progress: processed,
|
||||
message: `正在同步 ${processed}/${rows.length}…`
|
||||
})
|
||||
}
|
||||
|
||||
if (options.setDefault) {
|
||||
setDefaultBackend(options.toBackendId)
|
||||
}
|
||||
|
||||
if (failedKeys.length > 0) {
|
||||
markJobFailed(jobId, `${failedKeys.length} 张图片同步失败`, `部分失败:${failedKeys.length}/${rows.length}`)
|
||||
updateBackupJob(jobId, {
|
||||
payload: { failedKeys, failedLogPath, preview }
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
markJobDone(jobId, {
|
||||
message: '同步完成',
|
||||
payload: { preview, synced: rows.length }
|
||||
})
|
||||
}
|
||||
|
||||
export async function retryFailedBackendSync(jobId: string): Promise<void> {
|
||||
const failedLogPath = join(getBackupStagingDir(jobId), 'failed_keys.jsonl')
|
||||
let raw: string
|
||||
try {
|
||||
raw = await fs.readFile(failedLogPath, 'utf8')
|
||||
} catch {
|
||||
throw new Error('没有可重试的失败记录')
|
||||
}
|
||||
|
||||
const keys = raw
|
||||
.split('\n')
|
||||
.map(line => line.split('\t')[0]?.trim())
|
||||
.filter((key): key is string => Boolean(key))
|
||||
|
||||
const job = getBackupJob(jobId)
|
||||
if (!job) throw new Error('任务不存在')
|
||||
|
||||
const fromBackendId = String(job.payload.fromBackendId ?? '')
|
||||
const toBackendId = String(job.payload.toBackendId ?? '')
|
||||
const deleteSource = Boolean(job.payload.deleteSource)
|
||||
const fromRow = getStorageBackendRow(fromBackendId)
|
||||
const toRow = getStorageBackendRow(toBackendId)
|
||||
if (!fromRow || !toRow) throw new Error('存储后端配置无效')
|
||||
|
||||
const fromBackend = createBackendInstance(fromRow)
|
||||
const toBackend = createBackendInstance(toRow)
|
||||
|
||||
const stillFailed: string[] = []
|
||||
for (const key of keys) {
|
||||
const row = getImageIndexAsStored(key)
|
||||
if (!row) continue
|
||||
try {
|
||||
const stream = await createImageStream(key)
|
||||
const bytes = await streamToBuffer(stream)
|
||||
await toBackend.put(key, bytes, {
|
||||
originalName: row.originalName,
|
||||
uploadedAt: row.uploadedAt,
|
||||
contentType: row.contentType,
|
||||
size: row.size,
|
||||
userId: row.userId
|
||||
})
|
||||
updateImageBackendId(key, toBackendId)
|
||||
if (deleteSource) {
|
||||
await fromBackend.delete(key)
|
||||
}
|
||||
} catch {
|
||||
stillFailed.push(key)
|
||||
}
|
||||
}
|
||||
|
||||
if (stillFailed.length > 0) {
|
||||
await fs.writeFile(
|
||||
failedLogPath,
|
||||
stillFailed.map(key => `${key}\tretry failed\n`).join(''),
|
||||
'utf8'
|
||||
)
|
||||
throw new Error(`仍有 ${stillFailed.length} 张图片同步失败`)
|
||||
}
|
||||
|
||||
await fs.rm(failedLogPath, { force: true }).catch(() => {})
|
||||
markJobDone(jobId, { message: '失败项重试完成' })
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { copyFile, mkdir, rename, rm } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { closeDb, getDb, setDbReplacing } from '../db'
|
||||
import { getDataDir } from '../data-dir'
|
||||
|
||||
async function retireCurrentDatabaseFiles(dbPath: string, stamp: number): Promise<void> {
|
||||
const targets = [
|
||||
dbPath,
|
||||
`${dbPath}-wal`,
|
||||
`${dbPath}-shm`
|
||||
]
|
||||
for (const path of targets) {
|
||||
try {
|
||||
await rename(path, `${path}.bak-${stamp}`)
|
||||
} catch {
|
||||
// Missing sidecar files are fine.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupRetiredDatabaseFiles(dbPath: string, stamp: number): Promise<void> {
|
||||
const paths = [
|
||||
dbPath,
|
||||
`${dbPath}-wal`,
|
||||
`${dbPath}-shm`
|
||||
]
|
||||
for (const path of paths) {
|
||||
await rm(`${path}.bak-${stamp}`, { force: true }).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
function sqlitePathLiteral(path: string): string {
|
||||
return path.replace(/\\/g, '/').replace(/'/g, '\'\'')
|
||||
}
|
||||
|
||||
export function checkpointDatabase(): void {
|
||||
getDb().exec('PRAGMA wal_checkpoint(TRUNCATE)')
|
||||
}
|
||||
|
||||
/** Consistent snapshot while the app keeps the DB open (safe on Windows). */
|
||||
export async function copyDatabaseSnapshot(targetPath: string): Promise<void> {
|
||||
await mkdir(join(targetPath, '..'), { recursive: true })
|
||||
checkpointDatabase()
|
||||
const escaped = sqlitePathLiteral(targetPath)
|
||||
getDb().exec(`VACUUM INTO '${escaped}'`)
|
||||
}
|
||||
|
||||
export async function replaceDatabaseFromSnapshot(sourcePath: string): Promise<void> {
|
||||
const dataDir = getDataDir()
|
||||
const target = join(dataDir, 'pichost.db')
|
||||
const stamp = Date.now()
|
||||
|
||||
checkpointDatabase()
|
||||
closeDb()
|
||||
setDbReplacing(true)
|
||||
|
||||
try {
|
||||
await retireCurrentDatabaseFiles(target, stamp)
|
||||
await copyFile(sourcePath, target)
|
||||
} finally {
|
||||
setDbReplacing(false)
|
||||
const reopened = getDb()
|
||||
reopened.exec('PRAGMA journal_mode=WAL')
|
||||
void cleanupRetiredDatabaseFiles(target, stamp)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { createWriteStream, promises as fs } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { pipeline } from 'node:stream/promises'
|
||||
import * as tar from 'tar'
|
||||
import { getImageTotals, listAllImageIndexRows } from '../image-index'
|
||||
import {
|
||||
listStorageBackendRows
|
||||
} from '../storage-backends'
|
||||
import { parseS3Config } from '../storage/s3'
|
||||
import { createImageStream } from '../storage'
|
||||
import { copyDatabaseSnapshot } from './db-snapshot'
|
||||
import { markJobDone, markJobFailed, updateBackupJob } from './jobs'
|
||||
import { BACKUP_FORMAT_VERSION, type BackupManifest, type StorageBackendMetaExport } from './manifest'
|
||||
import {
|
||||
BACKUP_ARCHIVE_EXT,
|
||||
getBackupStagingDir,
|
||||
getBackupsDir
|
||||
} from './paths'
|
||||
import type { StorageBackendRow } from '../storage/types'
|
||||
|
||||
function formatArchiveName(jobId: string): string {
|
||||
return `pichost-${jobId}${BACKUP_ARCHIVE_EXT}`
|
||||
}
|
||||
|
||||
function buildStorageBackendsMeta(): StorageBackendMetaExport[] {
|
||||
return listStorageBackendRows().map((row: StorageBackendRow) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
type: row.type,
|
||||
config: row.type === 's3' ? (parseS3Config(row.config_json) ?? {}) : {},
|
||||
servingMode: row.serving_mode,
|
||||
publicUrl: row.public_url,
|
||||
quotaBytes: row.quota_bytes,
|
||||
enabled: row.enabled === 1,
|
||||
isDefault: row.is_default === 1,
|
||||
sortOrder: row.sort_order
|
||||
}))
|
||||
}
|
||||
|
||||
async function writeStreamToFile(stream: NodeJS.ReadableStream, filePath: string): Promise<void> {
|
||||
await fs.mkdir(join(filePath, '..'), { recursive: true })
|
||||
await pipeline(stream, createWriteStream(filePath))
|
||||
}
|
||||
|
||||
export async function runFullExportJob(jobId: string, appVersion: string): Promise<void> {
|
||||
const stagingRoot = getBackupStagingDir(jobId)
|
||||
const archiveName = formatArchiveName(jobId)
|
||||
const archivePath = join(getBackupsDir(), archiveName)
|
||||
|
||||
try {
|
||||
await fs.rm(stagingRoot, { recursive: true, force: true })
|
||||
await fs.mkdir(stagingRoot, { recursive: true })
|
||||
await fs.mkdir(getBackupsDir(), { recursive: true })
|
||||
|
||||
const totals = getImageTotals()
|
||||
const rows = listAllImageIndexRows()
|
||||
updateBackupJob(jobId, {
|
||||
total: totals.count + 1,
|
||||
message: '正在备份数据库…'
|
||||
})
|
||||
|
||||
await copyDatabaseSnapshot(join(stagingRoot, 'database', 'pichost.sqlite'))
|
||||
updateBackupJob(jobId, { progress: 1, message: '正在写入备份元数据…' })
|
||||
|
||||
const manifest: BackupManifest = {
|
||||
formatVersion: BACKUP_FORMAT_VERSION,
|
||||
appVersion,
|
||||
createdAt: new Date().toISOString(),
|
||||
imageCount: totals.count,
|
||||
imageBytes: totals.bytes,
|
||||
includesDatabase: true
|
||||
}
|
||||
await fs.writeFile(
|
||||
join(stagingRoot, 'manifest.json'),
|
||||
JSON.stringify(manifest, null, 2),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
const metaDir = join(stagingRoot, 'meta')
|
||||
await fs.mkdir(metaDir, { recursive: true })
|
||||
await fs.writeFile(
|
||||
join(metaDir, 'storage_backends.json'),
|
||||
JSON.stringify(buildStorageBackendsMeta(), null, 2),
|
||||
'utf8'
|
||||
)
|
||||
await fs.writeFile(
|
||||
join(metaDir, 'readme.txt'),
|
||||
[
|
||||
'PicHost site backup package.',
|
||||
'Restore via Storage → Backup & migration in the admin UI.',
|
||||
'Object storage secrets are not included; re-enter keys after restore.'
|
||||
].join('\n'),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
let processed = 1
|
||||
for (const row of rows) {
|
||||
updateBackupJob(jobId, {
|
||||
progress: processed,
|
||||
message: `正在导出图片 ${processed}/${totals.count + 1}…`
|
||||
})
|
||||
|
||||
const blobPath = join(stagingRoot, 'blobs', row.key)
|
||||
const stream = await createImageStream(row.key)
|
||||
await writeStreamToFile(stream, blobPath)
|
||||
processed += 1
|
||||
updateBackupJob(jobId, { progress: processed })
|
||||
}
|
||||
|
||||
updateBackupJob(jobId, { message: '正在压缩备份包…' })
|
||||
await tar.c(
|
||||
{
|
||||
gzip: true,
|
||||
file: archivePath,
|
||||
cwd: stagingRoot,
|
||||
portable: true
|
||||
},
|
||||
['.']
|
||||
)
|
||||
|
||||
await fs.rm(stagingRoot, { recursive: true, force: true })
|
||||
|
||||
markJobDone(jobId, {
|
||||
message: '备份完成',
|
||||
outputPath: archivePath,
|
||||
payload: { archiveName }
|
||||
})
|
||||
} catch (error) {
|
||||
await fs.rm(stagingRoot, { recursive: true, force: true }).catch(() => {})
|
||||
const message = error instanceof Error ? error.message : '导出失败'
|
||||
markJobFailed(jobId, message)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import { createReadStream, promises as fs } from 'node:fs'
|
||||
import { join, relative } from 'node:path'
|
||||
import { pipeline } from 'node:stream/promises'
|
||||
import * as tar from 'tar'
|
||||
import { getImageIndexRow } from '../image-index'
|
||||
import { getActiveBackend } from '../storage/resolver'
|
||||
import { replaceDatabaseFromSnapshot } from './db-snapshot'
|
||||
import { markJobDone, markJobFailed, restoreBackupJobsAfterReplace, snapshotBackupJobs, updateBackupJob } from './jobs'
|
||||
import {
|
||||
BACKUP_FORMAT_VERSION,
|
||||
type BackupManifest,
|
||||
type BackupRestorePreview
|
||||
} from './manifest'
|
||||
import { getBackupStagingDir } from './paths'
|
||||
import { ensureDefaultBackends } from '../storage-backends'
|
||||
|
||||
async function walkFiles(dir: string): Promise<string[]> {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true })
|
||||
const files: string[] = []
|
||||
for (const entry of entries) {
|
||||
const full = join(dir, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...await walkFiles(full))
|
||||
} else if (entry.isFile()) {
|
||||
files.push(full)
|
||||
}
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
async function extractArchive(archivePath: string, targetDir: string): Promise<void> {
|
||||
await fs.rm(targetDir, { recursive: true, force: true }).catch(() => {})
|
||||
await fs.mkdir(targetDir, { recursive: true })
|
||||
await tar.x({
|
||||
file: archivePath,
|
||||
cwd: targetDir,
|
||||
gzip: true
|
||||
})
|
||||
}
|
||||
|
||||
async function readManifest(stagingRoot: string): Promise<BackupManifest> {
|
||||
const raw = await fs.readFile(join(stagingRoot, 'manifest.json'), 'utf8')
|
||||
const manifest = JSON.parse(raw) as BackupManifest
|
||||
if (manifest.formatVersion !== BACKUP_FORMAT_VERSION) {
|
||||
throw new Error(`不支持的备份格式版本:${manifest.formatVersion}`)
|
||||
}
|
||||
return manifest
|
||||
}
|
||||
|
||||
export async function previewFullRestore(archivePath: string): Promise<BackupRestorePreview> {
|
||||
const stagingRoot = getBackupStagingDir(`preview-${Date.now()}`)
|
||||
try {
|
||||
await extractArchive(archivePath, stagingRoot)
|
||||
const manifest = await readManifest(stagingRoot)
|
||||
const blobsRoot = join(stagingRoot, 'blobs')
|
||||
let blobFiles: string[] = []
|
||||
try {
|
||||
blobFiles = await walkFiles(blobsRoot)
|
||||
} catch {
|
||||
blobFiles = []
|
||||
}
|
||||
|
||||
let conflictingImages = 0
|
||||
for (const file of blobFiles) {
|
||||
const rel = relative(blobsRoot, file).replace(/\\/g, '/')
|
||||
if (getImageIndexRow(rel)) {
|
||||
conflictingImages += 1
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
imageCount: manifest.imageCount,
|
||||
imageBytes: manifest.imageBytes,
|
||||
newImages: Math.max(0, blobFiles.length - conflictingImages),
|
||||
conflictingImages,
|
||||
formatVersion: manifest.formatVersion,
|
||||
appVersion: manifest.appVersion,
|
||||
createdAt: manifest.createdAt
|
||||
}
|
||||
} finally {
|
||||
await fs.rm(stagingRoot, { recursive: true, force: true }).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
export async function runFullRestoreJob(
|
||||
jobId: string,
|
||||
archivePath: string,
|
||||
options: { conflictMode: 'skip' | 'overwrite' }
|
||||
): Promise<void> {
|
||||
const stagingRoot = getBackupStagingDir(jobId)
|
||||
const preservedJobs = snapshotBackupJobs(50)
|
||||
|
||||
try {
|
||||
updateBackupJob(jobId, { message: '正在解压备份包…', total: 0, progress: 0 })
|
||||
await extractArchive(archivePath, stagingRoot)
|
||||
const manifest = await readManifest(stagingRoot)
|
||||
|
||||
const dbPath = join(stagingRoot, 'database', 'pichost.sqlite')
|
||||
await fs.access(dbPath)
|
||||
|
||||
updateBackupJob(jobId, { message: '正在恢复数据库…' })
|
||||
await replaceDatabaseFromSnapshot(dbPath)
|
||||
ensureDefaultBackends()
|
||||
restoreBackupJobsAfterReplace(preservedJobs)
|
||||
updateBackupJob(jobId, {
|
||||
status: 'running',
|
||||
message: '正在恢复图片…',
|
||||
progress: 0,
|
||||
total: 0
|
||||
})
|
||||
|
||||
const blobsRoot = join(stagingRoot, 'blobs')
|
||||
const blobFiles = await walkFiles(blobsRoot)
|
||||
const total = blobFiles.length + 1
|
||||
updateBackupJob(jobId, { total, progress: 1, message: '正在恢复图片…' })
|
||||
|
||||
const backend = await getActiveBackend()
|
||||
let processed = 1
|
||||
|
||||
for (const file of blobFiles) {
|
||||
const key = relative(blobsRoot, file).replace(/\\/g, '/')
|
||||
const indexed = getImageIndexRow(key)
|
||||
|
||||
if (!indexed && options.conflictMode === 'skip') {
|
||||
processed += 1
|
||||
continue
|
||||
}
|
||||
|
||||
const bytes = await fs.readFile(file)
|
||||
const meta = indexed
|
||||
? {
|
||||
originalName: indexed.original_name,
|
||||
uploadedAt: indexed.uploaded_at,
|
||||
contentType: indexed.content_type,
|
||||
size: indexed.size,
|
||||
userId: indexed.user_id
|
||||
}
|
||||
: {
|
||||
originalName: key.split('/').pop() ?? key,
|
||||
uploadedAt: new Date().toISOString(),
|
||||
contentType: 'application/octet-stream',
|
||||
size: bytes.length,
|
||||
userId: null
|
||||
}
|
||||
|
||||
if (options.conflictMode === 'overwrite' || !indexed) {
|
||||
await backend.put(key, bytes, meta)
|
||||
}
|
||||
|
||||
processed += 1
|
||||
updateBackupJob(jobId, {
|
||||
progress: processed,
|
||||
message: `正在恢复图片 ${processed}/${total}…`
|
||||
})
|
||||
}
|
||||
|
||||
await fs.rm(stagingRoot, { recursive: true, force: true })
|
||||
|
||||
markJobDone(jobId, {
|
||||
message: '恢复完成,请重新登录并在存储页检查对象存储密钥',
|
||||
payload: {
|
||||
restoredImages: blobFiles.length,
|
||||
manifest
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
await fs.rm(stagingRoot, { recursive: true, force: true }).catch(() => {})
|
||||
const message = error instanceof Error ? error.message : '恢复失败'
|
||||
markJobFailed(jobId, message)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function streamBlobToFile(
|
||||
archivePath: string,
|
||||
targetPath: string
|
||||
): Promise<void> {
|
||||
await fs.mkdir(join(targetPath, '..'), { recursive: true })
|
||||
const readStream = createReadStream(archivePath)
|
||||
const writeStream = (await import('node:fs')).createWriteStream(targetPath)
|
||||
await pipeline(readStream, writeStream)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export { ensureBackupSchema } from './schema'
|
||||
export * from './jobs'
|
||||
export * from './paths'
|
||||
export * from './manifest'
|
||||
export * from './stats'
|
||||
export { previewFullRestore } from './full-restore'
|
||||
export { previewBackendSync } from './backend-sync'
|
||||
export {
|
||||
startFullExportJob,
|
||||
startFullRestoreJob,
|
||||
startBackendSyncJob,
|
||||
isBackupRunnerBusy,
|
||||
cancelBackupJob
|
||||
} from './runner'
|
||||
export { retryFailedBackendSync } from './backend-sync'
|
||||