Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81dcc2c15e | ||
|
|
20afda5869 |
No files matched your search
@@ -4,6 +4,18 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [1.2.4] - 2026-08-31
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
|
||||||
|
- 访问控制(Referer 防盗链)留空时仍会拦截外链图片:浏览器直开无 Referer 可访问,博客/论坛嵌入带 Referer 会 403 图裂;现仅在配置了白名单时才校验
|
||||||
|
|
||||||
|
## [1.2.3] - 2026-08-30
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
|
||||||
|
- 开启「简短图片链接」且按年/月分组时,纯文件名直链(如 `/xxx.webp`)误解析为 `images/xxx.webp` 导致 404;现先经 SQLite 索引反查完整 key
|
||||||
|
|
||||||
## [1.2.2] - 2026-08-30
|
## [1.2.2] - 2026-08-30
|
||||||
|
|
||||||
### 修复
|
### 修复
|
||||||
|
|||||||
@@ -4,6 +4,20 @@ Version history and notable changes. The full log is also in the repo root [CHAN
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [1.2.4] — 2026-08-31
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **Empty referer whitelist still blocked hotlinks**: with no Referer whitelist configured, direct browser access worked but embedded images on blogs/forums returned 403; protection now runs only when a whitelist is configured
|
||||||
|
|
||||||
|
## [1.2.3] — 2026-08-30
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **Short links + date-based storage**: bare filename URLs (e.g. `https://host/xxx.webp`) no longer map to flat `images/xxx.webp`; SQLite index resolves the full `images/YYYY/MM/xxx.webp` key so thumbnails and short links work again
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [1.2.2] — 2026-08-30
|
## [1.2.2] — 2026-08-30
|
||||||
|
|
||||||
> **Restart the service** after upgrading so middleware and API updates take effect.
|
> **Restart the service** after upgrading so middleware and API updates take effect.
|
||||||
|
|||||||
@@ -4,6 +4,20 @@ PicHost 版本历史与重要变更说明。完整记录亦见仓库根目录 [C
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [1.2.4] — 2026-08-31
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
|
||||||
|
- **访问控制留空仍拦截外链**:未配置 Referer 白名单时,浏览器直开图片正常,但博客/论坛嵌入会因 Referer 校验返回 403 图裂;现仅在配置了白名单时才启用防盗链
|
||||||
|
|
||||||
|
## [1.2.3] — 2026-08-30
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
|
||||||
|
- **简短图片链接 + 按年/月分组**:纯文件名直链(如 `https://域名/xxx.webp`)不再误当成 `images/xxx.webp`,改为经 SQLite 索引反查 `images/年/月/xxx.webp`,缩略图与短链恢复正常
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [1.2.2] — 2026-08-30
|
## [1.2.2] — 2026-08-30
|
||||||
|
|
||||||
> 升级后请**重启服务**使中间件与 API 生效。
|
> 升级后请**重启服务**使中间件与 API 生效。
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "pichost",
|
"name": "pichost",
|
||||||
"version": "1.2.2",
|
"version": "1.2.4",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "pichost",
|
"name": "pichost",
|
||||||
"version": "1.2.2",
|
"version": "1.2.4",
|
||||||
"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.2.2",
|
"version": "1.2.4",
|
||||||
"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,10 +1,31 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it, vi, afterEach } from 'vitest'
|
||||||
|
import { findImageKeyByDatePath, findImageKeyByFilename } from './image-index'
|
||||||
import { requestPathToImageKey } from './serve-image'
|
import { requestPathToImageKey } from './serve-image'
|
||||||
|
|
||||||
|
vi.mock('./image-index', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('./image-index')>()
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
findImageKeyByFilename: vi.fn(actual.findImageKeyByFilename),
|
||||||
|
findImageKeyByDatePath: vi.fn(actual.findImageKeyByDatePath)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
describe('requestPathToImageKey hide folder', () => {
|
describe('requestPathToImageKey hide folder', () => {
|
||||||
it('resolves bare filename to default folder flat key', () => {
|
afterEach(() => {
|
||||||
|
vi.mocked(findImageKeyByFilename).mockReset()
|
||||||
|
vi.mocked(findImageKeyByDatePath).mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resolves bare filename via index when hide folder is enabled', () => {
|
||||||
|
vi.mocked(findImageKeyByFilename).mockReturnValue('images/2026/08/demo.webp')
|
||||||
expect(requestPathToImageKey('/demo.webp', { hideFolder: true }))
|
expect(requestPathToImageKey('/demo.webp', { hideFolder: true }))
|
||||||
.toBe('images/demo.webp')
|
.toBe('images/2026/08/demo.webp')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not guess flat images/name when hide folder is enabled', () => {
|
||||||
|
vi.mocked(findImageKeyByFilename).mockReturnValue(null)
|
||||||
|
expect(requestPathToImageKey('/demo.webp', { hideFolder: true })).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('resolves hidden date path to default folder key', () => {
|
it('resolves hidden date path to default folder key', () => {
|
||||||
|
|||||||
@@ -1,5 +1,30 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it, vi, afterEach } from 'vitest'
|
||||||
import { isRefererAllowed } from './serve-image'
|
import type { H3Event } from 'h3'
|
||||||
|
import { isHotlinkBlocked, isRefererAllowed } from './serve-image'
|
||||||
|
import { getSetting, SETTINGS_ALLOWED_REFERER_HOSTS } from './db'
|
||||||
|
|
||||||
|
vi.mock('./db', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('./db')>()
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
getSetting: vi.fn()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.stubGlobal('getRequestURL', () => new URL('https://pic.example.com'))
|
||||||
|
|
||||||
|
vi.stubGlobal('useRuntimeConfig', () => ({
|
||||||
|
apiUploadToken: '',
|
||||||
|
siteBaseUrl: '',
|
||||||
|
imageBaseUrl: ''
|
||||||
|
}))
|
||||||
|
|
||||||
|
function mockEvent(): H3Event {
|
||||||
|
return {
|
||||||
|
node: { req: {}, res: {} },
|
||||||
|
context: {}
|
||||||
|
} as H3Event
|
||||||
|
}
|
||||||
|
|
||||||
describe('isRefererAllowed', () => {
|
describe('isRefererAllowed', () => {
|
||||||
const allowedHosts = new Set(['pic.example.com', 'blog.example.com'])
|
const allowedHosts = new Set(['pic.example.com', 'blog.example.com'])
|
||||||
@@ -21,3 +46,42 @@ describe('isRefererAllowed', () => {
|
|||||||
expect(isRefererAllowed('not-a-url', allowedHosts)).toBe(false)
|
expect(isRefererAllowed('not-a-url', allowedHosts)).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('isHotlinkBlocked', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
delete process.env.ALLOWED_REFERER_HOSTS
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not block external referers when protection is not configured', () => {
|
||||||
|
vi.mocked(getSetting).mockReturnValue(null)
|
||||||
|
const event = mockEvent()
|
||||||
|
expect(isHotlinkBlocked(event, 'https://blog.example.com/post')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not block external referers when db explicitly cleared', () => {
|
||||||
|
process.env.ALLOWED_REFERER_HOSTS = 'blog.example.com'
|
||||||
|
vi.mocked(getSetting).mockImplementation(key =>
|
||||||
|
key === SETTINGS_ALLOWED_REFERER_HOSTS ? '' : null
|
||||||
|
)
|
||||||
|
const event = mockEvent()
|
||||||
|
expect(isHotlinkBlocked(event, 'https://forum.example.com/thread')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('blocks external referers when protection is configured', () => {
|
||||||
|
vi.mocked(getSetting).mockImplementation(key =>
|
||||||
|
key === SETTINGS_ALLOWED_REFERER_HOSTS ? 'blog.example.com' : null
|
||||||
|
)
|
||||||
|
const event = mockEvent()
|
||||||
|
expect(isHotlinkBlocked(event, 'https://evil.example.com/hotlink')).toBe(true)
|
||||||
|
expect(isHotlinkBlocked(event, 'https://blog.example.com/post')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('still allows missing referer when protection is configured', () => {
|
||||||
|
vi.mocked(getSetting).mockImplementation(key =>
|
||||||
|
key === SETTINGS_ALLOWED_REFERER_HOSTS ? 'blog.example.com' : null
|
||||||
|
)
|
||||||
|
const event = mockEvent()
|
||||||
|
expect(isHotlinkBlocked(event, null)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -4,11 +4,12 @@ import {
|
|||||||
getAllowedRefererHostsRaw,
|
getAllowedRefererHostsRaw,
|
||||||
getImageBaseUrlConfigured,
|
getImageBaseUrlConfigured,
|
||||||
getSiteBaseUrlConfigured,
|
getSiteBaseUrlConfigured,
|
||||||
hostnameFromBaseUrl
|
hostnameFromBaseUrl,
|
||||||
|
isRefererConfigured
|
||||||
} from './env'
|
} from './env'
|
||||||
import { findImageKeyByDatePath, findImageKeyByFilename } from './image-index'
|
import { findImageKeyByDatePath, findImageKeyByFilename } from './image-index'
|
||||||
import { validateImageKey } from './image-key'
|
import { validateImageKey } from './image-key'
|
||||||
import { toStorageKeyFromPublicPath } from './image-public-path'
|
import { isBareImageFilename, toStorageKeyFromPublicPath } from './image-public-path'
|
||||||
import { buildPublicImageUrl } from './image-response'
|
import { buildPublicImageUrl } from './image-response'
|
||||||
import { createImageStream, headImage } from './storage'
|
import { createImageStream, headImage } from './storage'
|
||||||
import { getBackendRowForKey } from './storage/resolver'
|
import { getBackendRowForKey } from './storage/resolver'
|
||||||
@@ -58,6 +59,12 @@ export function isRefererAllowed(referer: string | null, allowedHosts: Set<strin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 未配置防盗链白名单时不拦截;已配置时校验 Referer(缺失 Referer 仍放行) */
|
||||||
|
export function isHotlinkBlocked(event: H3Event, referer: string | null): boolean {
|
||||||
|
if (!isRefererConfigured(event)) return false
|
||||||
|
return !isRefererAllowed(referer, getAllowedHosts(event))
|
||||||
|
}
|
||||||
|
|
||||||
function parseRangeHeader(
|
function parseRangeHeader(
|
||||||
rangeHeader: string | undefined,
|
rangeHeader: string | undefined,
|
||||||
size: number
|
size: number
|
||||||
@@ -85,6 +92,13 @@ export function requestPathToImageKey(
|
|||||||
|
|
||||||
if (validateImageKey(raw)) return raw
|
if (validateImageKey(raw)) return raw
|
||||||
|
|
||||||
|
// 简短链接:纯文件名须先走索引反查,不能猜成 images/文件名(与按年/月存储冲突)
|
||||||
|
if (options?.hideFolder && isBareImageFilename(raw)) {
|
||||||
|
const byFilename = findImageKeyByFilename(raw)
|
||||||
|
if (byFilename && validateImageKey(byFilename)) return byFilename
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
const fromPublic = toStorageKeyFromPublicPath(raw)
|
const fromPublic = toStorageKeyFromPublicPath(raw)
|
||||||
if (fromPublic) return fromPublic
|
if (fromPublic) return fromPublic
|
||||||
|
|
||||||
@@ -101,7 +115,7 @@ export function requestPathToImageKey(
|
|||||||
|
|
||||||
export async function serveImageByKey(event: H3Event, key: string) {
|
export async function serveImageByKey(event: H3Event, key: string) {
|
||||||
const referer = getHeader(event, 'referer') ?? null
|
const referer = getHeader(event, 'referer') ?? null
|
||||||
if (!isRefererAllowed(referer, getAllowedHosts(event))) {
|
if (isHotlinkBlocked(event, referer)) {
|
||||||
setHeader(event, 'Cache-Control', ERROR_CACHE_CONTROL)
|
setHeader(event, 'Cache-Control', ERROR_CACHE_CONTROL)
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 403,
|
statusCode: 403,
|
||||||
|
|||||||
Reference in new issue
Block a user