3 Commits
Author SHA1 Message Date
O96u 55effa204e release: v1.1.2 — activity logs, storage filter, upload rate limits
ci / ci (22, ubuntu-latest) (push) Canceled after 0s
GitHub Release / release (push) Canceled after 0s
Publish Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Canceled after 0s
Publish Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Canceled after 0s
Publish Docker image / merge (push) Canceled after 0s
Bump version and changelog; fix lint issues before release.
2026-08-26 17:14:50 +08:00
O96u 6fc054328d feat: activity logs, gallery storage filter, and upload rate limits
Add per-user activity logs with storage backend tracking, gallery
storage filter, and IP/token upload rate limiting. Fix storage delete
modal flash, pin local backend in filter order, and link settings
version to GitHub releases.
2026-08-26 17:09:38 +08:00
O96u 901fefb3f5 release: v1.1.1 — session fixes, storage cleanup, logging
GitHub Release / release (push) Canceled after 0s
ci / ci (22, ubuntu-latest) (push) Canceled after 0s
Publish Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Canceled after 0s
Publish Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Canceled after 0s
Publish Docker image / merge (push) Canceled after 0s
2026-08-26 15:38:56 +08:00
47 changed files with 1609 additions and 110 deletions

No files matched your search

+33 -1
View File
@@ -2,6 +2,36 @@
本仓库自 **v1.0.0** 起作为正式版维护;此前历史提交已归档重置。 本仓库自 **v1.0.0** 起作为正式版维护;此前历史提交已归档重置。
## [1.1.2] - 2026-08-26
### 新增
- 操作日志页(`/logs`):记录上传/删除,管理员可查看全部用户并筛选
- 图库记录支持按存储后端筛选
- 上传 IP / API Token 限流(15 分钟窗口)
### 改进
- 操作日志记录存储后端;图库卡片可展示存储来源
- 存储筛选下拉固定「本地磁盘」排在第二位
- 系统设置页版本号链接至 GitHub Releases
### 修复
- 修复删除存储后端后确认弹窗闪出空名称
## [1.1.1] - 2026-08-26
### 修复
- 移除启动时自动创建的空白「S3 兼容存储」占位;升级后自动清理旧占位记录
- 修复管理页切换路由时反复闪现「正在验证登录状态」
- 修复静默会话校验后设置 / 统计 / API 页不加载数据
### 改进
- Docker 日志:4xx/5xx 分级输出,记录错误原因与堆栈;上传/启动任务失败写入详细错误
## [1.1.0] - 2026-08-26 ## [1.1.0] - 2026-08-26
### 新增 ### 新增
@@ -83,8 +113,10 @@
### 路线图 ### 路线图
- **v1.1.0**(当前 `main`):S3、Cloudflare R2 等多后端对象存储 - **v1.1.2**(当前 `main`):操作日志、图库存储筛选、上传限流
[1.1.2]: https://github.com/O96u/PicHost/releases/tag/v1.1.2
[1.1.1]: https://github.com/O96u/PicHost/releases/tag/v1.1.1
[1.1.0]: https://github.com/O96u/PicHost/releases/tag/v1.1.0 [1.1.0]: https://github.com/O96u/PicHost/releases/tag/v1.1.0
[1.0.4]: https://github.com/O96u/PicHost/releases/tag/v1.0.4 [1.0.4]: https://github.com/O96u/PicHost/releases/tag/v1.0.4
[1.0.3]: https://github.com/O96u/PicHost/releases/tag/v1.0.3 [1.0.3]: https://github.com/O96u/PicHost/releases/tag/v1.0.3
+4 -2
View File
@@ -32,7 +32,7 @@
| Branch | Notes | | Branch | Notes |
| ------ | ----- | | ------ | ----- |
| [**main**](https://github.com/O96u/PicHost/tree/main) (default) | **v1.1.0** — local disk + multi-backend object storage (R2 / COS / OSS / AWS), [`/storage`](docs/README.md) UI, hybrid URLs | | [**main**](https://github.com/O96u/PicHost/tree/main) (default) | **v1.1.2** — local disk + multi-backend object storage (R2 / COS / OSS / AWS), [`/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.
@@ -193,7 +193,9 @@ npm test # Unit tests
| Version | Plan | | Version | Plan |
| ---------- | -------------------------------------------------------------- | | ---------- | -------------------------------------------------------------- |
| **v1.1.0** | Multi-backend object storage, hybrid URLs, storage UI (current `main`) | | **v1.1.2** | Activity logs, gallery storage filter, upload rate limits (current `main`) |
| **v1.1.1** | Session check fixes, storage placeholder cleanup, Docker logging |
| **v1.1.0** | Multi-backend object storage, hybrid URLs, storage UI |
| **v1.0.4** | Password visibility toggle, legacy ADMIN_SECRET migration | | **v1.0.4** | Password visibility toggle, legacy ADMIN_SECRET migration |
| **v1.0.3** | CI and Docker multi-arch build fixes | | **v1.0.3** | CI and Docker multi-arch build fixes |
| **v1.0.2** | i18n, mobile menus, stats/upload UI, Docker password reset | | **v1.0.2** | i18n, mobile menus, stats/upload UI, Docker password reset |
+4 -2
View File
@@ -32,7 +32,7 @@
| 分支 | 说明 | | 分支 | 说明 |
| ---- | ---- | | ---- | ---- |
| [**main**](https://github.com/O96u/PicHost/tree/main)(默认) | **v1.1.0** 主线:本地磁盘 + 多对象存储后端(R2 / COS / OSS / AWS)、[`/storage`](docs/README.md) 存储管理、混合直链 | | [**main**](https://github.com/O96u/PicHost/tree/main)(默认) | **v1.1.2** 主线:本地磁盘 + 多对象存储后端(R2 / COS / OSS / AWS)、[`/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**。
@@ -198,7 +198,9 @@ npm test # 单元测试
| 版本 | 说明 | | 版本 | 说明 |
| ---------- | ------------------------------------------------- | | ---------- | ------------------------------------------------- |
| **v1.1.0** | S3 / R2 多后端对象存储、混合直链、存储管理页(当前 main) | | **v1.1.2** | 操作日志、图库存储筛选、上传限流(当前 main) |
| **v1.1.1** | 会话校验与存储占位修复、Docker 日志增强 |
| **v1.1.0** | S3 / R2 多后端对象存储、混合直链、存储管理页 |
| **v1.0.4** | 密码显示切换、遗留 ADMIN_SECRET 迁移对齐 | | **v1.0.4** | 密码显示切换、遗留 ADMIN_SECRET 迁移对齐 |
| **v1.0.3** | CI 与 Docker 多架构构建修复 | | **v1.0.3** | CI 与 Docker 多架构构建修复 |
| **v1.0.2** | 中英文、移动端菜单、统计/上传 UI、Docker 重置密码 | | **v1.0.2** | 中英文、移动端菜单、统计/上传 UI、Docker 重置密码 |
+5
View File
@@ -23,6 +23,11 @@ const navItems = computed(() => {
}) })
const accountMenuItems = computed<DropdownMenuItem[]>(() => [ const accountMenuItems = computed<DropdownMenuItem[]>(() => [
{
label: t('nav.logs'),
icon: 'i-lucide-scroll-text',
to: '/logs'
},
{ {
label: t('nav.changePassword'), label: t('nav.changePassword'),
icon: 'i-lucide-key-round', icon: 'i-lucide-key-round',
+23 -1
View File
@@ -9,10 +9,12 @@ const props = withDefaults(defineProps<{
deleting?: boolean deleting?: boolean
selectable?: boolean selectable?: boolean
showKey?: boolean showKey?: boolean
showStorage?: boolean
compact?: boolean compact?: boolean
}>(), { }>(), {
selectable: true, selectable: true,
showKey: true, showKey: true,
showStorage: false,
compact: false compact: false
}) })
@@ -62,6 +64,10 @@ const copySuccessTitle = computed(() => {
} }
}) })
const storageIcon = computed(() =>
props.image.storage?.type === 'local' ? 'i-lucide-hard-drive' : 'i-lucide-cloud'
)
function toggleSelected(value: boolean | 'indeterminate') { function toggleSelected(value: boolean | 'indeterminate') {
emit('update:selected', value === true) emit('update:selected', value === true)
} }
@@ -106,7 +112,7 @@ function toggleSelected(value: boolean | 'indeterminate') {
</div> </div>
<div <div
v-if="image.owner" v-if="image.owner"
class="absolute bottom-2 left-2 max-w-[calc(100%-1rem)]" class="absolute bottom-2 left-2 max-w-[calc(50%-0.5rem)]"
> >
<span <span
class="inline-flex max-w-full items-center gap-1 rounded-md bg-black/65 px-2 py-0.5 text-xs font-medium text-white shadow-sm backdrop-blur-sm" class="inline-flex max-w-full items-center gap-1 rounded-md bg-black/65 px-2 py-0.5 text-xs font-medium text-white shadow-sm backdrop-blur-sm"
@@ -119,6 +125,22 @@ function toggleSelected(value: boolean | 'indeterminate') {
<span class="truncate">{{ image.owner.username }}</span> <span class="truncate">{{ image.owner.username }}</span>
</span> </span>
</div> </div>
<div
v-if="showStorage && image.storage"
class="absolute bottom-2 max-w-[calc(50%-0.5rem)]"
:class="image.owner ? 'right-2' : 'left-2'"
>
<span
class="inline-flex max-w-full items-center gap-1 rounded-md bg-black/65 px-2 py-0.5 text-xs font-medium text-white shadow-sm backdrop-blur-sm"
:title="t('image.storageBackend', { name: image.storage.name })"
>
<UIcon
:name="storageIcon"
class="size-3 shrink-0"
/>
<span class="truncate">{{ image.storage.name }}</span>
</span>
</div>
</div> </div>
<div <div
+3
View File
@@ -7,12 +7,14 @@ const props = withDefaults(defineProps<{
deletingKeys: Set<string> deletingKeys: Set<string>
selectable?: boolean selectable?: boolean
showKey?: boolean showKey?: boolean
showStorage?: boolean
emptyText?: string emptyText?: string
dense?: boolean dense?: boolean
compact?: boolean compact?: boolean
}>(), { }>(), {
selectable: true, selectable: true,
showKey: true, showKey: true,
showStorage: false,
dense: false, dense: false,
compact: false compact: false
}) })
@@ -48,6 +50,7 @@ function updateSelection(key: string, selected: boolean) {
:deleting="deletingKeys.has(image.key)" :deleting="deletingKeys.has(image.key)"
:selectable="selectable" :selectable="selectable"
:show-key="showKey" :show-key="showKey"
:show-storage="showStorage"
:compact="compact" :compact="compact"
@update:selected="updateSelection(image.key, $event)" @update:selected="updateSelection(image.key, $event)"
@delete="emit('delete', image.key)" @delete="emit('delete', image.key)"
+18
View File
@@ -136,6 +136,24 @@ function toggleOpen() {
{{ t('nav.account') }} {{ t('nav.account') }}
</h3> </h3>
<div class="space-y-2"> <div class="space-y-2">
<NuxtLink
to="/logs"
class="flex items-center justify-between rounded-lg border border-default px-3.5 py-2.5 text-sm text-highlighted transition-colors hover:bg-elevated"
@click="open = false"
>
<span class="flex items-center gap-2.5">
<UIcon
name="i-lucide-scroll-text"
class="size-4 opacity-70"
/>
{{ t('nav.logs') }}
</span>
<UIcon
name="i-lucide-chevron-right"
class="size-4 text-muted"
/>
</NuxtLink>
<NuxtLink <NuxtLink
to="/account/password" to="/account/password"
class="flex items-center justify-between rounded-lg border border-default px-3.5 py-2.5 text-sm text-highlighted transition-colors hover:bg-elevated" class="flex items-center justify-between rounded-lg border border-default px-3.5 py-2.5 text-sm text-highlighted transition-colors hover:bg-elevated"
+18 -3
View File
@@ -216,7 +216,10 @@ function handleSubmit() {
/> />
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<label class="text-sm">{{ t('storage.bucket') }}</label> <label class="text-sm">
{{ t('storage.bucket') }}
<span class="text-error">*</span>
</label>
<UInput <UInput
v-model="bucket" v-model="bucket"
class="w-full font-mono text-sm" class="w-full font-mono text-sm"
@@ -241,7 +244,13 @@ function handleSubmit() {
<div class="grid gap-3 sm:grid-cols-2"> <div class="grid gap-3 sm:grid-cols-2">
<div class="space-y-2"> <div class="space-y-2">
<label class="text-sm">{{ t('storage.accessKey') }}</label> <label class="text-sm">
{{ t('storage.accessKey') }}
<span
v-if="createMode"
class="text-error"
>*</span>
</label>
<UInput <UInput
v-model="accessKeyId" v-model="accessKeyId"
:placeholder="backend?.secretsMasked.accessKeyId || ''" :placeholder="backend?.secretsMasked.accessKeyId || ''"
@@ -250,7 +259,13 @@ function handleSubmit() {
/> />
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<label class="text-sm">{{ t('storage.secretKey') }}</label> <label class="text-sm">
{{ t('storage.secretKey') }}
<span
v-if="createMode"
class="text-error"
>*</span>
</label>
<UInput <UInput
v-model="secretAccessKey" v-model="secretAccessKey"
type="password" type="password"
+14 -2
View File
@@ -71,8 +71,10 @@ export function useAuth() {
const status = useState<AuthStatus>('auth-status', () => 'checking') const status = useState<AuthStatus>('auth-status', () => 'checking')
const user = useState<AuthUser | null>('auth-user', () => null) const user = useState<AuthUser | null>('auth-user', () => null)
const authStatus = useState<AuthStatusResponse | null>('auth-status-response', () => null) const authStatus = useState<AuthStatusResponse | null>('auth-status-response', () => null)
/** 是否已完成至少一次会话校验(用于避免路由切换时反复全屏 loading) */
const hasValidatedOnce = useState('auth-validated-once', () => false)
const isChecking = computed(() => status.value === 'checking') const isChecking = computed(() => status.value === 'checking' && !hasValidatedOnce.value)
const isAuthenticated = computed(() => status.value === 'authenticated') const isAuthenticated = computed(() => status.value === 'authenticated')
const isAdmin = computed(() => user.value?.role === 'admin') const isAdmin = computed(() => user.value?.role === 'admin')
@@ -84,10 +86,14 @@ export function useAuth() {
return data return data
} }
async function checkSession() { async function checkSession(options?: { force?: boolean }) {
const silent = !options?.force && hasValidatedOnce.value
if (!silent) {
status.value = 'checking' status.value = 'checking'
}
try { try {
const data = await fetchStatus() const data = await fetchStatus()
hasValidatedOnce.value = true
if (data.user) { if (data.user) {
user.value = data.user user.value = data.user
status.value = 'authenticated' status.value = 'authenticated'
@@ -97,6 +103,7 @@ export function useAuth() {
status.value = 'unauthenticated' status.value = 'unauthenticated'
return false return false
} catch { } catch {
hasValidatedOnce.value = true
user.value = null user.value = null
status.value = 'unauthenticated' status.value = 'unauthenticated'
return false return false
@@ -129,6 +136,7 @@ export function useAuth() {
user.value = result.user user.value = result.user
} }
status.value = 'authenticated' status.value = 'authenticated'
hasValidatedOnce.value = true
await fetchStatus() await fetchStatus()
return { ok: true } return { ok: true }
} catch (error: unknown) { } catch (error: unknown) {
@@ -159,6 +167,7 @@ export function useAuth() {
) )
user.value = result.user user.value = result.user
status.value = 'authenticated' status.value = 'authenticated'
hasValidatedOnce.value = true
await fetchStatus() await fetchStatus()
return { ok: true } return { ok: true }
} catch (error: unknown) { } catch (error: unknown) {
@@ -194,6 +203,7 @@ export function useAuth() {
) )
user.value = result.user user.value = result.user
status.value = 'authenticated' status.value = 'authenticated'
hasValidatedOnce.value = true
await fetchStatus() await fetchStatus()
return { ok: true } return { ok: true }
} catch (error: unknown) { } catch (error: unknown) {
@@ -229,6 +239,7 @@ export function useAuth() {
) )
user.value = result.user user.value = result.user
status.value = 'authenticated' status.value = 'authenticated'
hasValidatedOnce.value = true
await fetchStatus() await fetchStatus()
return { ok: true } return { ok: true }
} catch (error: unknown) { } catch (error: unknown) {
@@ -271,6 +282,7 @@ export function useAuth() {
status.value = 'unauthenticated' status.value = 'unauthenticated'
user.value = null user.value = null
authStatus.value = null authStatus.value = null
hasValidatedOnce.value = true
} }
} }
+86 -6
View File
@@ -17,6 +17,8 @@ export function useImageList() {
const searchQuery = ref('') const searchQuery = ref('')
const activeSearch = ref('') const activeSearch = ref('')
const activeFolder = ref('all') const activeFolder = ref('all')
const activeStorageBackend = ref('all')
const storageBackendOptions = ref<Array<{ id: string, name: string }>>([])
function folderQueryParams(): Record<string, string> { function folderQueryParams(): Record<string, string> {
const folder = activeFolder.value.trim() const folder = activeFolder.value.trim()
@@ -24,18 +26,53 @@ export function useImageList() {
return { folder } return { folder }
} }
function storageQueryParams(): Record<string, string> {
const backendId = activeStorageBackend.value.trim()
if (!backendId || backendId === 'all') return {}
return { backendId }
}
function listQueryParams(): Record<string, string> {
return {
...folderQueryParams(),
...storageQueryParams()
}
}
function matchesActiveFolder(key: string) { function matchesActiveFolder(key: string) {
const folder = activeFolder.value.trim() const folder = activeFolder.value.trim()
if (!folder || folder === 'all') return true if (!folder || folder === 'all') return true
return key.startsWith(`${folder}/`) return key.startsWith(`${folder}/`)
} }
function matchesActiveStorage(item: ImageItem) {
const backendId = activeStorageBackend.value.trim()
if (!backendId || backendId === 'all') return true
return item.storage?.id === backendId
}
async function loadStorageBackendOptions() {
try {
const data = await $fetch<{
backends: Array<{ id: string, name: string, type: string }>
}>('/api/storage-backends', {
credentials: 'include'
})
storageBackendOptions.value = data.backends.map(backend => ({
id: backend.id,
name: backend.name
}))
} catch {
storageBackendOptions.value = []
}
}
async function fetchTotal() { async function fetchTotal() {
loadingTotal.value = true loadingTotal.value = true
try { try {
const data = await $fetch<{ total: number }>('/api/images/count', { const data = await $fetch<{ total: number }>('/api/images/count', {
credentials: 'include', credentials: 'include',
query: folderQueryParams() query: listQueryParams()
}) })
folderTotal.value = data.total folderTotal.value = data.total
} catch { } catch {
@@ -53,7 +90,7 @@ export function useImageList() {
query: { query: {
limit: PAGE_SIZE, limit: PAGE_SIZE,
page: targetPage, page: targetPage,
...folderQueryParams() ...listQueryParams()
} }
}) })
@@ -81,7 +118,7 @@ export function useImageList() {
q, q,
limit: PAGE_SIZE, limit: PAGE_SIZE,
page: targetPage, page: targetPage,
...folderQueryParams() ...listQueryParams()
} }
}) })
@@ -107,7 +144,7 @@ export function useImageList() {
page.value = 1 page.value = 1
activeSearch.value = '' activeSearch.value = ''
searchQuery.value = '' searchQuery.value = ''
await Promise.all([fetchList(1), fetchTotal()]) await Promise.all([fetchList(1), fetchTotal(), loadStorageBackendOptions()])
} }
async function setActiveFolder(folder: string) { async function setActiveFolder(folder: string) {
@@ -122,6 +159,18 @@ export function useImageList() {
} }
} }
async function setActiveStorageBackend(backendId: string) {
const next = backendId.trim() || 'all'
if (next === activeStorageBackend.value) return
activeStorageBackend.value = next
page.value = 1
if (activeSearch.value) {
await Promise.all([fetchSearch(1), fetchTotal()])
} else {
await Promise.all([fetchList(1), fetchTotal()])
}
}
async function submitSearch() { async function submitSearch() {
const q = searchQuery.value.trim() const q = searchQuery.value.trim()
activeSearch.value = q activeSearch.value = q
@@ -143,7 +192,9 @@ export function useImageList() {
} }
function prependItems(newItems: ImageItem[]) { function prependItems(newItems: ImageItem[]) {
const matched = newItems.filter(item => matchesActiveFolder(item.key)) const matched = newItems.filter(item =>
matchesActiveFolder(item.key) && matchesActiveStorage(item)
)
if (!matched.length || page.value !== 1) return if (!matched.length || page.value !== 1) return
items.value = [...matched, ...items.value] items.value = [...matched, ...items.value]
total.value += matched.length total.value += matched.length
@@ -164,16 +215,41 @@ export function useImageList() {
} }
} }
const activeStorageLabel = computed(() => {
if (activeStorageBackend.value === 'all') return ''
return storageBackendOptions.value.find(
backend => backend.id === activeStorageBackend.value
)?.name ?? activeStorageBackend.value
})
const listSummary = computed(() => { const listSummary = computed(() => {
const folder = activeFolder.value.trim() || 'all' const folder = activeFolder.value.trim() || 'all'
const folderLabel = folder === 'all' ? t('stats.allFolders') : folder const folderLabel = folder === 'all' ? t('stats.allFolders') : folder
const storageLabel = activeStorageLabel.value
if (loadingTotal.value && folderTotal.value === null) { if (loadingTotal.value && folderTotal.value === null) {
return `${folderLabel} · ${t('stats.counting')}` return storageLabel
? `${folderLabel} · ${storageLabel} · ${t('stats.counting')}`
: `${folderLabel} · ${t('stats.counting')}`
} }
if (activeSearch.value) { if (activeSearch.value) {
if (storageLabel) {
return t('stats.summarySearchStorage', {
folder: folderLabel,
storage: storageLabel,
q: activeSearch.value,
total: total.value
})
}
return t('stats.summarySearch', { folder: folderLabel, q: activeSearch.value, total: total.value }) return t('stats.summarySearch', { folder: folderLabel, q: activeSearch.value, total: total.value })
} }
const count = folderTotal.value === null ? total.value : folderTotal.value const count = folderTotal.value === null ? total.value : folderTotal.value
if (storageLabel) {
return t('stats.summaryTotalStorage', {
folder: folderLabel,
storage: storageLabel,
total: count
})
}
return t('stats.summaryTotal', { folder: folderLabel, total: count }) return t('stats.summaryTotal', { folder: folderLabel, total: count })
}) })
@@ -184,6 +260,8 @@ export function useImageList() {
total, total,
loading, loading,
activeFolder, activeFolder,
activeStorageBackend,
storageBackendOptions,
searchQuery, searchQuery,
activeSearch, activeSearch,
listSummary, listSummary,
@@ -193,6 +271,8 @@ export function useImageList() {
refreshList, refreshList,
initializeList, initializeList,
setActiveFolder, setActiveFolder,
setActiveStorageBackend,
loadStorageBackendOptions,
submitSearch, submitSearch,
goToPage, goToPage,
prependItems, prependItems,
+5 -2
View File
@@ -240,10 +240,13 @@ onMounted(async () => {
return return
} }
await checkSession() await checkSession()
if (isAuthenticated.value) {
await loadSettings()
}
}) })
watch(isAuthenticated, async (authed) => { watch(isAuthenticated, async (authed, prev) => {
if (authed) { if (authed && prev === false) {
await nextTick() await nextTick()
await loadSettings() await loadSettings()
} else { } else {
+587
View File
@@ -0,0 +1,587 @@
<script setup lang="ts">
import type { ActivityLogListResponse, ActivityLogStorage, LogAction, LogSource } from '~/types/logs'
const { isChecking, isAuthenticated, checkSession, handleAuthError, fetchStatus, isAdmin } = useAuth()
const toast = useToast()
const { t, locale } = useI18n()
const { formatFileSize } = useFileSize()
const loading = ref(true)
const page = ref(1)
const actionFilter = ref<'all' | LogAction>('all')
const sourceFilter = ref<'all' | LogSource>('all')
const userFilter = ref<'all' | number>('all')
const searchQuery = ref('')
const activeSearch = ref('')
const listData = ref<ActivityLogListResponse | null>(null)
const pageSize = 10
const summaryLabel = computed(() => {
const summary = listData.value?.summary
if (!summary) return ''
return t('logs.summaryAll', {
total: summary.total,
upload: summary.upload,
delete: summary.delete
})
})
const actionItems = computed(() => [
{ label: t('logs.filterActionAll'), value: 'all' as const },
{ label: t('logs.actionUpload'), value: 'upload' as const },
{ label: t('logs.actionDelete'), value: 'delete' as const }
])
const sourceItems = computed(() => [
{ label: t('logs.filterSourceAll'), value: 'all' as const },
{ label: t('logs.sourceWeb'), value: 'web' as const },
{ label: t('logs.sourceApi'), value: 'api' as const },
{ label: t('logs.sourceTwikoo'), value: 'twikoo' as const }
])
const userItems = computed(() => {
const items: Array<{ label: string, value: 'all' | number }> = [
{ label: t('logs.filterUserAll'), value: 'all' }
]
for (const user of listData.value?.users ?? []) {
items.push({ label: user.username, value: user.id })
}
return items
})
function formatTime(value: string) {
try {
return new Date(value).toLocaleString(locale.value, { hour12: false })
} catch {
return value
}
}
function formatTimeShort(value: string) {
try {
return new Date(value).toLocaleString(locale.value, {
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: false
})
} catch {
return value
}
}
function folderFromKey(key: string) {
const slash = key.indexOf('/')
return slash > 0 ? key.slice(0, slash) : '(root)'
}
function actionColor(action: LogAction) {
return action === 'upload' ? 'success' : 'error'
}
function actionIcon(action: LogAction) {
return action === 'upload' ? 'i-lucide-upload' : 'i-lucide-trash-2'
}
function sourceLabel(source: LogSource) {
switch (source) {
case 'web':
return t('logs.sourceWeb')
case 'api':
return t('logs.sourceApi')
case 'twikoo':
return t('logs.sourceTwikoo')
}
}
function storageIcon(storage: ActivityLogStorage | null) {
return storage?.type === 'local' ? 'i-lucide-hard-drive' : 'i-lucide-cloud'
}
function imageUrl(key: string) {
if (!import.meta.client) return ''
return `${window.location.origin}/${key}`
}
async function loadLogs() {
loading.value = true
try {
const query: Record<string, string | number> = {
page: page.value,
limit: pageSize
}
if (actionFilter.value !== 'all') {
query.action = actionFilter.value
}
if (sourceFilter.value !== 'all') {
query.source = sourceFilter.value
}
if (isAdmin.value && userFilter.value !== 'all') {
query.userId = userFilter.value
}
if (activeSearch.value) {
query.q = activeSearch.value
}
listData.value = await $fetch<ActivityLogListResponse>('/api/logs', {
credentials: 'include',
query
})
page.value = listData.value.page
} catch (error: unknown) {
handleAuthError(error)
if (isAuthenticated.value) {
toast.add({ title: t('logs.loadFailed'), color: 'error' })
}
} finally {
loading.value = false
}
}
function applyFilters() {
page.value = 1
void loadLogs()
}
function submitSearch() {
activeSearch.value = searchQuery.value.trim()
applyFilters()
}
function clearSearch() {
searchQuery.value = ''
activeSearch.value = ''
applyFilters()
}
function handlePageChange(target: number) {
page.value = target
void loadLogs()
}
onMounted(async () => {
const status = await fetchStatus()
if (!status.initialized && !status.legacyMode) {
await navigateTo('/setup')
return
}
await checkSession()
if (!isAuthenticated.value) {
await navigateTo('/')
return
}
await loadLogs()
})
watch(isAuthenticated, async (authed, prev) => {
if (authed && prev === false) {
await loadLogs()
}
})
</script>
<template>
<div class="min-h-screen">
<div
v-if="isChecking"
class="flex min-h-screen items-center justify-center"
>
<div class="flex flex-col items-center gap-3 text-muted">
<UIcon
name="i-lucide-loader-circle"
class="size-8 animate-spin"
/>
<p class="text-sm">
{{ t('common.loadingSession') }}
</p>
</div>
</div>
<AdminLoginGate v-else-if="!isAuthenticated" />
<AppShell v-else>
<section class="min-w-0 overflow-x-hidden rounded-2xl border border-default bg-elevated shadow-sm">
<div class="flex flex-wrap items-start justify-between gap-3 border-b border-default px-4 py-4 sm:px-6">
<div class="flex items-start gap-2">
<UIcon
name="i-lucide-scroll-text"
class="mt-0.5 size-5 shrink-0 text-primary"
/>
<div>
<h1 class="text-base font-semibold">
{{ t('logs.pageTitle') }}
</h1>
<p class="mt-0.5 text-xs text-muted">
{{ isAdmin ? t('logs.pageSubtitleAdmin') : t('logs.pageSubtitle') }}
</p>
</div>
</div>
<UButton
icon="i-lucide-refresh-cw"
variant="ghost"
color="neutral"
size="sm"
:aria-label="t('common.refresh')"
:loading="loading"
@click="loadLogs"
/>
</div>
<form
class="border-b border-default px-4 py-3 sm:px-6"
@submit.prevent="submitSearch"
>
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<p
v-if="summaryLabel"
class="text-xs text-muted"
>
{{ summaryLabel }}
</p>
<p
v-else
class="text-xs text-muted"
>
{{ t('stats.counting') }}
</p>
<div class="flex min-w-0 flex-wrap items-center gap-2 sm:justify-end">
<USelect
v-if="isAdmin"
v-model="userFilter"
:items="userItems"
value-key="value"
class="min-w-0 w-full sm:w-36"
size="sm"
@update:model-value="applyFilters"
/>
<USelect
v-model="actionFilter"
:items="actionItems"
value-key="value"
class="min-w-0 w-[calc(50%-0.25rem)] sm:w-32"
size="sm"
@update:model-value="applyFilters"
/>
<USelect
v-model="sourceFilter"
:items="sourceItems"
value-key="value"
class="min-w-0 w-[calc(50%-0.25rem)] sm:w-36"
size="sm"
@update:model-value="applyFilters"
/>
<div class="flex min-w-0 w-full items-center gap-2 sm:w-auto">
<UInput
v-model="searchQuery"
icon="i-lucide-search"
:placeholder="t('logs.searchPlaceholder')"
class="min-w-0 flex-1 sm:w-40 sm:flex-none"
size="sm"
:disabled="loading"
/>
<UButton
type="submit"
icon="i-lucide-search"
size="sm"
class="shrink-0"
:aria-label="t('common.search')"
:loading="loading"
>
<span class="hidden sm:inline">{{ t('common.search') }}</span>
</UButton>
<UButton
v-if="activeSearch"
type="button"
icon="i-lucide-x"
size="sm"
variant="ghost"
color="neutral"
class="shrink-0"
:aria-label="t('stats.clear')"
@click="clearSearch"
>
<span class="hidden sm:inline">{{ t('stats.clear') }}</span>
</UButton>
</div>
</div>
</div>
</form>
<div
v-if="loading"
class="p-4 sm:p-6"
>
<div class="space-y-3 sm:hidden">
<USkeleton
v-for="n in 6"
:key="`mobile-${n}`"
class="h-24 rounded-lg"
/>
</div>
<div class="hidden space-y-2 sm:block">
<USkeleton
v-for="n in 8"
:key="`desktop-${n}`"
class="h-12 rounded-lg"
/>
</div>
</div>
<template v-else-if="listData && listData.items.length">
<div class="min-w-0 sm:hidden">
<div class="divide-y divide-default">
<article
v-for="row in listData.items"
:key="row.id"
class="min-w-0 px-4 py-3.5"
>
<div class="flex items-start justify-between gap-3">
<time class="text-xs text-muted">
{{ formatTimeShort(row.createdAt) }}
</time>
<span class="shrink-0 text-xs tabular-nums text-muted">
{{ formatFileSize(row.size) }}
</span>
</div>
<div class="mt-2 flex min-w-0 max-w-full flex-wrap items-center gap-1.5">
<UBadge
:color="actionColor(row.action)"
variant="subtle"
size="xs"
>
<UIcon
:name="actionIcon(row.action)"
class="mr-1 size-3"
/>
{{ row.action === 'upload' ? t('logs.actionUpload') : t('logs.actionDelete') }}
</UBadge>
<UBadge
color="neutral"
variant="subtle"
size="xs"
>
{{ sourceLabel(row.source) }}
</UBadge>
<span
v-if="isAdmin"
class="text-xs text-muted"
>
{{ row.username ?? '—' }}
</span>
<UBadge
v-if="row.storage"
color="neutral"
variant="subtle"
size="xs"
class="max-w-full truncate"
>
<UIcon
:name="storageIcon(row.storage)"
class="mr-1 size-3"
/>
{{ row.storage.name }}
</UBadge>
</div>
<p
class="mt-2 truncate text-sm"
:title="row.originalName"
>
{{ row.originalName }}
</p>
<div class="mt-2 flex min-w-0 items-center gap-1">
<a
v-if="row.action === 'upload'"
:href="imageUrl(row.key)"
target="_blank"
rel="noopener noreferrer"
class="min-w-0 flex-1 truncate font-mono text-xs text-primary hover:underline"
:title="row.key"
>
{{ row.key }}
</a>
<span
v-else
class="min-w-0 flex-1 truncate font-mono text-xs text-dimmed"
:title="row.key"
>
{{ row.key }}
</span>
<CopyButton
icon="i-lucide-copy"
icon-only
class="shrink-0"
:label="t('common.copy')"
:value="row.key"
:success-title="t('copy.copiedUrl')"
/>
</div>
</article>
</div>
</div>
<div class="hidden overflow-x-auto sm:block">
<table class="min-w-full text-left text-sm">
<thead class="border-b border-default bg-muted/30 text-xs text-muted">
<tr>
<th class="px-4 py-3 font-medium sm:px-6">
{{ t('logs.colTime') }}
</th>
<th
v-if="isAdmin"
class="px-4 py-3 font-medium"
>
{{ t('logs.colUser') }}
</th>
<th class="px-4 py-3 font-medium">
{{ t('logs.colAction') }}
</th>
<th class="px-4 py-3 font-medium">
{{ t('logs.colSource') }}
</th>
<th class="px-4 py-3 font-medium">
{{ t('logs.colStorage') }}
</th>
<th class="hidden px-4 py-3 font-medium md:table-cell">
{{ t('logs.colFile') }}
</th>
<th class="hidden px-4 py-3 font-medium lg:table-cell">
{{ t('logs.colFolder') }}
</th>
<th class="px-4 py-3 font-medium">
{{ t('logs.colSize') }}
</th>
<th class="hidden px-4 py-3 font-medium xl:table-cell">
{{ t('logs.colKey') }}
</th>
</tr>
</thead>
<tbody class="divide-y divide-default">
<tr
v-for="row in listData.items"
:key="row.id"
class="hover:bg-muted/20"
>
<td class="whitespace-nowrap px-4 py-3 text-xs text-muted sm:px-6">
{{ formatTime(row.createdAt) }}
</td>
<td
v-if="isAdmin"
class="px-4 py-3 text-xs"
>
{{ row.username ?? '—' }}
</td>
<td class="px-4 py-3">
<UBadge
:color="actionColor(row.action)"
variant="subtle"
size="xs"
>
<UIcon
:name="actionIcon(row.action)"
class="mr-1 size-3"
/>
{{ row.action === 'upload' ? t('logs.actionUpload') : t('logs.actionDelete') }}
</UBadge>
</td>
<td class="px-4 py-3">
<UBadge
color="neutral"
variant="subtle"
size="xs"
>
{{ sourceLabel(row.source) }}
</UBadge>
</td>
<td class="px-4 py-3 text-xs">
<span
v-if="row.storage"
class="inline-flex max-w-[8rem] items-center gap-1 truncate"
:title="row.storage.name"
>
<UIcon
:name="storageIcon(row.storage)"
class="size-3.5 shrink-0 opacity-70"
/>
<span class="truncate">{{ row.storage.name }}</span>
</span>
<span
v-else
class="text-muted"
>—</span>
</td>
<td
class="hidden max-w-[12rem] truncate px-4 py-3 md:table-cell"
:title="row.originalName"
>
{{ row.originalName }}
</td>
<td class="hidden px-4 py-3 font-mono text-xs text-muted lg:table-cell">
{{ folderFromKey(row.key) }}
</td>
<td class="whitespace-nowrap px-4 py-3 tabular-nums text-muted">
{{ formatFileSize(row.size) }}
</td>
<td class="hidden px-4 py-3 xl:table-cell">
<div class="flex max-w-[16rem] items-center gap-1">
<a
v-if="row.action === 'upload'"
:href="imageUrl(row.key)"
target="_blank"
rel="noopener noreferrer"
class="truncate font-mono text-xs text-primary hover:underline"
:title="row.key"
>
{{ row.key }}
</a>
<span
v-else
class="truncate font-mono text-xs text-dimmed"
:title="row.key"
>
{{ row.key }}
</span>
<CopyButton
icon="i-lucide-copy"
icon-only
:label="t('common.copy')"
:value="row.key"
:success-title="t('copy.copiedUrl')"
/>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<div
v-else
class="px-4 py-16 text-center text-sm text-muted sm:px-6"
>
{{ t('logs.empty') }}
</div>
<div
v-if="listData && listData.total > 0"
class="border-t border-default px-4 py-4 sm:px-6"
>
<PaginationBar
:page="listData.page"
:total-pages="listData.totalPages"
:total="listData.total"
:unit="t('logs.entriesUnit')"
:loading="loading"
@update:page="handlePageChange"
/>
</div>
</section>
</AppShell>
</div>
</template>
+20 -3
View File
@@ -161,11 +161,15 @@ onMounted(async () => {
await checkSession() await checkSession()
if (isAuthenticated.value && !isAdmin.value) { if (isAuthenticated.value && !isAdmin.value) {
await navigateTo('/') await navigateTo('/')
return
}
if (isAuthenticated.value && isAdmin.value) {
await loadPage()
} }
}) })
watch(isAuthenticated, async (authed) => { watch(isAuthenticated, async (authed, prev) => {
if (authed) { if (authed && prev === false) {
if (!isAdmin.value) { if (!isAdmin.value) {
await navigateTo('/') await navigateTo('/')
return return
@@ -329,7 +333,20 @@ watch(isAuthenticated, async (authed) => {
class="size-3.5 shrink-0" class="size-3.5 shrink-0"
/> />
<span> <span>
{{ t('settings.apiTokenLine', { source: tokenSourceText(settings.tokenSource), version: settings.appVersion }) }} {{ t('settings.apiTokenLine', { source: tokenSourceText(settings.tokenSource) }) }}
<a
href="https://github.com/O96u/PicHost/releases"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-0.5 text-primary underline-offset-2 hover:underline"
:title="t('settings.viewReleases')"
>
v{{ settings.appVersion }}
<UIcon
name="i-lucide-external-link"
class="size-3 opacity-70"
/>
</a>
<template v-if="hasServerChanges"> <template v-if="hasServerChanges">
· <span class="text-warning">{{ t('settings.unsavedChanges') }}</span> · <span class="text-warning">{{ t('settings.unsavedChanges') }}</span>
</template> </template>
+42 -3
View File
@@ -41,7 +41,11 @@ const {
setActiveFolder, setActiveFolder,
submitSearch, submitSearch,
goToPage, goToPage,
removeItems removeItems,
activeStorageBackend,
storageBackendOptions,
setActiveStorageBackend,
loadStorageBackendOptions
} = useImageList() } = useImageList()
const { formatFileSize } = useFileSize() const { formatFileSize } = useFileSize()
@@ -60,6 +64,7 @@ const deleteTargetKeys = ref<string[]>([])
const batchDeleting = ref(false) const batchDeleting = ref(false)
const showScrollTop = ref(false) const showScrollTop = ref(false)
const currentFolder = ref('all') const currentFolder = ref('all')
const currentStorage = ref('all')
const folderOptions = ref<string[]>(['images']) const folderOptions = ref<string[]>(['images'])
const folderSelectItems = computed(() => [ const folderSelectItems = computed(() => [
@@ -67,6 +72,14 @@ const folderSelectItems = computed(() => [
...folderOptions.value.map(folder => ({ label: folder, value: folder })) ...folderOptions.value.map(folder => ({ label: folder, value: folder }))
]) ])
const storageSelectItems = computed(() => [
{ label: t('stats.filterStorageAll'), value: 'all' },
...storageBackendOptions.value.map(backend => ({
label: backend.name,
value: backend.id
}))
])
const selectedCount = computed(() => selectedKeys.value.size) const selectedCount = computed(() => selectedKeys.value.size)
const FOLDER_CHART_COLORS = [ const FOLDER_CHART_COLORS = [
@@ -165,7 +178,8 @@ async function refreshGallery() {
if (!isAuthenticated.value) return if (!isAuthenticated.value) return
selectedKeys.value = new Set() selectedKeys.value = new Set()
try { try {
await Promise.all([refreshList(), loadFolders()]) await Promise.all([refreshList(), loadFolders(), loadStorageBackendOptions()])
currentStorage.value = activeStorageBackend.value
} catch (error: unknown) { } catch (error: unknown) {
handleAuthError(error) handleAuthError(error)
} }
@@ -188,8 +202,10 @@ async function refreshAll() {
await Promise.all([ await Promise.all([
fetchStats(), fetchStats(),
reloadGallery(), reloadGallery(),
loadFolders() loadFolders(),
loadStorageBackendOptions()
]) ])
currentStorage.value = activeStorageBackend.value
} catch (error: unknown) { } catch (error: unknown) {
handleAuthError(error) handleAuthError(error)
if (isAuthenticated.value) { if (isAuthenticated.value) {
@@ -211,6 +227,17 @@ async function handleFolderChange(folder: string) {
} }
} }
async function handleStorageChange(backendId: string) {
if (!isAuthenticated.value) return
currentStorage.value = backendId
selectedKeys.value = new Set()
try {
await setActiveStorageBackend(backendId)
} catch (error: unknown) {
handleAuthError(error)
}
}
async function handleSearch() { async function handleSearch() {
if (!isAuthenticated.value) return if (!isAuthenticated.value) return
selectedKeys.value = new Set() selectedKeys.value = new Set()
@@ -316,6 +343,9 @@ onMounted(async () => {
return return
} }
await checkSession() await checkSession()
if (isAuthenticated.value) {
await refreshAll()
}
}) })
onUnmounted(() => { onUnmounted(() => {
@@ -478,6 +508,14 @@ watch(isAuthenticated, async (authed, prev) => {
:aria-label="t('stats.folder')" :aria-label="t('stats.folder')"
@update:model-value="handleFolderChange" @update:model-value="handleFolderChange"
/> />
<USelect
v-model="currentStorage"
:items="storageSelectItems"
class="w-[6.5rem] shrink-0 sm:w-36"
size="sm"
:aria-label="t('stats.storage')"
@update:model-value="handleStorageChange"
/>
<UInput <UInput
v-model="searchQuery" v-model="searchQuery"
icon="i-lucide-search" icon="i-lucide-search"
@@ -539,6 +577,7 @@ watch(isAuthenticated, async (authed, prev) => {
<ImageGrid <ImageGrid
v-else v-else
:show-key="false" :show-key="false"
show-storage
:items="items" :items="items"
:selected-keys="selectedKeys" :selected-keys="selectedKeys"
:deleting-keys="deletingKeys" :deleting-keys="deletingKeys"
+6 -3
View File
@@ -18,6 +18,7 @@ const savingForm = ref(false)
const deleteOpen = ref(false) const deleteOpen = ref(false)
const deleteTarget = ref<StorageBackendItem | null>(null) const deleteTarget = ref<StorageBackendItem | null>(null)
const deleteConfirmName = ref('')
const deleting = ref(false) const deleting = ref(false)
async function loadStorage() { async function loadStorage() {
@@ -165,14 +166,16 @@ function confirmDelete(backend: StorageBackendItem) {
return return
} }
deleteTarget.value = backend deleteTarget.value = backend
deleteConfirmName.value = backend.name
deleteOpen.value = true deleteOpen.value = true
} }
async function executeDelete() { async function executeDelete() {
if (!deleteTarget.value) return const target = deleteTarget.value
if (!target) return
deleting.value = true deleting.value = true
try { try {
await $fetch(`/api/admin/storage/${deleteTarget.value.id}`, { await $fetch(`/api/admin/storage/${target.id}`, {
method: 'DELETE', method: 'DELETE',
credentials: 'include' credentials: 'include'
}) })
@@ -326,7 +329,7 @@ watch(isAuthenticated, async (authed) => {
{{ t('storage.deleteConfirmTitle') }} {{ t('storage.deleteConfirmTitle') }}
</h2> </h2>
<p class="mt-2 text-sm text-muted"> <p class="mt-2 text-sm text-muted">
{{ t('storage.deleteConfirmBody', { name: deleteTarget?.name ?? '' }) }} {{ t('storage.deleteConfirmBody', { name: deleteConfirmName }) }}
</p> </p>
<div class="mt-5 flex justify-end gap-2"> <div class="mt-5 flex justify-end gap-2">
<UButton <UButton
+6
View File
@@ -36,6 +36,12 @@ export interface ImageItem {
userId: number | null userId: number | null
username: string username: string
} }
/** 图片所在存储后端(统计/管理列表) */
storage?: {
id: string
name: string
type: 'local' | 's3'
}
} }
export interface UploadErrorItem { export interface UploadErrorItem {
+43
View File
@@ -0,0 +1,43 @@
export type LogAction = 'upload' | 'delete'
export type LogSource = 'web' | 'api' | 'twikoo'
export interface ActivityLogUser {
id: number
username: string
}
export interface ActivityLogStorage {
id: string
name: string
type: 'local' | 's3'
}
export interface ActivityLogItem {
id: number
action: LogAction
key: string
originalName: string
size: number
contentType: string
source: LogSource
userId: number | null
username: string | null
storage: ActivityLogStorage | null
createdAt: string
}
export interface ActivityLogSummary {
total: number
upload: number
delete: number
}
export interface ActivityLogListResponse {
items: ActivityLogItem[]
total: number
page: number
pageSize: number
totalPages: number
summary: ActivityLogSummary
users?: ActivityLogUser[]
}
+37 -3
View File
@@ -24,6 +24,7 @@
"nav": { "nav": {
"api": "API", "api": "API",
"stats": "Stats", "stats": "Stats",
"logs": "Activity log",
"settings": "Settings", "settings": "Settings",
"storage": "Storage", "storage": "Storage",
"menu": "Menu", "menu": "Menu",
@@ -186,7 +187,8 @@
"tokenEnv": "Environment", "tokenEnv": "Environment",
"tokenDb": "Generated in app", "tokenDb": "Generated in app",
"tokenNone": "Not configured", "tokenNone": "Not configured",
"apiTokenLine": "API Token · {source} · v{version}", "apiTokenLine": "API Token · {source} ·",
"viewReleases": "View releases",
"unsavedChanges": "Unsaved changes", "unsavedChanges": "Unsaved changes",
"manageToken": "Manage token", "manageToken": "Manage token",
"envOverride": "Overridden by environment variable; cannot edit in UI", "envOverride": "Overridden by environment variable; cannot edit in UI",
@@ -292,6 +294,8 @@
"folderCount": "{count} · {size}", "folderCount": "{count} · {size}",
"gallery": "Gallery", "gallery": "Gallery",
"folder": "Folder", "folder": "Folder",
"filterStorageAll": "All storage",
"storage": "Storage",
"searchPlaceholder": "Search filename", "searchPlaceholder": "Search filename",
"clear": "Clear", "clear": "Clear",
"selected": "{n} selected", "selected": "{n} selected",
@@ -306,12 +310,42 @@
"allFolders": "All", "allFolders": "All",
"counting": "Counting…", "counting": "Counting…",
"summarySearch": "{folder} · Search “{q}” · {total} images", "summarySearch": "{folder} · Search “{q}” · {total} images",
"summaryTotal": "{folder} · {total} images" "summaryTotal": "{folder} · {total} images",
"summarySearchStorage": "{folder} · {storage} · Search “{q}” · {total} images",
"summaryTotalStorage": "{folder} · {storage} · {total} images"
},
"logs": {
"pageTitle": "Activity log",
"pageSubtitle": "Your upload and delete history",
"pageSubtitleAdmin": "Upload and delete history for all users",
"loadFailed": "Failed to load activity log",
"empty": "No activity yet",
"entriesUnit": "entries",
"filterActionAll": "All actions",
"filterSourceAll": "All sources",
"filterUserAll": "All users",
"searchPlaceholder": "Search filename",
"summaryAll": "All · {total} entries, {upload} uploads, {delete} deletes",
"actionUpload": "Upload",
"actionDelete": "Delete",
"sourceWeb": "Web",
"sourceApi": "API",
"sourceTwikoo": "Twikoo",
"colTime": "Time",
"colUser": "User",
"colStorage": "Storage",
"colAction": "Action",
"colSource": "Source",
"colFile": "File",
"colFolder": "Folder",
"colSize": "Size",
"colKey": "Path"
}, },
"image": { "image": {
"empty": "No images", "empty": "No images",
"previewFailed": "Preview failed", "previewFailed": "Preview failed",
"uploader": "Uploader: {name}" "uploader": "Uploader: {name}",
"storageBackend": "Storage: {name}"
}, },
"delete": { "delete": {
"confirmTitle": "Confirm delete", "confirmTitle": "Confirm delete",
+37 -3
View File
@@ -24,6 +24,7 @@
"nav": { "nav": {
"api": "API", "api": "API",
"stats": "统计", "stats": "统计",
"logs": "操作日志",
"settings": "设置", "settings": "设置",
"storage": "存储", "storage": "存储",
"menu": "菜单", "menu": "菜单",
@@ -186,7 +187,8 @@
"tokenEnv": "环境变量", "tokenEnv": "环境变量",
"tokenDb": "后台生成", "tokenDb": "后台生成",
"tokenNone": "未配置", "tokenNone": "未配置",
"apiTokenLine": "API Token · {source} · v{version}", "apiTokenLine": "API Token · {source} ·",
"viewReleases": "查看发行版",
"unsavedChanges": "有未保存的更改", "unsavedChanges": "有未保存的更改",
"manageToken": "管理 Token", "manageToken": "管理 Token",
"envOverride": "该项已由环境变量覆盖,无法在后台修改", "envOverride": "该项已由环境变量覆盖,无法在后台修改",
@@ -292,6 +294,8 @@
"folderCount": "{count} 张 · {size}", "folderCount": "{count} 张 · {size}",
"gallery": "图库记录", "gallery": "图库记录",
"folder": "目录", "folder": "目录",
"filterStorageAll": "全部存储",
"storage": "存储",
"searchPlaceholder": "搜索文件名", "searchPlaceholder": "搜索文件名",
"clear": "清除", "clear": "清除",
"selected": "已选 {n}", "selected": "已选 {n}",
@@ -306,12 +310,42 @@
"allFolders": "全部", "allFolders": "全部",
"counting": "统计中…", "counting": "统计中…",
"summarySearch": "{folder} · 搜索「{q}」· 共 {total} 张", "summarySearch": "{folder} · 搜索「{q}」· 共 {total} 张",
"summaryTotal": "{folder} · 共 {total} 张" "summaryTotal": "{folder} · 共 {total} 张",
"summarySearchStorage": "{folder} · {storage} · 搜索「{q}」· 共 {total} 张",
"summaryTotalStorage": "{folder} · {storage} · 共 {total} 张"
},
"logs": {
"pageTitle": "操作日志",
"pageSubtitle": "查看您的上传与删除记录",
"pageSubtitleAdmin": "查看所有用户的上传与删除记录",
"loadFailed": "加载操作日志失败",
"empty": "暂无操作记录",
"entriesUnit": "条记录",
"filterActionAll": "全部操作",
"filterSourceAll": "全部来源",
"filterUserAll": "全部用户",
"searchPlaceholder": "搜索文件名",
"summaryAll": "全部 · 共 {total} 条记录,上传 {upload} 条,删除 {delete} 条",
"actionUpload": "上传",
"actionDelete": "删除",
"sourceWeb": "网页",
"sourceApi": "API",
"sourceTwikoo": "Twikoo",
"colTime": "时间",
"colUser": "用户",
"colStorage": "存储",
"colAction": "操作",
"colSource": "来源",
"colFile": "文件名",
"colFolder": "目录",
"colSize": "大小",
"colKey": "路径"
}, },
"image": { "image": {
"empty": "暂无图片", "empty": "暂无图片",
"previewFailed": "预览失败", "previewFailed": "预览失败",
"uploader": "上传者:{name}" "uploader": "上传者:{name}",
"storageBackend": "存储:{name}"
}, },
"delete": { "delete": {
"confirmTitle": "确认删除", "confirmTitle": "确认删除",
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "pichost", "name": "pichost",
"version": "1.1.0", "version": "1.1.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "pichost", "name": "pichost",
"version": "1.1.0", "version": "1.1.2",
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "pichost", "name": "pichost",
"version": "1.1.0", "version": "1.1.2",
"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": {
+3 -1
View File
@@ -72,7 +72,9 @@ export default defineEventHandler(async (event) => {
originalName: existing.originalName, originalName: existing.originalName,
size: existing.size, size: existing.size,
contentType: existing.contentType, contentType: existing.contentType,
source: resolveActivitySource(event) source: resolveActivitySource(event),
userId: existing.userId ?? null,
backendId: existing.backendId ?? null
}) })
logInfo('delete success', { key, size: existing.size }) logInfo('delete success', { key, size: existing.size })
+7 -1
View File
@@ -1,5 +1,7 @@
import { getImageUserFilter, requireApiOrAdminAuth } from '../../utils/access' import { getImageUserFilter, requireApiOrAdminAuth } from '../../utils/access'
import { isValidFolderName } from '../../utils/image-key' import { isValidFolderName } from '../../utils/image-key'
import { readBackendIdQuery } from '../../utils/image-query'
import { createApiError } from '../../utils/api-error'
import { countImages } from '../../utils/storage' import { countImages } from '../../utils/storage'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
@@ -9,8 +11,12 @@ export default defineEventHandler(async (event) => {
const query = getQuery(event) const query = getQuery(event)
const raw = typeof query.folder === 'string' ? query.folder.trim() : '' const raw = typeof query.folder === 'string' ? query.folder.trim() : ''
const folder = raw && raw !== 'all' && isValidFolderName(raw) ? raw : undefined const folder = raw && raw !== 'all' && isValidFolderName(raw) ? raw : undefined
const backendId = readBackendIdQuery(query)
if (backendId === null) {
createApiError(event, 'INVALID_REQUEST', '无效的存储后端', 400)
}
const total = await countImages(folder, userFilter) const total = await countImages(folder, userFilter, backendId)
return { total } return { total }
}) })
+3 -1
View File
@@ -42,7 +42,9 @@ export default defineEventHandler(async (event) => {
originalName: existing.originalName, originalName: existing.originalName,
size: existing.size, size: existing.size,
contentType: existing.contentType, contentType: existing.contentType,
source: resolveActivitySource(event) source: resolveActivitySource(event),
userId: existing.userId ?? null,
backendId: existing.backendId ?? null
}) })
logInfo('delete success', { key, size: existing.size }) logInfo('delete success', { key, size: existing.size })
+10 -2
View File
@@ -5,9 +5,12 @@ import {
DEFAULT_LIST_LIMIT, DEFAULT_LIST_LIMIT,
MAX_LIST_LIMIT MAX_LIST_LIMIT
} from '../../utils/constants' } from '../../utils/constants'
import { createApiError } from '../../utils/api-error'
import { listUserIdUsernameMap } from '../../utils/db' import { listUserIdUsernameMap } from '../../utils/db'
import { mapStoredImageToItem } from '../../utils/image-response' import { mapStoredImageToItem } from '../../utils/image-response'
import { listStorageBackendNameMap } from '../../utils/storage-backends'
import { isValidFolderName } from '../../utils/image-key' import { isValidFolderName } from '../../utils/image-key'
import { readBackendIdQuery } from '../../utils/image-query'
import { listImages } from '../../utils/storage' import { listImages } from '../../utils/storage'
function readFolderQuery(query: Record<string, unknown>): string | undefined { function readFolderQuery(query: Record<string, unknown>): string | undefined {
@@ -32,14 +35,19 @@ export default defineEventHandler(async (event) => {
: 1 : 1
const folder = readFolderQuery(query) const folder = readFolderQuery(query)
const backendId = readBackendIdQuery(query)
if (backendId === null) {
createApiError(event, 'INVALID_REQUEST', '无效的存储后端', 400)
}
const userFilter = await getImageUserFilter(event) const userFilter = await getImageUserFilter(event)
const listing = await listImages({ limit, page, folder, userFilter }) const listing = await listImages({ limit, page, folder, userFilter, backendId })
const user = await getCurrentUser(event) const user = await getCurrentUser(event)
const ownerMap = user?.role === 'admin' ? listUserIdUsernameMap() : undefined const ownerMap = user?.role === 'admin' ? listUserIdUsernameMap() : undefined
const backendMap = listStorageBackendNameMap()
const items = listing.items.map(stored => const items = listing.items.map(stored =>
mapStoredImageToItem(event, stored, { ownerMap }) mapStoredImageToItem(event, stored, { ownerMap, backendMap })
) )
items.sort((a, b) => b.uploadedAt.localeCompare(a.uploadedAt)) items.sort((a, b) => b.uploadedAt.localeCompare(a.uploadedAt))
+9 -2
View File
@@ -8,7 +8,9 @@ import {
import { createApiError } from '../../utils/api-error' import { createApiError } from '../../utils/api-error'
import { listUserIdUsernameMap } from '../../utils/db' import { listUserIdUsernameMap } from '../../utils/db'
import { mapStoredImageToItem } from '../../utils/image-response' import { mapStoredImageToItem } from '../../utils/image-response'
import { listStorageBackendNameMap } from '../../utils/storage-backends'
import { isValidFolderName } from '../../utils/image-key' import { isValidFolderName } from '../../utils/image-key'
import { readBackendIdQuery } from '../../utils/image-query'
import { searchImages } from '../../utils/storage' import { searchImages } from '../../utils/storage'
function readFolderQuery(query: Record<string, unknown>): string | undefined { function readFolderQuery(query: Record<string, unknown>): string | undefined {
@@ -42,14 +44,19 @@ export default defineEventHandler(async (event) => {
: 1 : 1
const folder = readFolderQuery(query) const folder = readFolderQuery(query)
const backendId = readBackendIdQuery(query)
if (backendId === null) {
createApiError(event, 'INVALID_REQUEST', '无效的存储后端', 400)
}
const userFilter = await getImageUserFilter(event) const userFilter = await getImageUserFilter(event)
const result = await searchImages({ query: q, limit, page, folder, userFilter }) const result = await searchImages({ query: q, limit, page, folder, userFilter, backendId })
const user = await getCurrentUser(event) const user = await getCurrentUser(event)
const ownerMap = user?.role === 'admin' ? listUserIdUsernameMap() : undefined const ownerMap = user?.role === 'admin' ? listUserIdUsernameMap() : undefined
const backendMap = listStorageBackendNameMap()
const items = result.items.map(stored => const items = result.items.map(stored =>
mapStoredImageToItem(event, stored, { ownerMap }) mapStoredImageToItem(event, stored, { ownerMap, backendMap })
) )
items.sort((a, b) => b.uploadedAt.localeCompare(a.uploadedAt)) items.sort((a, b) => b.uploadedAt.localeCompare(a.uploadedAt))
+10 -3
View File
@@ -16,6 +16,7 @@ import {
isValidFolderName, isValidFolderName,
normalizeFolderName normalizeFolderName
} from '../../utils/image-key' } from '../../utils/image-key'
import { checkUploadRateLimit } from '../../utils/rate-limit'
function readFormText( function readFormText(
formData: { name?: string, data?: Buffer | Uint8Array }[], formData: { name?: string, data?: Buffer | Uint8Array }[],
@@ -29,14 +30,20 @@ function readFormText(
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
await requireUploadAuth(event) await requireUploadAuth(event)
const source = verifyApiUploadToken(event) ? 'api' : 'web'
const uploadUserId = await getUploadUserId(event)
const formData = await readMultipartFormData(event) const formData = await readMultipartFormData(event)
if (!formData?.length) { if (!formData?.length) {
createApiError(event, 'INVALID_REQUEST', '未收到上传文件', 400) createApiError(event, 'INVALID_REQUEST', '未收到上传文件', 400)
} }
const tokenField = formData.find(part => part.name === 'token')
const formToken = tokenField?.data?.length
? new TextDecoder().decode(tokenField.data).trim()
: ''
checkUploadRateLimit(event, formToken)
const source = verifyApiUploadToken(event) ? 'api' : 'web'
const uploadUserId = await getUploadUserId(event)
const query = getQuery(event) const query = getQuery(event)
const folderFromForm = readFormText(formData, 'folder') || readFormText(formData, 'type') const folderFromForm = readFormText(formData, 'folder') || readFormText(formData, 'type')
const folderFromQuery = typeof query.folder === 'string' const folderFromQuery = typeof query.folder === 'string'
+45 -5
View File
@@ -1,17 +1,21 @@
import { requireAdminAuth } from '../../utils/access' import { requireUserAuth } from '../../utils/access'
import { createApiError } from '../../utils/api-error' import { createApiError } from '../../utils/api-error'
import { logException } from '../../utils/logger'
import { import {
listActivityLogs, listActivityLogs,
listUserIdUsernameMap,
summarizeActivityLogs,
type LogAction, type LogAction,
type LogSource type LogSource
} from '../../utils/db' } from '../../utils/db'
import { ensureStorageSchema } from '../../utils/storage-backends'
import { import {
DEFAULT_LIST_LIMIT, DEFAULT_LIST_LIMIT,
MAX_LIST_LIMIT MAX_LIST_LIMIT
} from '../../utils/constants' } from '../../utils/constants'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
await requireAdminAuth(event) const user = await requireUserAuth(event)
const query = getQuery(event) const query = getQuery(event)
@@ -34,13 +38,34 @@ export default defineEventHandler(async (event) => {
? query.folder.trim() ? query.folder.trim()
: undefined : undefined
const searchRaw = typeof query.q === 'string' ? query.q.trim() : ''
const search = searchRaw || undefined
const pageRaw = Number(query.page ?? 1) const pageRaw = Number(query.page ?? 1)
const page = Number.isFinite(pageRaw) && pageRaw > 0 const page = Number.isFinite(pageRaw) && pageRaw > 0
? Math.floor(pageRaw) ? Math.floor(pageRaw)
: 1 : 1
let userId: number | undefined
if (user.role === 'admin') {
const userIdRaw = query.userId
if (userIdRaw !== undefined && userIdRaw !== '' && userIdRaw !== 'all') {
const parsed = Number(userIdRaw)
if (!Number.isFinite(parsed) || parsed <= 0) {
createApiError(event, 'INVALID_REQUEST', '无效的用户筛选', 400)
}
userId = Math.floor(parsed)
}
} else {
userId = user.id
}
try { try {
const result = listActivityLogs({ limit, page, action, source, folder }) ensureStorageSchema()
const result = listActivityLogs({ limit, page, action, source, folder, userId, search })
const summary = summarizeActivityLogs({ source, folder, userId, search })
const userMap = user.role === 'admin' ? listUserIdUsernameMap() : undefined
return { return {
items: result.items.map(row => ({ items: result.items.map(row => ({
id: row.id, id: row.id,
@@ -50,15 +75,30 @@ export default defineEventHandler(async (event) => {
size: row.size, size: row.size,
contentType: row.content_type, contentType: row.content_type,
source: row.source, source: row.source,
userId: row.user_id,
username: row.username,
storage: row.backend_id
? {
id: row.backend_id,
name: row.backend_name ?? row.backend_id,
type: (row.backend_type === 'local' ? 'local' : 's3') as 'local' | 's3'
}
: null,
createdAt: row.created_at createdAt: row.created_at
})), })),
total: result.total, total: result.total,
page: result.page, page: result.page,
pageSize: result.pageSize, pageSize: result.pageSize,
totalPages: result.totalPages totalPages: result.totalPages,
summary,
users: userMap
? Array.from(userMap.entries())
.map(([id, username]) => ({ id, username }))
.sort((a, b) => a.username.localeCompare(b.username))
: undefined
} }
} catch (error) { } catch (error) {
console.error('[logs] list failed', error) logException('activity logs list failed', error)
createApiError(event, 'INVALID_REQUEST', '读取操作记录失败', 500) createApiError(event, 'INVALID_REQUEST', '读取操作记录失败', 500)
} }
}) })
+2 -1
View File
@@ -1,5 +1,6 @@
import { getImageUserFilter, requireUserAuth } from '../utils/access' import { getImageUserFilter, requireUserAuth } from '../utils/access'
import { createApiError } from '../utils/api-error' import { createApiError } from '../utils/api-error'
import { logException } from '../utils/logger'
import { countUsers, getActivityStats } from '../utils/db' import { countUsers, getActivityStats } from '../utils/db'
import { import {
countImages, countImages,
@@ -46,7 +47,7 @@ export default defineEventHandler(async (event) => {
byFolder: scoped.byFolder byFolder: scoped.byFolder
} }
} catch (error) { } catch (error) {
console.error('[stats] failed', error) logException('stats failed', error)
createApiError(event, 'INVALID_REQUEST', '读取统计失败', 500) createApiError(event, 'INVALID_REQUEST', '读取统计失败', 500)
} }
}) })
+30
View File
@@ -0,0 +1,30 @@
import { requireUserAuth } from '../utils/access'
import {
ensureStorageSchema,
listStorageBackendNameMap,
LOCAL_BACKEND_ID
} from '../utils/storage-backends'
function sortBackendsForFilter<T extends { id: string, name: string }>(backends: T[]): T[] {
return [...backends].sort((a, b) => {
if (a.id === LOCAL_BACKEND_ID) return -1
if (b.id === LOCAL_BACKEND_ID) return 1
return a.name.localeCompare(b.name, 'zh-CN')
})
}
export default defineEventHandler(async (event) => {
await requireUserAuth(event)
ensureStorageSchema()
const map = listStorageBackendNameMap()
const backends = sortBackendsForFilter(
Array.from(map.entries()).map(([id, backend]) => ({
id,
name: backend.name,
type: backend.type
}))
)
return { backends }
})
+43
View File
@@ -0,0 +1,43 @@
import { clientIp, logError, logException, logWarn, serializeError } from '../utils/logger'
/**
* 记录未捕获的 H3/Nitro 错误(含 createApiError 抛出的 4xx/5xx)。
*/
export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('error', (error, { event }) => {
const serialized = serializeError(error)
const status = typeof serialized.statusCode === 'number'
? serialized.statusCode
: 500
if (!event) {
logError('unhandled error', serialized)
return
}
const path = getRequestURL(event).pathname
const method = getMethod(event)
const base = {
method,
path,
status,
ip: clientIp(event),
message: serialized.message,
...(serialized.statusMessage ? { reason: serialized.statusMessage } : {}),
...(serialized.data ? { data: serialized.data } : {})
}
if (!path.startsWith('/api/')) {
if (status >= 500) {
logException(`${method} ${path}`, error, base)
}
return
}
if (status >= 500) {
logException('API error', error, base)
} else if (status >= 400) {
logWarn('API rejected', base)
}
})
})
+16 -3
View File
@@ -1,4 +1,4 @@
import { clientIp, logInfo } from '../utils/logger' import { clientIp, logError, logInfo, logWarn } from '../utils/logger'
/** /**
* 只记录 /api/*,避免 /images、/twikoo 直链刷屏。 * 只记录 /api/*,避免 /images、/twikoo 直链刷屏。
@@ -19,11 +19,24 @@ export default defineNitroPlugin((nitroApp) => {
const status = getResponseStatus(event) const status = getResponseStatus(event)
const started = event.context._reqStartedAt as number | undefined const started = event.context._reqStartedAt as number | undefined
const ms = started ? Date.now() - started : undefined const ms = started ? Date.now() - started : undefined
const reason = status >= 400 ? getResponseStatusText(event) : undefined
logInfo(`${method} ${path}`, { const detail: Record<string, unknown> = {
status, status,
ms, ms,
ip: clientIp(event) ip: clientIp(event)
}) }
if (reason && reason !== String(status)) {
detail.reason = reason
}
const line = `${method} ${path}`
if (status >= 500) {
logError(line, detail)
} else if (status >= 400) {
logWarn(line, detail)
} else {
logInfo(line, detail)
}
}) })
}) })
+5 -1
View File
@@ -2,7 +2,7 @@ import { getDataDir } from '../utils/data-dir'
import { runAutoDeleteCleanup } from '../utils/auto-delete' import { runAutoDeleteCleanup } from '../utils/auto-delete'
import { migrateLocalImagesToIndex } from '../utils/image-index' import { migrateLocalImagesToIndex } from '../utils/image-index'
import { ensureDefaultBackends } from '../utils/storage-backends' import { ensureDefaultBackends } from '../utils/storage-backends'
import { logInfo } from '../utils/logger' import { logException, logInfo } from '../utils/logger'
export default defineNitroPlugin(() => { export default defineNitroPlugin(() => {
const port = process.env.NITRO_PORT || process.env.PORT || '6892' const port = process.env.NITRO_PORT || process.env.PORT || '6892'
@@ -20,11 +20,15 @@ export default defineNitroPlugin(() => {
if (count > 0) { if (count > 0) {
logInfo('migrated local images to index', { count }) logInfo('migrated local images to index', { count })
} }
}).catch((error) => {
logException('migrate local images to index failed', error)
}) })
void runAutoDeleteCleanup().then((result) => { void runAutoDeleteCleanup().then((result) => {
if (!result.skipped && (result.deleted > 0 || result.failed > 0)) { if (!result.skipped && (result.deleted > 0 || result.failed > 0)) {
logInfo('startup auto-delete', { ...result }) logInfo('startup auto-delete', { ...result })
} }
}).catch((error) => {
logException('startup auto-delete failed', error)
}) })
}) })
+2
View File
@@ -1,5 +1,6 @@
import { readMultipartFormData } from 'h3' import { readMultipartFormData } from 'h3'
import { verifyUploadTokenValue } from '../../utils/access' import { verifyUploadTokenValue } from '../../utils/access'
import { checkUploadRateLimit } from '../../utils/rate-limit'
import { import {
detectMimeFromSignature, detectMimeFromSignature,
isAllowedMimeType isAllowedMimeType
@@ -34,6 +35,7 @@ export default defineEventHandler(async (event) => {
if (!verifyUploadTokenValue(event, token)) { if (!verifyUploadTokenValue(event, token)) {
return easyImageError(401, 'token 错误或无效') return easyImageError(401, 'token 错误或无效')
} }
checkUploadRateLimit(event, token)
} }
const imagePart = formData.find( const imagePart = formData.find(
+5 -3
View File
@@ -2,7 +2,7 @@ import { insertActivityLog, listAdminUserIds, listUserAutoDeletePolicies, type A
import { getGlobalAutoDeletePolicy } from './env' import { getGlobalAutoDeletePolicy } from './env'
import { deleteImage, type StoredImage } from './storage' import { deleteImage, type StoredImage } from './storage'
import { listImageKeysForAutoDelete } from './image-index' import { listImageKeysForAutoDelete } from './image-index'
import { logInfo, logWarn } from './logger' import { logInfo, logWarn, serializeError } from './logger'
export interface AutoDeleteResult { export interface AutoDeleteResult {
deleted: number deleted: number
@@ -91,11 +91,13 @@ export async function runAutoDeleteCleanup(): Promise<AutoDeleteResult> {
originalName: image.originalName, originalName: image.originalName,
size: image.size, size: image.size,
contentType: image.contentType, contentType: image.contentType,
source: 'web' source: 'web',
userId: image.userId ?? null,
backendId: image.backendId ?? null
}) })
deleted++ deleted++
} catch (error) { } catch (error) {
logWarn('auto-delete failed', { key: image.key, error }) logWarn('auto-delete failed', { key: image.key, error: serializeError(error) })
failed++ failed++
} }
} }
+123 -23
View File
@@ -2,6 +2,7 @@ import { DatabaseSync } from 'node:sqlite'
import { mkdirSync } from 'node:fs' import { mkdirSync } from 'node:fs'
import { join } from 'node:path' import { join } from 'node:path'
import { getDataDir } from './data-dir' import { getDataDir } from './data-dir'
import { logException } from './logger'
export type LogAction = 'upload' | 'delete' export type LogAction = 'upload' | 'delete'
export type LogSource = 'web' | 'api' | 'twikoo' export type LogSource = 'web' | 'api' | 'twikoo'
@@ -14,6 +15,11 @@ export interface ActivityLogRow {
size: number size: number
content_type: string content_type: string
source: LogSource source: LogSource
user_id: number | null
username: string | null
backend_id: string | null
backend_name: string | null
backend_type: string | null
created_at: string created_at: string
} }
@@ -24,6 +30,8 @@ export interface ActivityLogInput {
size: number size: number
contentType: string contentType: string
source: LogSource source: LogSource
userId?: number | null
backendId?: string | null
createdAt?: string createdAt?: string
} }
@@ -117,6 +125,23 @@ function migrateSchema(database: DatabaseSync): void {
VALUES ('auto_delete_enabled_at', ?, ?) VALUES ('auto_delete_enabled_at', ?, ?)
`).run(now, now) `).run(now, now)
} }
const logColumns = database.prepare('PRAGMA table_info(activity_logs)').all() as Array<{
name: string
}>
if (!logColumns.some(column => column.name === 'user_id')) {
database.exec('ALTER TABLE activity_logs ADD COLUMN user_id INTEGER')
database.exec(`
CREATE INDEX IF NOT EXISTS idx_activity_logs_user_created
ON activity_logs(user_id, created_at DESC)
`)
}
const activityLogColumns = database.prepare('PRAGMA table_info(activity_logs)').all() as Array<{
name: string
}>
if (!activityLogColumns.some(column => column.name === 'backend_id')) {
database.exec('ALTER TABLE activity_logs ADD COLUMN backend_id TEXT')
}
} }
const USER_COLUMNS = 'id, username, password_hash, role, created_at, api_token, auto_delete_days, auto_delete_enabled_at' const USER_COLUMNS = 'id, username, password_hash, role, created_at, api_token, auto_delete_days, auto_delete_enabled_at'
@@ -392,8 +417,8 @@ export function insertActivityLog(input: ActivityLogInput): void {
const createdAt = input.createdAt ?? new Date().toISOString() const createdAt = input.createdAt ?? new Date().toISOString()
getDb().prepare(` getDb().prepare(`
INSERT INTO activity_logs INSERT INTO activity_logs
(action, key, original_name, size, content_type, source, created_at) (action, key, original_name, size, content_type, source, user_id, backend_id, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run( `).run(
input.action, input.action,
input.key, input.key,
@@ -401,10 +426,15 @@ export function insertActivityLog(input: ActivityLogInput): void {
input.size, input.size,
input.contentType, input.contentType,
input.source, input.source,
input.userId ?? null,
input.backendId ?? null,
createdAt createdAt
) )
} catch (error) { } catch (error) {
console.error('[activity-log] insert failed', error) logException('activity-log insert failed', error, {
action: input.action,
key: input.key
})
} }
} }
@@ -414,6 +444,8 @@ export function listActivityLogs(options: {
action?: LogAction action?: LogAction
source?: LogSource source?: LogSource
folder?: string folder?: string
userId?: number
search?: string
}): { }): {
items: ActivityLogRow[] items: ActivityLogRow[]
total: number total: number
@@ -423,26 +455,11 @@ export function listActivityLogs(options: {
} { } {
const database = getDb() const database = getDb()
const pageSize = options.limit const pageSize = options.limit
const params: Array<string | number> = [] const { whereSql, params } = buildActivityLogWhere(options)
const where: string[] = []
if (options.action) {
where.push('action = ?')
params.push(options.action)
}
if (options.source) {
where.push('source = ?')
params.push(options.source)
}
if (options.folder) {
where.push('key LIKE ?')
params.push(`${options.folder}/%`)
}
const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : ''
const countRow = database.prepare(` const countRow = database.prepare(`
SELECT COUNT(*) AS count SELECT COUNT(*) AS count
FROM activity_logs FROM activity_logs al
${whereSql} ${whereSql}
`).get(...params) as { count: number } `).get(...params) as { count: number }
@@ -453,10 +470,25 @@ export function listActivityLogs(options: {
const offset = (safePage - 1) * pageSize const offset = (safePage - 1) * pageSize
const rows = database.prepare(` const rows = database.prepare(`
SELECT id, action, key, original_name, size, content_type, source, created_at SELECT
FROM activity_logs al.id,
al.action,
al.key,
al.original_name,
al.size,
al.content_type,
al.source,
al.user_id,
u.username,
al.backend_id,
sb.name AS backend_name,
sb.type AS backend_type,
al.created_at
FROM activity_logs al
LEFT JOIN users u ON u.id = al.user_id
LEFT JOIN storage_backends sb ON sb.id = al.backend_id
${whereSql} ${whereSql}
ORDER BY id DESC ORDER BY al.id DESC
LIMIT ? OFFSET ? LIMIT ? OFFSET ?
`).all(...params, pageSize, offset) as unknown as ActivityLogRow[] `).all(...params, pageSize, offset) as unknown as ActivityLogRow[]
@@ -469,6 +501,74 @@ export function listActivityLogs(options: {
} }
} }
export function summarizeActivityLogs(options: {
source?: LogSource
folder?: string
userId?: number
search?: string
}): {
total: number
upload: number
delete: number
} {
const database = getDb()
const { whereSql, params } = buildActivityLogWhere(options, { includeAction: false })
const row = database.prepare(`
SELECT
COUNT(*) AS total,
COALESCE(SUM(CASE WHEN al.action = 'upload' THEN 1 ELSE 0 END), 0) AS upload_count,
COALESCE(SUM(CASE WHEN al.action = 'delete' THEN 1 ELSE 0 END), 0) AS delete_count
FROM activity_logs al
${whereSql}
`).get(...params) as { total: number, upload_count: number, delete_count: number } | undefined
return {
total: row?.total ?? 0,
upload: row?.upload_count ?? 0,
delete: row?.delete_count ?? 0
}
}
function buildActivityLogWhere(
options: {
action?: LogAction
source?: LogSource
folder?: string
userId?: number
search?: string
},
{ includeAction = true }: { includeAction?: boolean } = {}
): { whereSql: string, params: Array<string | number> } {
const params: Array<string | number> = []
const where: string[] = []
if (includeAction && options.action) {
where.push('al.action = ?')
params.push(options.action)
}
if (options.source) {
where.push('al.source = ?')
params.push(options.source)
}
if (options.folder) {
where.push('al.key LIKE ?')
params.push(`${options.folder}/%`)
}
if (options.userId !== undefined) {
where.push('al.user_id = ?')
params.push(options.userId)
}
if (options.search) {
where.push('al.original_name LIKE ?')
params.push(`%${options.search}%`)
}
return {
whereSql: where.length ? `WHERE ${where.join(' AND ')}` : '',
params
}
}
function countSince(action: LogAction, sinceIso: string): number { function countSince(action: LogAction, sinceIso: string): number {
const row = getDb().prepare(` const row = getDb().prepare(`
SELECT COUNT(*) AS total SELECT COUNT(*) AS total
+19 -4
View File
@@ -222,9 +222,19 @@ export async function listFoldersForUser(
return folders.sort((a, b) => a.localeCompare(b)) return folders.sort((a, b) => a.localeCompare(b))
} }
function buildBackendFilterSql(
backendId: string | undefined,
params: Array<string | number>
): string {
if (!backendId) return ''
params.push(backendId)
return ' AND backend_id = ?'
}
export async function countImages( export async function countImages(
folder?: string, folder?: string,
userFilter?: number | 'admin' userFilter?: number | 'admin',
backendId?: string
): Promise<number> { ): Promise<number> {
ensureStorageSchema() ensureStorageSchema()
const params: Array<string | number> = [] const params: Array<string | number> = []
@@ -236,8 +246,9 @@ export async function countImages(
} }
const userSql = buildUserFilterSql(userFilter, params) const userSql = buildUserFilterSql(userFilter, params)
const backendSql = buildBackendFilterSql(backendId, params)
const row = getDb().prepare(` const row = getDb().prepare(`
SELECT COUNT(*) AS count FROM images WHERE ${clauses.join(' AND ')}${userSql} SELECT COUNT(*) AS count FROM images WHERE ${clauses.join(' AND ')}${userSql}${backendSql}
`).get(...params) as { count: number } `).get(...params) as { count: number }
return row.count return row.count
@@ -264,6 +275,7 @@ export async function listImages(options: {
page?: number page?: number
folder?: string folder?: string
userFilter?: number | 'admin' userFilter?: number | 'admin'
backendId?: string
}): Promise<PaginatedResult<StoredImage>> { }): Promise<PaginatedResult<StoredImage>> {
ensureStorageSchema() ensureStorageSchema()
const params: Array<string | number> = [] const params: Array<string | number> = []
@@ -275,10 +287,11 @@ export async function listImages(options: {
} }
const userSql = buildUserFilterSql(options.userFilter, params) const userSql = buildUserFilterSql(options.userFilter, params)
const backendSql = buildBackendFilterSql(options.backendId, params)
const rows = getDb().prepare(` const rows = getDb().prepare(`
SELECT key, backend_id, user_id, folder, original_name, content_type, size, uploaded_at SELECT key, backend_id, user_id, folder, original_name, content_type, size, uploaded_at
FROM images FROM images
WHERE ${clauses.join(' AND ')}${userSql} WHERE ${clauses.join(' AND ')}${userSql}${backendSql}
ORDER BY uploaded_at DESC, key DESC ORDER BY uploaded_at DESC, key DESC
`).all(...params) as unknown as ImageIndexRow[] `).all(...params) as unknown as ImageIndexRow[]
@@ -295,6 +308,7 @@ export async function searchImages(options: {
page?: number page?: number
folder?: string folder?: string
userFilter?: number | 'admin' userFilter?: number | 'admin'
backendId?: string
}): Promise<PaginatedResult<StoredImage>> { }): Promise<PaginatedResult<StoredImage>> {
ensureStorageSchema() ensureStorageSchema()
const needle = options.query.trim().toLowerCase() const needle = options.query.trim().toLowerCase()
@@ -307,10 +321,11 @@ export async function searchImages(options: {
} }
const userSql = buildUserFilterSql(options.userFilter, params) const userSql = buildUserFilterSql(options.userFilter, params)
const backendSql = buildBackendFilterSql(options.backendId, params)
const rows = getDb().prepare(` const rows = getDb().prepare(`
SELECT key, backend_id, user_id, folder, original_name, content_type, size, uploaded_at SELECT key, backend_id, user_id, folder, original_name, content_type, size, uploaded_at
FROM images FROM images
WHERE ${clauses.join(' AND ')}${userSql} WHERE ${clauses.join(' AND ')}${userSql}${backendSql}
ORDER BY uploaded_at DESC, key DESC ORDER BY uploaded_at DESC, key DESC
`).all(...params) as unknown as ImageIndexRow[] `).all(...params) as unknown as ImageIndexRow[]
+9
View File
@@ -0,0 +1,9 @@
import { getStorageBackendRow } from './storage-backends'
export function readBackendIdQuery(
query: Record<string, unknown>
): string | undefined | null {
const raw = typeof query.backendId === 'string' ? query.backendId.trim() : ''
if (!raw || raw === 'all') return undefined
return getStorageBackendRow(raw)?.id ?? null
}
+32 -4
View File
@@ -5,8 +5,8 @@ import type { StoredImage } from './storage'
import { getImageBaseUrl } from './env' import { getImageBaseUrl } from './env'
import { getActiveBackendRow } from './storage/resolver' import { getActiveBackendRow } from './storage/resolver'
import { getStorageBackendRow, getStorageBackendFromEnv } from './storage-backends' import { getStorageBackendRow, getStorageBackendFromEnv } from './storage-backends'
import type { StorageBackendType, StorageBackendRow } from './storage/types'
import { buildObjectKey, parseS3Config } from './storage/s3' import { buildObjectKey, parseS3Config } from './storage/s3'
import type { StorageBackendRow } from './storage/types'
export function resolveImageOwner( export function resolveImageOwner(
userId: number | null | undefined, userId: number | null | undefined,
@@ -24,10 +24,32 @@ export function resolveImageOwner(
} }
} }
export function resolveImageStorage(
backendId: string | undefined,
backendMap?: Map<string, { name: string, type: StorageBackendType }>
): ImageItem['storage'] | undefined {
if (!backendId) return undefined
const fromMap = backendMap?.get(backendId)
if (fromMap) {
return { id: backendId, name: fromMap.name, type: fromMap.type }
}
const row = getStorageBackendRow(backendId)
if (row) {
return { id: backendId, name: row.name, type: row.type }
}
return { id: backendId, name: backendId, type: 's3' }
}
export function mapStoredImageToItem( export function mapStoredImageToItem(
event: H3Event, event: H3Event,
stored: StoredImage, stored: StoredImage,
options?: { ownerMap?: Map<number, string> } options?: {
ownerMap?: Map<number, string>
backendMap?: Map<string, { name: string, type: StorageBackendType }>
}
): ImageItem { ): ImageItem {
const item = buildImageItem({ const item = buildImageItem({
key: stored.key, key: stored.key,
@@ -40,8 +62,14 @@ export function mapStoredImageToItem(
}) })
const owner = resolveImageOwner(stored.userId, options?.ownerMap) const owner = resolveImageOwner(stored.userId, options?.ownerMap)
if (owner) { const storage = resolveImageStorage(stored.backendId, options?.backendMap)
return { ...item, owner }
if (owner || storage) {
return {
...item,
...(owner ? { owner } : {}),
...(storage ? { storage } : {})
}
} }
return item return item
+32
View File
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest'
import { serializeError } from './logger'
describe('serializeError', () => {
it('extracts message and status from H3-like errors', () => {
const error = new Error('bucket not found') as Error & {
statusCode: number
statusMessage: string
data: { error: { code: string } }
}
error.statusCode = 404
error.statusMessage = '存储桶不存在'
error.data = { error: { code: 'NOT_FOUND' } }
expect(serializeError(error)).toMatchObject({
name: 'Error',
message: 'bucket not found',
statusCode: 404,
statusMessage: '存储桶不存在',
data: { error: { code: 'NOT_FOUND' } }
})
})
it('includes stack for 5xx errors at info log level', () => {
const error = new Error('db locked') as Error & { statusCode: number }
error.statusCode = 500
error.stack = 'Error: db locked\n at test'
const serialized = serializeError(error)
expect(serialized.stack).toBe(error.stack)
})
})
+40
View File
@@ -7,6 +7,12 @@ const LEVEL_ORDER: Record<LogLevel, number> = {
error: 40 error: 40
} }
type H3LikeError = Error & {
statusCode?: number
statusMessage?: string
data?: unknown
}
function currentLevel(): LogLevel { function currentLevel(): LogLevel {
const raw = (process.env.LOG_LEVEL || 'info').toLowerCase() const raw = (process.env.LOG_LEVEL || 'info').toLowerCase()
if (raw === 'debug' || raw === 'info' || raw === 'warn' || raw === 'error') { if (raw === 'debug' || raw === 'info' || raw === 'warn' || raw === 'error') {
@@ -27,6 +33,35 @@ function formatLine(level: LogLevel, message: string, extra?: Record<string, unk
return `[${time}] [${level.toUpperCase()}] ${message}${payload}` return `[${time}] [${level.toUpperCase()}] ${message}${payload}`
} }
/** 将 unknown / Error / H3Error 转为可 JSON 输出的字段,便于 Docker 日志排查 */
export function serializeError(error: unknown): Record<string, unknown> {
if (error instanceof Error) {
const h3 = error as H3LikeError
const out: Record<string, unknown> = {
name: error.name,
message: error.message
}
if (h3.statusCode !== undefined) out.statusCode = h3.statusCode
if (h3.statusMessage) out.statusMessage = h3.statusMessage
if (h3.data !== undefined) out.data = h3.data
if (error.stack && shouldLog('debug')) out.stack = error.stack
else if (error.stack && (h3.statusCode ?? 500) >= 500) out.stack = error.stack
if ('cause' in error && error.cause !== undefined) {
out.cause = serializeError(error.cause)
}
return out
}
if (typeof error === 'string') return { message: error }
if (error && typeof error === 'object') {
try {
return { detail: JSON.parse(JSON.stringify(error)) }
} catch {
return { message: String(error) }
}
}
return { message: String(error) }
}
export function logDebug(message: string, extra?: Record<string, unknown>) { export function logDebug(message: string, extra?: Record<string, unknown>) {
if (!shouldLog('debug')) return if (!shouldLog('debug')) return
console.debug(formatLine('debug', message, extra)) console.debug(formatLine('debug', message, extra))
@@ -47,6 +82,11 @@ export function logError(message: string, extra?: Record<string, unknown>) {
console.error(formatLine('error', message, extra)) console.error(formatLine('error', message, extra))
} }
/** message + 原始 error 对象(自动 serialize) */
export function logException(message: string, error: unknown, extra?: Record<string, unknown>) {
logError(message, { ...extra, error: serializeError(error) })
}
export function clientIp(event: Parameters<typeof getRequestIP>[0]): string { export function clientIp(event: Parameters<typeof getRequestIP>[0]): string {
return getRequestIP(event, { xForwardedFor: true }) || '-' return getRequestIP(event, { xForwardedFor: true }) || '-'
} }
+7 -4
View File
@@ -17,7 +17,7 @@ import { buildImageItem, sanitizeOriginalName } from './image-response'
import { getWebpQuality } from './env' import { getWebpQuality } from './env'
import { insertActivityLog } from './db' import { insertActivityLog } from './db'
import { putImage } from './storage' import { putImage } from './storage'
import { logInfo, logWarn } from './logger' import { logInfo, logException } from './logger'
/** 可转 WebP 的位图格式;SVG(矢量)、ICO、已是 WebP 的原样存储 */ /** 可转 WebP 的位图格式;SVG(矢量)、ICO、已是 WebP 的原样存储 */
const WEBP_CONVERTIBLE: readonly AllowedMimeType[] = [ const WEBP_CONVERTIBLE: readonly AllowedMimeType[] = [
@@ -128,16 +128,17 @@ export async function processSingleImageUpload(
input.prefix ?? 'images' input.prefix ?? 'images'
) )
let backendId: string
try { try {
await putImage(key, compressed.bytes, { backendId = await putImage(key, compressed.bytes, {
originalName, originalName,
uploadedAt, uploadedAt,
contentType: compressed.mime, contentType: compressed.mime,
size: compressed.bytes.byteLength, size: compressed.bytes.byteLength,
userId: input.userId ?? null userId: input.userId ?? null
}) })
} catch { } catch (error) {
logWarn('upload failed: storage error', { logException('upload failed: storage error', error, {
name: originalName, name: originalName,
source: input.source ?? 'web' source: input.source ?? 'web'
}) })
@@ -159,6 +160,8 @@ export async function processSingleImageUpload(
size: compressed.bytes.byteLength, size: compressed.bytes.byteLength,
contentType: compressed.mime, contentType: compressed.mime,
source: input.source ?? 'web', source: input.source ?? 'web',
userId: input.userId ?? null,
backendId,
createdAt: uploadedAt createdAt: uploadedAt
}) })
+43
View File
@@ -0,0 +1,43 @@
import { describe, expect, it, vi } from 'vitest'
import { checkUploadRateLimit } from './rate-limit'
vi.mock('./logger', () => ({
clientIp: (event: { __ip?: string }) => event.__ip ?? '127.0.0.1'
}))
function mockEvent(ip = '127.0.0.1', authToken = '') {
return {
__ip: ip,
node: {
req: {
headers: {
...(authToken ? { 'auth-token': authToken } : {})
}
}
}
} as unknown as Parameters<typeof checkUploadRateLimit>[0]
}
describe('checkUploadRateLimit', () => {
it('allows requests under the IP limit', () => {
const event = mockEvent('10.0.0.1')
expect(() => checkUploadRateLimit(event)).not.toThrow()
})
it('limits repeated uploads by IP', () => {
const event = mockEvent('10.0.0.3')
for (let i = 0; i < 60; i++) {
checkUploadRateLimit(event)
}
expect(() => checkUploadRateLimit(event)).toThrow()
})
it('limits repeated API token uploads', () => {
const token = 'token-a'
for (let i = 0; i < 120; i++) {
checkUploadRateLimit(mockEvent(`10.0.${i % 200}`), token)
}
expect(() => checkUploadRateLimit(mockEvent('10.0.250'), token)).toThrow()
})
})
+67
View File
@@ -0,0 +1,67 @@
import { createHash } from 'node:crypto'
import { createError, getHeader, type H3Event } from 'h3'
import { clientIp } from './logger'
const UPLOAD_IP_MAX = 60
const UPLOAD_TOKEN_MAX = 120
const UPLOAD_WINDOW_MS = 15 * 60 * 1000
const stores = new Map<string, Map<string, { count: number, resetAt: number }>>()
function getStore(name: string): Map<string, { count: number, resetAt: number }> {
let store = stores.get(name)
if (!store) {
store = new Map()
stores.set(name, store)
}
return store
}
function assertRateLimit(
storeName: string,
key: string,
max: number,
windowMs: number,
message: string
): void {
const store = getStore(storeName)
const now = Date.now()
const entry = store.get(key)
if (!entry || now > entry.resetAt) {
store.set(key, { count: 1, resetAt: now + windowMs })
return
}
entry.count += 1
if (entry.count > max) {
throw createError({
statusCode: 429,
statusMessage: message
})
}
}
export function checkUploadRateLimit(event: H3Event, apiToken?: string): void {
const ip = clientIp(event)
assertRateLimit(
'upload-ip',
ip,
UPLOAD_IP_MAX,
UPLOAD_WINDOW_MS,
'上传过于频繁,请稍后再试'
)
const headerToken = getHeader(event, 'auth-token')?.trim() ?? ''
const token = apiToken?.trim() || headerToken
if (!token) return
const hash = createHash('sha256').update(token).digest('hex').slice(0, 32)
assertRateLimit(
'upload-token',
hash,
UPLOAD_TOKEN_MAX,
UPLOAD_WINDOW_MS,
'上传过于频繁,请稍后再试'
)
}
+42 -11
View File
@@ -71,18 +71,37 @@ export function ensureDefaultBackends(): void {
`).run(LOCAL_BACKEND_ID, '本地磁盘') `).run(LOCAL_BACKEND_ID, '本地磁盘')
} }
const s3 = db.prepare(` cleanupPlaceholderS3Backend(db)
SELECT id FROM storage_backends WHERE id = ?
`).get(S3_BACKEND_ID)
if (!s3) {
db.prepare(`
INSERT INTO storage_backends
(id, name, type, config_json, secret_json, serving_mode, public_url,
quota_bytes, enabled, is_default, sort_order)
VALUES (?, ?, 's3', '{}', '{}', 'proxy', '', NULL, 0, 0, 1)
`).run(S3_BACKEND_ID, 'S3 兼容存储')
} }
/** 移除早期版本自动种下的空 S3 占位(未配置、未启用、非默认、无图片) */
function cleanupPlaceholderS3Backend(db: DatabaseSync): void {
const row = db.prepare(`
SELECT * FROM storage_backends WHERE id = ?
`).get(S3_BACKEND_ID) as StorageBackendRow | undefined
if (!row || row.type !== 's3' || row.is_default === 1) {
return
}
const config = parseS3Config(row.config_json)
const secrets = parseS3Secrets(row.secret_json)
const configured = Boolean(
config?.bucket?.trim()
|| config?.endpoint?.trim()
|| secrets?.accessKeyId?.trim()
|| secrets?.secretAccessKey?.trim()
)
if (configured) {
return
}
const usage = getBackendUsageStats(S3_BACKEND_ID)
if (usage.count > 0) {
return
}
db.prepare('DELETE FROM storage_backends WHERE id = ?').run(S3_BACKEND_ID)
} }
function rowToInfo(row: StorageBackendRow, _maskSecrets = true): StorageBackendInfo & { function rowToInfo(row: StorageBackendRow, _maskSecrets = true): StorageBackendInfo & {
@@ -140,6 +159,17 @@ export function listStorageBackends(maskSecrets = true): Array<StorageBackendInf
return listStorageBackendRows().map(row => rowToInfo(row, maskSecrets)) return listStorageBackendRows().map(row => rowToInfo(row, maskSecrets))
} }
export function listStorageBackendNameMap(): Map<string, {
name: string
type: StorageBackendType
}> {
ensureStorageSchema()
const rows = getDb().prepare(`
SELECT id, name, type FROM storage_backends
`).all() as Array<{ id: string, name: string, type: StorageBackendType }>
return new Map(rows.map(row => [row.id, { name: row.name, type: row.type }]))
}
export function getStorageBackendRow(id: string): StorageBackendRow | null { export function getStorageBackendRow(id: string): StorageBackendRow | null {
ensureDefaultBackends() ensureDefaultBackends()
const row = getDb().prepare(` const row = getDb().prepare(`
@@ -172,6 +202,7 @@ export function getBackendIdForKey(key: string): string | null {
} }
export function setDefaultBackend(id: string): void { export function setDefaultBackend(id: string): void {
ensureDefaultBackends()
const db = getDb() const db = getDb()
db.exec('UPDATE storage_backends SET is_default = 0') db.exec('UPDATE storage_backends SET is_default = 0')
db.prepare(` db.prepare(`
+4 -1
View File
@@ -21,12 +21,15 @@ describe('buildBackendCapacity', () => {
it('reads disk stats for local backend when available', async () => { it('reads disk stats for local backend when available', async () => {
const capacity = await buildBackendCapacity('local', 'local', 1024, null) const capacity = await buildBackendCapacity('local', 'local', 1024, null)
if (capacity.source === 'disk') { expect(capacity.source).toBe('disk')
if (capacity.totalBytes != null) {
expect(capacity.totalBytes).toBeGreaterThan(0) expect(capacity.totalBytes).toBeGreaterThan(0)
expect(capacity.freeBytes).not.toBeNull() expect(capacity.freeBytes).not.toBeNull()
expect(capacity.percent).not.toBeNull() expect(capacity.percent).not.toBeNull()
} else { } else {
expect(capacity.usedBytes).toBe(1024) expect(capacity.usedBytes).toBe(1024)
expect(capacity.freeBytes).toBeNull()
expect(capacity.percent).toBeNull()
} }
}) })
}) })
+7 -3
View File
@@ -37,7 +37,7 @@ export async function putImage(
key: string, key: string,
bytes: Uint8Array, bytes: Uint8Array,
meta: StoredImageMeta meta: StoredImageMeta
): Promise<void> { ): Promise<string> {
const backend = await getActiveBackend() const backend = await getActiveBackend()
await backend.put(key, bytes, meta) await backend.put(key, bytes, meta)
insertImageIndex({ insertImageIndex({
@@ -49,6 +49,7 @@ export async function putImage(
size: meta.size, size: meta.size,
uploadedAt: meta.uploadedAt uploadedAt: meta.uploadedAt
}) })
return backend.id
} }
export async function headImage(key: string): Promise<StoredImage | null> { export async function headImage(key: string): Promise<StoredImage | null> {
@@ -97,9 +98,10 @@ export async function listImageKeys(): Promise<string[]> {
export async function countImages( export async function countImages(
folder?: string, folder?: string,
userFilter?: number | 'admin' userFilter?: number | 'admin',
backendId?: string
): Promise<number> { ): Promise<number> {
return countImagesFromIndex(folder, userFilter) return countImagesFromIndex(folder, userFilter, backendId)
} }
export async function getFolderStorageStats( export async function getFolderStorageStats(
@@ -129,6 +131,7 @@ export async function listImages(options: {
page?: number page?: number
folder?: string folder?: string
userFilter?: number | 'admin' userFilter?: number | 'admin'
backendId?: string
}): Promise<PaginatedResult<StoredImage>> { }): Promise<PaginatedResult<StoredImage>> {
return listImagesFromIndex(options) return listImagesFromIndex(options)
} }
@@ -139,6 +142,7 @@ export async function searchImages(options: {
page?: number page?: number
folder?: string folder?: string
userFilter?: number | 'admin' userFilter?: number | 'admin'
backendId?: string
}): Promise<PaginatedResult<StoredImage>> { }): Promise<PaginatedResult<StoredImage>> {
return searchImagesFromIndex(options) return searchImagesFromIndex(options)
} }