Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55effa204e | ||
|
|
6fc054328d |
No files matched your search
+20
-1
@@ -2,6 +2,24 @@
|
|||||||
|
|
||||||
本仓库自 **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
|
## [1.1.1] - 2026-08-26
|
||||||
|
|
||||||
### 修复
|
### 修复
|
||||||
@@ -95,8 +113,9 @@
|
|||||||
|
|
||||||
### 路线图
|
### 路线图
|
||||||
|
|
||||||
- **v1.1.1**(当前 `main`):会话校验与存储占位修复、Docker 日志增强
|
- **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.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
|
||||||
|
|||||||
+3
-2
@@ -32,7 +32,7 @@
|
|||||||
|
|
||||||
| Branch | Notes |
|
| Branch | Notes |
|
||||||
| ------ | ----- |
|
| ------ | ----- |
|
||||||
| [**main**](https://github.com/O96u/PicHost/tree/main) (default) | **v1.1.1** — 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,8 @@ npm test # Unit tests
|
|||||||
|
|
||||||
| Version | Plan |
|
| Version | Plan |
|
||||||
| ---------- | -------------------------------------------------------------- |
|
| ---------- | -------------------------------------------------------------- |
|
||||||
| **v1.1.1** | Session check fixes, storage placeholder cleanup, Docker logging (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.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 |
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
|
|
||||||
| 分支 | 说明 |
|
| 分支 | 说明 |
|
||||||
| ---- | ---- |
|
| ---- | ---- |
|
||||||
| [**main**](https://github.com/O96u/PicHost/tree/main)(默认) | **v1.1.1** 主线:本地磁盘 + 多对象存储后端(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,8 @@ npm test # 单元测试
|
|||||||
|
|
||||||
| 版本 | 说明 |
|
| 版本 | 说明 |
|
||||||
| ---------- | ------------------------------------------------- |
|
| ---------- | ------------------------------------------------- |
|
||||||
| **v1.1.1** | 会话校验与存储占位修复、Docker 日志增强(当前 main) |
|
| **v1.1.2** | 操作日志、图库存储筛选、上传限流(当前 main) |
|
||||||
|
| **v1.1.1** | 会话校验与存储占位修复、Docker 日志增强 |
|
||||||
| **v1.1.0** | S3 / R2 多后端对象存储、混合直链、存储管理页 |
|
| **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 多架构构建修复 |
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -333,7 +333,20 @@ watch(isAuthenticated, async (authed, prev) => {
|
|||||||
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>
|
||||||
|
|||||||
+39
-3
@@ -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()
|
||||||
@@ -481,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"
|
||||||
@@ -542,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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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": "确认删除",
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "pichost",
|
"name": "pichost",
|
||||||
"version": "1.1.1",
|
"version": "1.1.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "pichost",
|
"name": "pichost",
|
||||||
"version": "1.1.1",
|
"version": "1.1.2",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pichost",
|
"name": "pichost",
|
||||||
"version": "1.1.1",
|
"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": {
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
|||||||
@@ -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 }
|
||||||
})
|
})
|
||||||
@@ -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 })
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -1,18 +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 { 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)
|
||||||
|
|
||||||
@@ -35,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,
|
||||||
@@ -51,12 +75,27 @@ 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) {
|
||||||
logException('activity logs list failed', error)
|
logException('activity logs list failed', error)
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
})
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -91,7 +91,9 @@ 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) {
|
||||||
|
|||||||
+118
-22
@@ -15,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,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'
|
||||||
@@ -393,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,
|
||||||
@@ -402,6 +426,8 @@ 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) {
|
||||||
@@ -418,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
|
||||||
@@ -427,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 }
|
||||||
|
|
||||||
@@ -457,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[]
|
||||||
|
|
||||||
@@ -473,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
|
||||||
|
|||||||
@@ -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[]
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -128,8 +128,9 @@ 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,
|
||||||
@@ -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
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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,
|
||||||
|
'上传过于频繁,请稍后再试'
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -159,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(`
|
||||||
@@ -191,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(`
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user