Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3dee0437e6 | ||
|
|
f25c89855f | ||
|
|
b0a5fbfca5 |
No files matched your search
@@ -2,6 +2,31 @@
|
|||||||
|
|
||||||
本仓库自 **v1.0.0** 起作为正式版维护;此前历史提交已归档重置。
|
本仓库自 **v1.0.0** 起作为正式版维护;此前历史提交已归档重置。
|
||||||
|
|
||||||
|
## [1.1.5] - 2026-08-28
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
|
||||||
|
- 修复 API 上传后图库预览偶发失败、需等待刷新才显示的问题
|
||||||
|
- 图库卡片加载失败后自动重试,刷新列表时重置预览状态
|
||||||
|
|
||||||
|
### 改进
|
||||||
|
|
||||||
|
- 出图前校验存储文件实际存在,避免索引与磁盘短暂不一致
|
||||||
|
- 图片 404/403 与域名隔离 404 禁止 CDN 缓存,降低反代误判影响
|
||||||
|
|
||||||
|
## [1.1.4] - 2026-08-28
|
||||||
|
|
||||||
|
### 新增
|
||||||
|
|
||||||
|
- 上传目录可按年/月分组或扁平保存(`storage_use_date_path` / `STORAGE_USE_DATE_PATH`),设置页可切换
|
||||||
|
- 双域名分离下网站域禁止直链出图,仅图片域可访问图片路径
|
||||||
|
|
||||||
|
### 改进
|
||||||
|
|
||||||
|
- 隐藏 `images/` 前缀时外链改为仅文件名(如 `xxx.webp`);图片域仍兼容旧版 `/2026/08/xxx.webp` 短链
|
||||||
|
- 设置页重构:功能开关、域名配置、存储路径分组展示
|
||||||
|
- 本地磁盘索引扫描支持扁平路径;文件夹列表 `images` 置顶
|
||||||
|
|
||||||
## [1.1.3] - 2026-08-27
|
## [1.1.3] - 2026-08-27
|
||||||
|
|
||||||
### 新增
|
### 新增
|
||||||
|
|||||||
+4
-2
@@ -32,7 +32,7 @@
|
|||||||
|
|
||||||
| Branch | Notes |
|
| Branch | Notes |
|
||||||
| ------ | ----- |
|
| ------ | ----- |
|
||||||
| [**main**](https://github.com/O96u/PicHost/tree/main) (default) | **v1.1.3** — local disk + multi-backend storage, dual-domain separation, [`/storage`](docs/README.md) UI, hybrid URLs |
|
| [**main**](https://github.com/O96u/PicHost/tree/main) (default) | **v1.1.5** — local disk + multi-backend storage, bidirectional dual-domain isolation, [`/storage`](docs/README.md) UI, hybrid URLs |
|
||||||
| [**cloudflare**](https://github.com/O96u/PicHost/tree/cloudflare) | **Cloudflare R2–only line** — streamlined for deployments that use R2 exclusively |
|
| [**cloudflare**](https://github.com/O96u/PicHost/tree/cloudflare) | **Cloudflare R2–only line** — streamlined for deployments that use R2 exclusively |
|
||||||
|
|
||||||
Use **main** for the full product and multiple cloud backends.
|
Use **main** for the full product and multiple cloud backends.
|
||||||
@@ -228,7 +228,9 @@ npm test # Unit tests
|
|||||||
|
|
||||||
| Version | Plan |
|
| Version | Plan |
|
||||||
| ---------- | -------------------------------------------------------------- |
|
| ---------- | -------------------------------------------------------------- |
|
||||||
| **v1.1.3** | Dual-domain separation, optional `images/` URL prefix, update check (current `main`) |
|
| **v1.1.5** | Gallery preview reliability: auto-retry, storage verify, no-cache errors (current `main`) |
|
||||||
|
| **v1.1.4** | Flat vs date storage paths, basename public URLs, bidirectional domain isolation |
|
||||||
|
| **v1.1.3** | Dual-domain separation, optional `images/` URL prefix, update check |
|
||||||
| **v1.1.2** | Activity logs, gallery storage filter, upload rate limits |
|
| **v1.1.2** | Activity logs, gallery storage filter, upload rate limits |
|
||||||
| **v1.1.1** | Session check fixes, storage placeholder cleanup, Docker logging |
|
| **v1.1.1** | Session check fixes, storage placeholder cleanup, Docker logging |
|
||||||
| **v1.1.0** | Multi-backend object storage, hybrid URLs, storage UI |
|
| **v1.1.0** | Multi-backend object storage, hybrid URLs, storage UI |
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
|
|
||||||
| 分支 | 说明 |
|
| 分支 | 说明 |
|
||||||
| ---- | ---- |
|
| ---- | ---- |
|
||||||
| [**main**](https://github.com/O96u/PicHost/tree/main)(默认) | **v1.1.3** 主线:本地磁盘 + 多对象存储、双域名分离、[`/storage`](docs/README.md) 存储管理、混合直链 |
|
| [**main**](https://github.com/O96u/PicHost/tree/main)(默认) | **v1.1.5** 主线:本地磁盘 + 多对象存储、双域名双向隔离、[`/storage`](docs/README.md) 存储管理、混合直链 |
|
||||||
| [**cloudflare**](https://github.com/O96u/PicHost/tree/cloudflare) | **Cloudflare R2 专用线**:面向「只用 R2」的部署,预设与配置更聚焦 R2 |
|
| [**cloudflare**](https://github.com/O96u/PicHost/tree/cloudflare) | **Cloudflare R2 专用线**:面向「只用 R2」的部署,预设与配置更聚焦 R2 |
|
||||||
|
|
||||||
日常使用、需要管理多种存储后端,请直接用 **main**。
|
日常使用、需要管理多种存储后端,请直接用 **main**。
|
||||||
@@ -233,7 +233,9 @@ npm test # 单元测试
|
|||||||
|
|
||||||
| 版本 | 说明 |
|
| 版本 | 说明 |
|
||||||
| ---------- | ------------------------------------------------- |
|
| ---------- | ------------------------------------------------- |
|
||||||
| **v1.1.3** | 双域名分离、隐藏 `images/` 前缀、版本更新提示(当前 main) |
|
| **v1.1.5** | 图库预览可靠性:自动重试、出图前校验、错误响应禁缓存(当前 main) |
|
||||||
|
| **v1.1.4** | 存储路径扁平/分组、basename 外链、双域名双向隔离 |
|
||||||
|
| **v1.1.3** | 双域名分离、隐藏 `images/` 前缀、版本更新提示 |
|
||||||
| **v1.1.2** | 操作日志、图库存储筛选、上传限流 |
|
| **v1.1.2** | 操作日志、图库存储筛选、上传限流 |
|
||||||
| **v1.1.1** | 会话校验与存储占位修复、Docker 日志增强 |
|
| **v1.1.1** | 会话校验与存储占位修复、Docker 日志增强 |
|
||||||
| **v1.1.0** | S3 / R2 多后端对象存储、混合直链、存储管理页 |
|
| **v1.1.0** | S3 / R2 多后端对象存储、混合直链、存储管理页 |
|
||||||
|
|||||||
@@ -26,8 +26,48 @@ const emit = defineEmits<{
|
|||||||
const { formatFileSize } = useFileSize()
|
const { formatFileSize } = useFileSize()
|
||||||
const { t, locale } = useI18n()
|
const { t, locale } = useI18n()
|
||||||
const imageError = ref(false)
|
const imageError = ref(false)
|
||||||
|
const retryCount = ref(0)
|
||||||
const copyFormat = ref<CopyFormat>('url')
|
const copyFormat = ref<CopyFormat>('url')
|
||||||
|
|
||||||
|
const MAX_PREVIEW_RETRIES = 3
|
||||||
|
const PREVIEW_RETRY_DELAYS_MS = [1000, 2000, 4000]
|
||||||
|
|
||||||
|
const previewSrc = computed(() => {
|
||||||
|
if (retryCount.value === 0) return props.image.url
|
||||||
|
const separator = props.image.url.includes('?') ? '&' : '?'
|
||||||
|
return `${props.image.url}${separator}retry=${retryCount.value}`
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.image.key, () => {
|
||||||
|
imageError.value = false
|
||||||
|
retryCount.value = 0
|
||||||
|
})
|
||||||
|
|
||||||
|
let retryTimer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
|
||||||
|
function clearRetryTimer() {
|
||||||
|
if (retryTimer !== undefined) {
|
||||||
|
clearTimeout(retryTimer)
|
||||||
|
retryTimer = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPreviewError() {
|
||||||
|
if (retryCount.value >= MAX_PREVIEW_RETRIES) {
|
||||||
|
imageError.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const delay = PREVIEW_RETRY_DELAYS_MS[retryCount.value] ?? PREVIEW_RETRY_DELAYS_MS.at(-1)!
|
||||||
|
clearRetryTimer()
|
||||||
|
retryTimer = setTimeout(() => {
|
||||||
|
retryCount.value += 1
|
||||||
|
retryTimer = undefined
|
||||||
|
}, delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(clearRetryTimer)
|
||||||
|
|
||||||
const copyFormatItems = computed(() => [
|
const copyFormatItems = computed(() => [
|
||||||
{ label: t('copy.url'), value: 'url' as const },
|
{ label: t('copy.url'), value: 'url' as const },
|
||||||
{ label: t('copy.markdown'), value: 'markdown' as const },
|
{ label: t('copy.markdown'), value: 'markdown' as const },
|
||||||
@@ -78,11 +118,12 @@ function toggleSelected(value: boolean | 'indeterminate') {
|
|||||||
<div class="relative aspect-square shrink-0 overflow-hidden bg-muted">
|
<div class="relative aspect-square shrink-0 overflow-hidden bg-muted">
|
||||||
<img
|
<img
|
||||||
v-if="!imageError"
|
v-if="!imageError"
|
||||||
:src="image.url"
|
:key="`${image.key}-${retryCount}`"
|
||||||
|
:src="previewSrc"
|
||||||
:alt="image.originalName"
|
:alt="image.originalName"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
class="absolute inset-0 size-full object-cover object-center"
|
class="absolute inset-0 size-full object-cover object-center"
|
||||||
@error="imageError = true"
|
@error="onPreviewError"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-else
|
v-else
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "pichost",
|
"name": "pichost",
|
||||||
"version": "1.1.3",
|
"version": "1.1.5",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "pichost",
|
"name": "pichost",
|
||||||
"version": "1.1.3",
|
"version": "1.1.5",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "GPL-3.0-only",
|
"license": "GPL-3.0-only",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pichost",
|
"name": "pichost",
|
||||||
"version": "1.1.3",
|
"version": "1.1.5",
|
||||||
"description": "Self-hosted image hosting for your NAS / home server — Nuxt admin UI, local disk or S3-compatible storage, server-side WebP compression, referer protection, Twikoo/EasyImage compatible.",
|
"description": "Self-hosted image hosting for your NAS / home server — Nuxt admin UI, local disk or S3-compatible storage, server-side WebP compression, referer protection, Twikoo/EasyImage compatible.",
|
||||||
"homepage": "https://github.com/O96u/PicHost#readme",
|
"homepage": "https://github.com/O96u/PicHost#readme",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { shouldBlockImageHostRequest, shouldBlockSiteHostImageRequest } from '../utils/host-isolation'
|
import { shouldBlockImageHostRequest, shouldBlockSiteHostImageRequest } from '../utils/host-isolation'
|
||||||
|
|
||||||
|
const HOST_ISOLATION_CACHE_CONTROL = 'private, no-cache, max-age=0'
|
||||||
|
|
||||||
export default defineEventHandler((event) => {
|
export default defineEventHandler((event) => {
|
||||||
if (shouldBlockImageHostRequest(event) || shouldBlockSiteHostImageRequest(event)) {
|
if (shouldBlockImageHostRequest(event) || shouldBlockSiteHostImageRequest(event)) {
|
||||||
|
setHeader(event, 'Cache-Control', HOST_ISOLATION_CACHE_CONTROL)
|
||||||
throw createError({ statusCode: 404, statusMessage: 'Not Found' })
|
throw createError({ statusCode: 404, statusMessage: 'Not Found' })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -20,7 +20,7 @@ import { getBackendRowForKey } from './storage/resolver'
|
|||||||
* - Referer 可被非浏览器客户端伪造,本方案防普通网站盗链,不是身份认证
|
* - Referer 可被非浏览器客户端伪造,本方案防普通网站盗链,不是身份认证
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const ERROR_CACHE_CONTROL = 'public, max-age=60'
|
const ERROR_CACHE_CONTROL = 'private, no-cache, max-age=0'
|
||||||
|
|
||||||
function getAllowedHosts(event: H3Event): Set<string> {
|
function getAllowedHosts(event: H3Event): Set<string> {
|
||||||
const hosts = new Set<string>()
|
const hosts = new Set<string>()
|
||||||
|
|||||||
+12
-3
@@ -53,13 +53,22 @@ export async function putImage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function headImage(key: string): Promise<StoredImage | null> {
|
export async function headImage(key: string): Promise<StoredImage | null> {
|
||||||
const indexed = getImageIndexAsStored(key)
|
|
||||||
if (indexed) return indexed
|
|
||||||
|
|
||||||
const backend = await getBackendForKey(key)
|
const backend = await getBackendForKey(key)
|
||||||
const stored = await backend.head(key)
|
const stored = await backend.head(key)
|
||||||
if (!stored) return null
|
if (!stored) return null
|
||||||
|
|
||||||
|
const indexed = getImageIndexAsStored(key)
|
||||||
|
if (indexed) {
|
||||||
|
return {
|
||||||
|
...stored,
|
||||||
|
backendId: backend.id,
|
||||||
|
originalName: indexed.originalName,
|
||||||
|
contentType: indexed.contentType,
|
||||||
|
uploadedAt: indexed.uploadedAt,
|
||||||
|
userId: indexed.userId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
insertImageIndex({
|
insertImageIndex({
|
||||||
key: stored.key,
|
key: stored.key,
|
||||||
backendId: backend.id,
|
backendId: backend.id,
|
||||||
|
|||||||
Reference in new issue
Block a user