Compare commits

...
6 Commits
Author SHA1 Message Date
相柳 78283f4908 ✨ Feature(gui): group album photos by uploader config (#1452) 2026-10-17 10:09:37 +08:00
PiEgg 2e3e30fcbc 📦 Chore(github): add default code owner for review requests 2026-10-09 20:20:57 -04:00
PiEgg b6ad09936b 📦 Chore(ci): bump node version to 24 in release workflow 2026-10-07 10:48:56 +08:00
PiEgg 4c1009e8bb 📦 Chore: bump node version to 24 2026-10-07 10:43:10 +08:00
PiEgg 7a9bc23084 ✨ Feature(gui): configure server upload secret in settings (#1451)
* ✨ Feature(gui): configure server upload secret in settings

PicGo Core already supports authenticating `POST /upload` with a shared secret (`Authorization: Bearer <secret>`, `X-PicGo-Secret` or `?secret=`), resolved from `PICGO_SERVER_SECRET` or `settings.server.secret`, but the GUI offered no way to set it. Add a masked secret field with a show/hide toggle to the PicGo Server dialog, next to host and port.

The dialog saves the trimmed value as `settings.server.secret`; an empty value disables authentication. Saving already restarts the server, and Core reads the secret from config on `listen()`, so the main process needs no change and `PICGO_SERVER_SECRET` still takes precedence. The secret value is never added to the settings search index.

* 🐛 Fix(gui): keep server secret when normalizing settings

normalizeSettingsConfig only copied host, port and enable from `settings.server`, so after a reload the renderer state had no secret: the server dialog reopened with an empty secret field, and saving it again (e.g. after changing the port) would have silently cleared the secret and disabled authentication. Keep `secret` (falling back to an empty string) and cover it with a test.

* 📝 Docs: document renderer config normalizer whitelist in AGENTS.md

The server secret was dropped from renderer state because normalizeSettingsConfig only copies listed fields, and the server dialog re-saves the whole object. Record the rule for future changes: when adding a persisted config field, update the shared types, renderer state type, default value and normalizer, and add a round-trip normalizer test. Also give `settings.server.secret` an explicit empty default to follow that rule.

* 📝 Docs: require English PRs and squash merges in AGENTS.md
2026-10-05 20:26:32 +08:00
PiEgg 1c0716f59b ✅ Test(gui): wait for schema sync in plugin detail panel tests (#1449)
Mode is hydrated synchronously, but Verbosity / API version only show the saved values after the async refreshConfigSchema call resolves with the advanced-state choices. Both tests waited for Mode and then asserted the dependent fields synchronously, so on a slow CI runner they could still see the placeholder ("verbosity▼"). Move those assertions into waitFor.
2026-10-05 19:14:44 +08:00
27 changed files with 1587 additions and 54 deletions
+1
View File
@@ -0,0 +1 @@
* @Molunerfinn
+1 -1
View File
@@ -46,7 +46,7 @@ on:
- test-signing
env:
NODE_VERSION: 22.x
NODE_VERSION: 24.x
jobs:
# ============== macOS Builds ==============
+1 -1
View File
@@ -1 +1 @@
22
24
+3
View File
@@ -49,6 +49,7 @@ Static assets are served from `public/`. In the main process use `getStaticPath`
- Renderer-side date/time formatting should use `dayjs` and shared format constants from `src/renderer/utils/consts.ts` (for example `DEFAULT_DATE_TIME_FORMAT`) instead of `Intl.DateTimeFormat` or ad-hoc inline format strings.
- Enum-like object constants declared with `as const` (for example status maps, option maps, and value registries) must use PascalCase names, not camelCase. Prefer names like `PicGoCloudRequestStatusValues`, `SettingsAppearanceValues`, or `AppPlatformValues`.
- If a renderer → main request mutates persisted config/state without using `saveConfig`, call `notifyAppConfigUpdated()` in main to inform renderers.
- Renderer config normalization is a whitelist: `normalizeAppConfig` / `normalizeSettingsConfig` in `src/renderer/store/utils.ts` copy only the fields they list (for example `settings.server` → `host`, `port`, `enable`, `secret`), and anything not listed is silently dropped from renderer state after every reload. When adding a new persisted config field, update all of: the shared types (`src/universal/types/types.d.ts` / `view.d.ts`), the renderer state type (e.g. `SettingsConfigState` in `src/renderer/components/main/settings/utils.ts`), its default value, and the normalizer. Settings dialogs re-save the whole object (e.g. `settings.server`), so a field missing from the normalizer is not just invisible in the UI but gets overwritten with an empty value on the next save. Add a round-trip test (raw config → normalizer → field still present) next to `src/__tests__/renderer/store/settings-normalize.spec.ts`; a component test that feeds drafts directly will not catch this.
- Prefer enums over union types for discrete value sets (e.g., encryption methods). Avoid introducing new string literal union types.
- Renderer page/component styles should prefer Tailwind utility classes; avoid adding new Vue `<style>` blocks unless there's no reasonable Tailwind equivalent.
- New renderer ↔ main request/response APIs should be implemented via RPC routes (see `src/main/events/rpc/routes/system.ts`) with `RPCRouter` + `IRPCActionType` rather than adding ad-hoc IPC modules (e.g. `picgoCloudIPC`).
@@ -61,6 +62,8 @@ Place renderer unit specs in `test/unit/specs` with the `.spec.js` suffix; Karma
## Commit & Pull Request Guidelines
Commits follow the PicGo conventional preset enforced by Husky (`pnpm lint:dpdm` + Commitlint). Stage your changes and run `pnpm cz` to craft messages that pass CI. Pull requests should explain the change, link related issues, and attach UI screenshots or recordings. Note how you validated the work (dev server, build, Karma, Spectron) and call out migration or configuration steps reviewers must perform.
- Write pull request titles, descriptions, and review comments in English, even when the conversation with the maintainer is in another language.
- Merge pull requests with squash merge (`gh pr merge <number> --squash`), using the PR title as the squash commit subject. Do not use merge commits or rebase merges, and only merge after all required CI checks have passed.
## Internationalization Tips
Add locales by creating `public/i18n/<locale>.yml`, exposing its `LANG_DISPLAY_LABEL`, and registering it in `src/universal/i18n/index.ts`. Typed i18n declarations are generated automatically from `public/i18n/en.yml` into `src/universal/types/i18n.d.ts` and `src/renderer/i18n/i18next.d.ts`.
+6
View File
@@ -233,6 +233,11 @@ SETTINGS_SET_SERVER_HOST: Set Server Host
SETTINGS_SET_SERVER_PORT: Set Server Port
SETTINGS_TIP_PLACEHOLDER_HOST: Default:127.0.0.1
SETTINGS_TIP_PLACEHOLDER_PORT: Default:36677
SETTINGS_SET_SERVER_SECRET: Set Server Secret
SETTINGS_TIP_PLACEHOLDER_SECRET: Leave empty to disable authentication
SETTINGS_TIPS_SERVER_SECRET: "When set, POST /upload requests must send the header Authorization: Bearer <secret> (or X-PicGo-Secret: <secret>). Leave empty to allow uploads without authentication."
SETTINGS_SHOW_SECRET: Show secret
SETTINGS_HIDE_SECRET: Hide secret
SETTINGS_LOG_LEVEL_ALL: All
SETTINGS_LOG_LEVEL_SUCCESS: Success
SETTINGS_LOG_LEVEL_ERROR: Error
@@ -444,6 +449,7 @@ PRIVACY: "Please read and agree to the Privacy Policy ${privacyUrl} and Terms of
PRIVACY_TIPS: Please agree the privacy policy to upload
QUIT: Quit
ALBUM_ALL_PHOTOS: All Photos
ALBUM_CONFIG_UNKNOWN: Unsorted
ALBUM_COLLECTIONS: Collections
ALBUM_TAGS: Tags
ALBUM_MENU: Menu
+6
View File
@@ -233,6 +233,11 @@ SETTINGS_SET_SERVER_HOST: サーバーホストを設定
SETTINGS_SET_SERVER_PORT: サーバーポートを設定
SETTINGS_TIP_PLACEHOLDER_HOST: デフォルト:127.0.0.1
SETTINGS_TIP_PLACEHOLDER_PORT: デフォルト:36677
SETTINGS_SET_SERVER_SECRET: サーバーシークレットを設定
SETTINGS_TIP_PLACEHOLDER_SECRET: 空欄の場合は認証を無効にします
SETTINGS_TIPS_SERVER_SECRET: "設定すると、POST /upload リクエストには Authorization: Bearer <secret>(または X-PicGo-Secret: <secret>)ヘッダーが必要になります。空欄の場合は認証なしでアップロードできます。"
SETTINGS_SHOW_SECRET: シークレットを表示
SETTINGS_HIDE_SECRET: シークレットを隠す
SETTINGS_LOG_LEVEL_ALL: すべて
SETTINGS_LOG_LEVEL_SUCCESS: 成功
SETTINGS_LOG_LEVEL_ERROR: エラー
@@ -444,6 +449,7 @@ PRIVACY: "ご利用の前にプライバシーポリシー${privacyUrl}と利用
PRIVACY_TIPS: アップロードするにはプライバシーポリシーに同意してください
QUIT: 終了
ALBUM_ALL_PHOTOS: すべての写真
ALBUM_CONFIG_UNKNOWN: 未分類
ALBUM_COLLECTIONS: コレクション
ALBUM_TAGS: タグ
ALBUM_MENU: メニュー
+6
View File
@@ -233,6 +233,11 @@ SETTINGS_SET_SERVER_HOST: 서버 호스트 설정
SETTINGS_SET_SERVER_PORT: 서버 포트 설정
SETTINGS_TIP_PLACEHOLDER_HOST: 기본값:127.0.0.1
SETTINGS_TIP_PLACEHOLDER_PORT: 기본값:36677
SETTINGS_SET_SERVER_SECRET: 서버 시크릿 설정
SETTINGS_TIP_PLACEHOLDER_SECRET: 비워 두면 인증을 사용하지 않습니다
SETTINGS_TIPS_SERVER_SECRET: "설정하면 POST /upload 요청에 Authorization: Bearer <secret> (또는 X-PicGo-Secret: <secret>) 헤더가 필요합니다. 비워 두면 인증 없이 업로드할 수 있습니다."
SETTINGS_SHOW_SECRET: 시크릿 표시
SETTINGS_HIDE_SECRET: 시크릿 숨기기
SETTINGS_LOG_LEVEL_ALL: 전체
SETTINGS_LOG_LEVEL_SUCCESS: 성공
SETTINGS_LOG_LEVEL_ERROR: 오류
@@ -444,6 +449,7 @@ PRIVACY: "이용하기 전에 개인정보 처리방침 ${privacyUrl} 및 이용
PRIVACY_TIPS: 업로드하려면 개인정보 처리방침에 동의해 주세요
QUIT: 종료
ALBUM_ALL_PHOTOS: 전체 사진
ALBUM_CONFIG_UNKNOWN: 미분류
ALBUM_COLLECTIONS: 컬렉션
ALBUM_TAGS: 태그
ALBUM_MENU: 메뉴
+6
View File
@@ -233,6 +233,11 @@ SETTINGS_SET_SERVER_HOST: 设置监听地址
SETTINGS_SET_SERVER_PORT: 设置监听端口
SETTINGS_TIP_PLACEHOLDER_HOST: 推荐默认地址:127.0.0.1
SETTINGS_TIP_PLACEHOLDER_PORT: 推荐默认端口:36677
SETTINGS_SET_SERVER_SECRET: 设置上传鉴权密钥
SETTINGS_TIP_PLACEHOLDER_SECRET: 留空则不开启鉴权
SETTINGS_TIPS_SERVER_SECRET: "设置后,调用 POST /upload 需要带上请求头 Authorization: Bearer <密钥>(或 X-PicGo-Secret: <密钥>)。留空则无需鉴权即可上传。"
SETTINGS_SHOW_SECRET: 显示密钥
SETTINGS_HIDE_SECRET: 隐藏密钥
SETTINGS_LOG_LEVEL_ALL: 全部-All
SETTINGS_LOG_LEVEL_SUCCESS: 成功-Success
SETTINGS_LOG_LEVEL_ERROR: 错误-Error
@@ -444,6 +449,7 @@ PRIVACY: "使用前请阅读并同意隐私政策 ${privacyUrl} 与服务条款
PRIVACY_TIPS: 请同意隐私协议,否则无法上传。
QUIT: 退出
ALBUM_ALL_PHOTOS: 全部照片
ALBUM_CONFIG_UNKNOWN: 未分类
ALBUM_COLLECTIONS: 合集
ALBUM_TAGS: 标签
ALBUM_MENU: 菜单
+6
View File
@@ -233,6 +233,11 @@ SETTINGS_SET_SERVER_HOST: 設定監聽地址
SETTINGS_SET_SERVER_PORT: 設定監聽端口
SETTINGS_TIP_PLACEHOLDER_HOST: 推薦預設地址:127.0.0.1
SETTINGS_TIP_PLACEHOLDER_PORT: 推薦預設端口:36677
SETTINGS_SET_SERVER_SECRET: 設定上傳驗證金鑰
SETTINGS_TIP_PLACEHOLDER_SECRET: 留空則不啟用驗證
SETTINGS_TIPS_SERVER_SECRET: "設定後,呼叫 POST /upload 需要帶上請求標頭 Authorization: Bearer <金鑰>(或 X-PicGo-Secret: <金鑰>)。留空則無需驗證即可上傳。"
SETTINGS_SHOW_SECRET: 顯示金鑰
SETTINGS_HIDE_SECRET: 隱藏金鑰
SETTINGS_LOG_LEVEL_ALL: 全部-All
SETTINGS_LOG_LEVEL_SUCCESS: 成功-Success
SETTINGS_LOG_LEVEL_ERROR: 錯誤-Error
@@ -444,6 +449,7 @@ PRIVACY: "使用前請閱讀並同意隱私政策 ${privacyUrl} 與服務條款
PRIVACY_TIPS: 請同意隱私協議,否則無法上傳。
QUIT: 退出
ALBUM_ALL_PHOTOS: 全部相片
ALBUM_CONFIG_UNKNOWN: 未分類
ALBUM_COLLECTIONS: 合輯
ALBUM_TAGS: 標籤
ALBUM_MENU: 選單
@@ -1,5 +1,7 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { createServer } from 'node:net'
import childProcess, { ChildProcess } from 'node:child_process'
import { PassThrough } from 'node:stream'
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
@@ -321,7 +323,7 @@ describe('GUI server upload options integration', () => {
expect(body.result).toEqual([expectedUrl])
expect(body.items[0]).toMatchObject({ imgUrl: expectedUrl, type: uploaderA })
expect(uploadCaptures).toEqual([{ uploader: uploaderA, endpoint: namedA.endpoint, origins: [jsonImagePath] }])
expect(mocks.albumInsertMock).toHaveBeenCalledWith(expect.objectContaining({ imgUrl: expectedUrl, type: uploaderA }))
expect(mocks.albumInsertMock).toHaveBeenCalledWith(expect.objectContaining({ imgUrl: expectedUrl, type: uploaderA, _configId: namedA._id, _configName: namedA._configName }))
await expectConfigUnchanged()
})
@@ -339,7 +341,7 @@ describe('GUI server upload options integration', () => {
expect(body.result).toEqual([expectedUrl])
expect(body.items[0]).toMatchObject({ imgUrl: expectedUrl, fileName: 'multipart image.png', type: uploaderB })
expect(uploadCaptures[0]).toMatchObject({ uploader: uploaderB, endpoint: selectedB.endpoint })
expect(mocks.albumInsertMock).toHaveBeenCalledWith(expect.objectContaining({ imgUrl: expectedUrl, type: uploaderB }))
expect(mocks.albumInsertMock).toHaveBeenCalledWith(expect.objectContaining({ imgUrl: expectedUrl, type: uploaderB, _configId: selectedB._id, _configName: selectedB._configName }))
await expectConfigUnchanged()
})
@@ -357,7 +359,101 @@ describe('GUI server upload options integration', () => {
expect(body.result[0]).toMatch(new RegExp(`^https://uploads\\.test/${defaultA.endpoint}/\\d+\\.png$`))
expect(uploadCaptures[0]).toMatchObject({ uploader: uploaderA, endpoint: defaultA.endpoint })
expect(mocks.clipboardReadImageMock).toHaveBeenCalled()
expect(mocks.albumInsertMock).toHaveBeenCalledWith(expect.objectContaining({ imgUrl: body.result[0], type: uploaderA }))
expect(mocks.albumInsertMock).toHaveBeenCalledWith(expect.objectContaining({ imgUrl: body.result[0], type: uploaderA, _configId: defaultA._id, _configName: defaultA._configName }))
await expectConfigUnchanged()
})
it('attributes a different uploader without a profile selector to its own active config', async () => {
const response = await fetch(`${baseUrl}/upload?uploader=${uploaderB}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ list: [jsonImagePath] })
})
const body = await parseUploadResponse(response)
expect(response.status).toBe(200)
expect(body.items[0]).toMatchObject({ type: uploaderB })
expect(mocks.albumInsertMock).toHaveBeenCalledWith(expect.objectContaining({ type: uploaderB, _configId: defaultB._id }))
await expectConfigUnchanged()
})
// 本用例依赖 Core 3.1.0 的行为:PicGo.upload 在 input 为空时先校验一次 options、再 await
// 读取系统剪贴板,之后才在 lifecycle.start 中真正解析 options。因此在剪贴板读取挂起期间
// 切换活动 profile,未做固定的实现会解析到切换后的 profile——这正是本用例的判别力来源。
// 只 mock 掉"启动系统剪贴板助手"这一层外部依赖,其余(HTTP 路由、快照、resolveUploadOptions、
// lifecycle.start、上传器插件、相册归属)都走真实链路。
it.each([false, true])('pins an uploader-only clipboard request across an active-profile switch (mirror differs: %s)', async (mirrorDiffers) => {
// 手工构造 ChildProcess 并挂上 PassThrough,以便精确控制剪贴板助手何时返回路径。
const stdout = new PassThrough()
const clipboardProcess = new ChildProcess()
clipboardProcess.stdout = stdout
let reportClipboardStart: () => void = () => undefined
const clipboardStarted = new Promise<void>(resolve => { reportClipboardStart = resolve })
// 配置必须在安装 spy 之前写入:若 saveConfig 抛错,try/finally 尚未进入,spy 会泄漏到后续用例。
picgo.saveConfig({
'settings.useBuiltinClipboard': false,
[`picBed.${uploaderB}`]: mirrorDiffers ? { ...defaultB, endpoint: 'b-mirror' } : defaultB
})
const spawnSpy = vi.spyOn(childProcess, 'spawn').mockImplementation(() => {
reportClipboardStart()
return clipboardProcess
})
try {
const request = fetch(`${baseUrl}/upload?uploader=${uploaderB}`, { method: 'POST' })
await clipboardStarted
expect(uploadCaptures).toEqual([])
picgo.saveConfig({
[`picBed.${uploaderB}`]: selectedB,
[`uploader.${uploaderB}.defaultId`]: selectedB._id
})
stdout.write(jsonImagePath)
const response = await request
const body = await parseUploadResponse(response)
const expectedUrl = 'https://uploads.test/b-default/json image.png'
expect(response.status).toBe(200)
expect(body.success).toBe(true)
expect(body.result).toEqual([expectedUrl])
expect(uploadCaptures).toEqual([{ uploader: uploaderB, endpoint: 'b-default', origins: [jsonImagePath] }])
expect(mocks.albumInsertMock).toHaveBeenCalledWith(expect.objectContaining({
imgUrl: expectedUrl,
type: uploaderB,
_configId: 'b-default-id',
_configName: 'Default B'
}))
} finally {
stdout.end()
spawnSpy.mockRestore()
picgo.saveConfig({
'settings.useBuiltinClipboard': true,
[`picBed.${uploaderB}`]: defaultB,
[`uploader.${uploaderB}.defaultId`]: defaultB._id
})
}
await expectConfigUnchanged()
})
it('keeps each profile attribution isolated across concurrent requests', async () => {
const [responseA, responseB] = await Promise.all([
fetch(`${baseUrl}/upload?configId=${namedA._id}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ list: [jsonImagePath] })
}),
fetch(`${baseUrl}/upload?configId=${selectedB._id}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ list: [jsonImagePath] })
})
])
const [bodyA, bodyB] = await Promise.all([parseUploadResponse(responseA), parseUploadResponse(responseB)])
expect(responseA.status).toBe(200)
expect(responseB.status).toBe(200)
expect(bodyA.items[0]).toMatchObject({ type: uploaderA })
expect(bodyB.items[0]).toMatchObject({ type: uploaderB })
expect(mocks.albumInsertMock).toHaveBeenCalledWith(expect.objectContaining({ imgUrl: bodyA.items[0].imgUrl, _configId: namedA._id }))
expect(mocks.albumInsertMock).toHaveBeenCalledWith(expect.objectContaining({ imgUrl: bodyB.items[0].imgUrl, _configId: selectedB._id }))
await expectConfigUnchanged()
})
@@ -377,6 +473,284 @@ describe('GUI server upload options integration', () => {
await expectConfigUnchanged()
})
it('attributes an upload without options to picBed.uploader when it differs from picBed.current', async () => {
picgo.saveConfig({ 'picBed.uploader': uploaderB })
try {
const response = await fetch(`${baseUrl}/upload`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ list: [jsonImagePath] })
})
const body = await parseUploadResponse(response)
const expectedUrl = `https://uploads.test/${defaultB.endpoint}/json image.png`
expect(response.status).toBe(200)
expect(body.result).toEqual([expectedUrl])
expect(uploadCaptures).toEqual([{ uploader: uploaderB, endpoint: defaultB.endpoint, origins: [jsonImagePath] }])
expect(mocks.albumInsertMock).toHaveBeenCalledWith(expect.objectContaining({
imgUrl: expectedUrl,
type: uploaderB,
_configId: defaultB._id,
_configName: defaultB._configName
}))
} finally {
picgo.saveConfig({ 'picBed.uploader': uploaderA })
}
await expectConfigUnchanged()
})
it('does not persist a stale config when Core falls back from an unregistered uploader', async () => {
const cloudUploader = picgo.helper.uploader.get('picgo-cloud')
expect(cloudUploader).toBeDefined()
if (!cloudUploader) return
const cloudHandle = vi.spyOn(cloudUploader, 'handle').mockImplementation((ctx: IPicGo) => {
ctx.output = ctx.output.map(item => ({ ...item, imgUrl: `https://uploads.test/cloud/${item.fileName}` }))
})
picgo.saveConfig({
'picBed.uploader': 'missing-plugin',
'picBed.missing-plugin': { _id: 'stale-id', _configName: 'Stale' }
})
try {
const response = await fetch(`${baseUrl}/upload`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ list: [jsonImagePath] })
})
const body = await parseUploadResponse(response)
const expectedUrl = 'https://uploads.test/cloud/json image.png'
expect(response.status).toBe(200)
expect(body.result).toEqual([expectedUrl])
expect(body.items[0]).toMatchObject({ imgUrl: expectedUrl, type: 'picgo-cloud' })
expect(mocks.albumInsertMock).toHaveBeenCalledOnce()
const stored = mocks.albumInsertMock.mock.calls[0][0] as IImgInfo
expect(stored.type).toBe('picgo-cloud')
expect(stored).not.toHaveProperty('_configId')
expect(stored).not.toHaveProperty('_configName')
} finally {
cloudHandle.mockRestore()
picgo.saveConfig({ 'picBed.uploader': uploaderA })
picgo.removeConfig('picBed', 'missing-plugin')
}
await expectConfigUnchanged()
})
it('keeps the saved profile aligned with a default upload when the global profile switches during transform', async () => {
let releaseTransform: () => void = () => undefined
let reportTransformStart: () => void = () => undefined
const holdTransform = new Promise<void>(resolve => { releaseTransform = resolve })
const transformStarted = new Promise<void>(resolve => { reportTransformStart = resolve })
const originalHandle = integrationTransformer.handle
const transformSpy = vi.spyOn(integrationTransformer, 'handle').mockImplementation(async (ctx: IPicGo) => {
reportTransformStart()
await holdTransform
await originalHandle(ctx)
})
try {
const request = fetch(`${baseUrl}/upload`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ list: [jsonImagePath] })
})
await transformStarted
picgo.saveConfig({
[`picBed.${uploaderA}`]: namedA,
[`uploader.${uploaderA}.defaultId`]: namedA._id
})
releaseTransform()
const response = await request
const body = await parseUploadResponse(response)
const expectedUrl = `https://uploads.test/${defaultA.endpoint}/json image.png`
expect(response.status).toBe(200)
expect(body.result).toEqual([expectedUrl])
expect(mocks.albumInsertMock).toHaveBeenCalledWith(expect.objectContaining({
imgUrl: expectedUrl,
type: uploaderA,
_configId: defaultA._id,
_configName: defaultA._configName
}))
} finally {
releaseTransform()
transformSpy.mockRestore()
picgo.saveConfig({
[`picBed.${uploaderA}`]: defaultA,
[`uploader.${uploaderA}.defaultId`]: defaultA._id
})
}
await expectConfigUnchanged()
})
it('preserves the active mirror upload target when a saved profile with the same ID differs', async () => {
const mirror = { ...defaultA, endpoint: 'a-mirror-new' }
picgo.saveConfig({ [`picBed.${uploaderA}`]: mirror })
try {
const response = await fetch(`${baseUrl}/upload`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ list: [jsonImagePath] })
})
const body = await parseUploadResponse(response)
const expectedUrl = 'https://uploads.test/a-mirror-new/json image.png'
expect(response.status).toBe(200)
expect(body.result).toEqual([expectedUrl])
expect(uploadCaptures).toEqual([{ uploader: uploaderA, endpoint: mirror.endpoint, origins: [jsonImagePath] }])
expect(mocks.albumInsertMock).toHaveBeenCalledOnce()
const stored = mocks.albumInsertMock.mock.calls[0][0] as IImgInfo
expect(stored.imgUrl).toBe(expectedUrl)
expect(stored.type).toBe(uploaderA)
expect(stored).not.toHaveProperty('_configId')
expect(stored).not.toHaveProperty('_configName')
} finally {
picgo.saveConfig({ [`picBed.${uploaderA}`]: defaultA })
}
await expectConfigUnchanged()
})
it('does not attribute a legacy mirror to the wrong profile when it changes during upload', async () => {
let releaseTransform: () => void = () => undefined
let reportTransformStart: () => void = () => undefined
const holdTransform = new Promise<void>(resolve => { releaseTransform = resolve })
const transformStarted = new Promise<void>(resolve => { reportTransformStart = resolve })
const originalHandle = integrationTransformer.handle
const transformSpy = vi.spyOn(integrationTransformer, 'handle').mockImplementation(async (ctx: IPicGo) => {
reportTransformStart()
await holdTransform
await originalHandle(ctx)
})
const configFileBeforeLegacyUpload = await readFile(testState.configPath, 'utf8')
picgo.removeConfig('uploader', uploaderA)
try {
const request = fetch(`${baseUrl}/upload`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ list: [jsonImagePath] })
})
await transformStarted
picgo.saveConfig({ [`picBed.${uploaderA}`]: namedA })
releaseTransform()
const response = await request
const body = await parseUploadResponse(response)
const expectedUrl = `https://uploads.test/${namedA.endpoint}/json image.png`
expect(response.status).toBe(200)
expect(body.result).toEqual([expectedUrl])
expect(mocks.albumInsertMock).toHaveBeenCalledOnce()
const stored = mocks.albumInsertMock.mock.calls[0][0] as IImgInfo
expect(stored.imgUrl).toBe(expectedUrl)
expect(stored.type).toBe(uploaderA)
expect(stored._configId).not.toBe(defaultA._id)
} finally {
releaseTransform()
transformSpy.mockRestore()
picgo.saveConfig(rootConfigBeforeUploads as IStringKeyMap)
}
expect(structuredClone(picgo.getConfig())).toEqual(rootConfigBeforeUploads)
await expect(readFile(testState.configPath, 'utf8')).resolves.toBe(configFileBeforeLegacyUpload)
})
it.each([
['switches to B and then back to A', true],
['switches from A to B after the uploader reads A', false]
])('leaves an unpinned upload unattributed when the config %s', async (_description, switchBeforeRead) => {
const mirror = { ...defaultA, endpoint: 'a-mirror-new' }
let releaseTransform: () => void = () => undefined
let reportTransformStart: () => void = () => undefined
let releaseUpload: () => void = () => undefined
let reportUploadRead: () => void = () => undefined
const holdTransform = new Promise<void>(resolve => { releaseTransform = resolve })
const transformStarted = new Promise<void>(resolve => { reportTransformStart = resolve })
const holdUpload = new Promise<void>(resolve => { releaseUpload = resolve })
const uploadRead = new Promise<void>(resolve => { reportUploadRead = resolve })
const originalTransform = integrationTransformer.handle
const transformSpy = vi.spyOn(integrationTransformer, 'handle').mockImplementation(async (ctx: IPicGo) => {
reportTransformStart()
await holdTransform
await originalTransform(ctx)
})
const originalUpload = integrationUploader.handle
const uploaderSpy = vi.spyOn(integrationUploader, 'handle').mockImplementation(async (ctx: IPicGo) => {
await originalUpload(ctx)
reportUploadRead()
await holdUpload
})
picgo.saveConfig({ [`picBed.${uploaderA}`]: mirror })
try {
const request = fetch(`${baseUrl}/upload`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ list: [jsonImagePath] })
})
await transformStarted
if (switchBeforeRead) picgo.saveConfig({ [`picBed.${uploaderA}`]: namedA })
releaseTransform()
await uploadRead
picgo.saveConfig({ [`picBed.${uploaderA}`]: switchBeforeRead ? mirror : namedA })
releaseUpload()
const response = await request
const body = await parseUploadResponse(response)
const expectedEndpoint = switchBeforeRead ? namedA.endpoint : mirror.endpoint
const expectedUrl = `https://uploads.test/${expectedEndpoint}/json image.png`
expect(response.status).toBe(200)
expect(body.result).toEqual([expectedUrl])
expect(mocks.albumInsertMock).toHaveBeenCalledOnce()
const stored = mocks.albumInsertMock.mock.calls[0][0] as IImgInfo
expect(stored.imgUrl).toBe(expectedUrl)
expect(stored.type).toBe(uploaderA)
expect(stored).not.toHaveProperty('_configId')
expect(stored).not.toHaveProperty('_configName')
} finally {
releaseTransform()
releaseUpload()
transformSpy.mockRestore()
uploaderSpy.mockRestore()
picgo.saveConfig({ [`picBed.${uploaderA}`]: defaultA })
}
await expectConfigUnchanged()
})
it('does not persist attribution copied from an input image after fallback to cloud', async () => {
const cloudUploader = picgo.helper.uploader.get('picgo-cloud')
expect(cloudUploader).toBeDefined()
if (!cloudUploader) return
const cloudHandle = vi.spyOn(cloudUploader, 'handle').mockImplementation((ctx: IPicGo) => {
ctx.output = ctx.output.map(item => ({ ...item, imgUrl: `https://uploads.test/cloud/${item.fileName}` }))
})
const originalHandle = integrationTransformer.handle
const transformerSpy = vi.spyOn(integrationTransformer, 'handle').mockImplementation(async (ctx: IPicGo) => {
await originalHandle(ctx)
ctx.output.forEach(item => {
item._configId = 'stale-id'
item._configName = 'Old upload'
})
})
picgo.saveConfig({ 'picBed.uploader': 'missing-plugin' })
try {
const response = await fetch(`${baseUrl}/upload`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ list: [jsonImagePath] })
})
const body = await parseUploadResponse(response)
expect(response.status).toBe(200)
expect(body.items[0].type).toBe('picgo-cloud')
expect(mocks.albumInsertMock).toHaveBeenCalledOnce()
const stored = mocks.albumInsertMock.mock.calls[0][0] as IImgInfo
expect(stored).not.toHaveProperty('_configId')
expect(stored).not.toHaveProperty('_configName')
} finally {
transformerSpy.mockRestore()
cloudHandle.mockRestore()
picgo.saveConfig({ 'picBed.uploader': uploaderA })
}
await expectConfigUnchanged()
})
it('rejects invalid options before invoking the GUI upload adapter', async () => {
const response = await fetch(`${baseUrl}/upload?uploader=missing-uploader`, {
method: 'POST',
+32 -1
View File
@@ -40,6 +40,7 @@ const mocks = vi.hoisted(() => {
}
const uploadMock = vi.fn<(input?: IUploadOption, options?: UploadOptions) => Promise<ImgInfo[] | Error>>()
const resolveUploadOptionsMock = vi.fn<(_picgo: unknown, options?: UploadOptions) => { uploader: string, config?: { _id: string, _configName: string } } | undefined>()
const reportUploadDataMock = vi.fn<(webContents: WebContents, options: ReportOptions) => Promise<void>>()
const privacyCheckMock = vi.fn<() => Promise<boolean>>()
const getClipboardFilePathListMock = vi.fn<() => string[]>()
@@ -51,6 +52,7 @@ const mocks = vi.hoisted(() => {
return {
uploadMock,
resolveUploadOptionsMock,
reportUploadDataMock,
privacyCheckMock,
getClipboardFilePathListMock,
@@ -68,7 +70,13 @@ const mocks = vi.hoisted(() => {
on: vi.fn((event: string, listener: EventListener) => {
addListener(picgoListeners, event, listener)
}),
uploaderConfig: {
getConfigList: vi.fn(() => [])
},
helper: {
uploader: {
get: vi.fn(() => undefined)
},
beforeUploadPlugins: {
register: vi.fn((_name: string, plugin: RenamePlugin) => {
renameHandler = plugin.handle
@@ -113,6 +121,10 @@ vi.mock('@core/picgo', () => ({
default: mocks.picgo
}))
vi.mock('picgo', () => ({
resolveUploadOptions: mocks.resolveUploadOptionsMock
}))
vi.mock('@core/picgo/logger', () => ({
default: {
error: mocks.loggerErrorMock,
@@ -270,6 +282,7 @@ describe('Uploader task context', () => {
vi.clearAllMocks()
mocks.resetIpcListeners()
mocks.uploadMock.mockReset()
mocks.resolveUploadOptionsMock.mockReset()
mocks.reportUploadDataMock.mockReset()
mocks.reportUploadDataMock.mockResolvedValue(undefined)
mocks.privacyCheckMock.mockReset()
@@ -462,6 +475,23 @@ describe('Uploader task context', () => {
expect(mocks.loggerErrorMock).toHaveBeenCalledWith(reportError)
})
it('does not borrow the globally active config when a selected uploader has no saved profile', async () => {
const image = createImage('cloud.png')
mocks.picgo.getConfig.mockImplementation((key: string) => {
if (key === 'picBed.current') return 's3'
if (key === 'picBed.s3') return { _id: 'global-id', _configName: 'Global' }
return undefined
})
mocks.resolveUploadOptionsMock.mockReturnValue({ uploader: 'picgo-cloud' })
mocks.uploadMock.mockResolvedValue([image])
const result = await uploader.upload({ options: { uploader: 'picgo-cloud' } })
expect(result).toEqual([image])
expect(image).not.toHaveProperty('_configId')
expect(image).not.toHaveProperty('_configName')
})
it('forwards options for the Core clipboard path and preserves default uploads', async () => {
const window = createWebContents(5)
const clipboardImage = createImage('core-clipboard.png')
@@ -535,7 +565,8 @@ describe('Uploader task context', () => {
await unlinkStarted.promise
expect(mocks.writeFileMock).toHaveBeenCalledWith(
expect.stringMatching(/^\/picgo-base\/.*\/\d{17}\.png$/),
// Separator-agnostic: path.join yields backslashes on Windows.
expect.stringMatching(/^[/\\]picgo-base[/\\].*[/\\]\d{17}\.png$/),
Buffer.from('png')
)
const generatedPath = mocks.writeFileMock.mock.calls[0][0]
@@ -0,0 +1,432 @@
import { describe, expect, it } from 'vitest'
import {
buildAlbumConfigMap,
buildAlbumPhotos,
buildAlbumProviderFilters,
filterAlbumImages,
inferConfigIdFromUrl,
unknownConfigKey,
NavType,
type AlbumConfigMap
} from '@/components/main/album/utils'
const S3_TYPE = 'aws-s3'
const MY_IMG_ID = 'config-my-img'
const COS_ID = 'config-cos'
const picBeds: IPicBedType[] = [
{ type: S3_TYPE, name: 'Amazon S3', visible: true },
{ type: 'smms', name: 'SM.MS', visible: true }
]
const configMap: AlbumConfigMap = {
[S3_TYPE]: [
{ id: MY_IMG_ID, name: 'my-img', urlPrefix: 'https://myimg.example.org' },
{ id: COS_ID, name: 'cos', urlPrefix: 'https://cos.example.org' }
]
}
function s3Item (overrides: Partial<ImgInfo> = {}): ImgInfo {
return {
id: `db-${Math.random().toString(36).slice(2)}`,
type: S3_TYPE,
fileName: 'photo.png',
imgUrl: 'https://myimg.example.org/img/2025/12/photo.png',
...overrides
}
}
describe('inferConfigIdFromUrl', () => {
it('matches a legacy item by its url prefix', () => {
const id = inferConfigIdFromUrl(s3Item(), configMap[S3_TYPE])
expect(id).toBe(MY_IMG_ID)
})
it('distinguishes configs that differ only by host', () => {
const id = inferConfigIdFromUrl(
s3Item({ imgUrl: 'https://cos.example.org/img/2025/12/photo.png' }),
configMap[S3_TYPE]
)
expect(id).toBe(COS_ID)
})
it('prefers the longest matching prefix when configs share a host', () => {
const configs = [
{ id: 'root', name: 'root', urlPrefix: 'https://cdn.example.org' },
{ id: 'nested', name: 'nested', urlPrefix: 'https://cdn.example.org/album' }
]
const id = inferConfigIdFromUrl(
s3Item({ imgUrl: 'https://cdn.example.org/album/photo.png' }),
configs
)
expect(id).toBe('nested')
})
it('requires a path boundary so a sibling segment is not claimed', () => {
const configs = [{ id: 'foo', name: 'foo', urlPrefix: 'https://cdn.example.org/foo' }]
expect(inferConfigIdFromUrl(
s3Item({ imgUrl: 'https://cdn.example.org/foobar/photo.png' }),
configs
)).toBe('')
expect(inferConfigIdFromUrl(
s3Item({ imgUrl: 'https://cdn.example.org/foo/photo.png' }),
configs
)).toBe('foo')
expect(inferConfigIdFromUrl(
s3Item({ imgUrl: 'https://cdn.example.org/foo' }),
configs
)).toBe('foo')
})
it('requires the host to match exactly', () => {
const configs = [{ id: 'cdn', name: 'cdn', urlPrefix: 'https://cdn.example.org' }]
expect(inferConfigIdFromUrl(
s3Item({ imgUrl: 'https://cdn.example.org.other.net/photo.png' }),
configs
)).toBe('')
expect(inferConfigIdFromUrl(
s3Item({ imgUrl: 'https://cdn.example.org/photo.png' }),
configs
)).toBe('cdn')
})
it('ignores host case but keeps paths case-sensitive', () => {
const configs = [
{ id: 'upper', name: 'upper', urlPrefix: 'https://cdn.example.org/Photos' },
{ id: 'lower', name: 'lower', urlPrefix: 'https://cdn.example.org/photos' }
]
expect(inferConfigIdFromUrl(
s3Item({ imgUrl: 'https://CDN.Example.ORG/Photos/a.png' }),
configs
)).toBe('upper')
expect(inferConfigIdFromUrl(
s3Item({ imgUrl: 'https://cdn.example.org/photos/a.png' }),
configs
)).toBe('lower')
})
it('ignores a rewritten imgUrl in favour of originImgUrl', () => {
const id = inferConfigIdFromUrl(
s3Item({
imgUrl: 'https://unrelated-cdn.example.net/photo.png',
originImgUrl: 'https://cos.example.org/img/photo.png'
}),
configMap[S3_TYPE]
)
expect(id).toBe(COS_ID)
})
it('tolerates a trailing slash and mixed case in the configured prefix', () => {
const id = inferConfigIdFromUrl(
s3Item({ imgUrl: 'https://myimg.example.org/img/photo.png' }),
[{ id: MY_IMG_ID, name: 'my-img', urlPrefix: 'https://MyImg.Example.org/' }]
)
expect(id).toBe(MY_IMG_ID)
})
it('returns empty string when no prefix matches', () => {
const id = inferConfigIdFromUrl(
s3Item({ imgUrl: 'https://somewhere-else.example.net/photo.png' }),
configMap[S3_TYPE]
)
expect(id).toBe('')
})
it('returns empty string when the config has no url prefix configured', () => {
const id = inferConfigIdFromUrl(s3Item(), [
{ id: 'no-prefix', name: 'no-prefix', urlPrefix: '' }
])
expect(id).toBe('')
})
it('returns empty string when the item has no url at all', () => {
const id = inferConfigIdFromUrl(
{ type: S3_TYPE, imgUrl: '', originImgUrl: '' },
configMap[S3_TYPE]
)
expect(id).toBe('')
})
})
describe('buildAlbumConfigMap', () => {
it('derives options from the uploader config, resolving url prefix fields', () => {
const map = buildAlbumConfigMap({
[S3_TYPE]: {
configList: [
{ _id: MY_IMG_ID, _configName: 'my-img', urlPrefix: 'https://myimg.example.org' },
{ _id: COS_ID, _configName: 'cos', customUrl: 'https://cos.example.org' }
]
}
})
expect(map[S3_TYPE]).toEqual([
{ id: MY_IMG_ID, name: 'my-img', urlPrefix: 'https://myimg.example.org' },
{ id: COS_ID, name: 'cos', urlPrefix: 'https://cos.example.org' }
])
})
it('skips configs without an _id and picbeds with no usable configs', () => {
const map = buildAlbumConfigMap({
[S3_TYPE]: { configList: [{ _configName: 'legacy' }] },
smms: { configList: [] }
})
expect(map).toEqual({})
})
it('falls back to the id when the config has no name', () => {
const map = buildAlbumConfigMap({
[S3_TYPE]: { configList: [{ _id: MY_IMG_ID }] }
})
expect(map[S3_TYPE][0].name).toBe(MY_IMG_ID)
expect(map[S3_TYPE][0].urlPrefix).toBe('')
})
it('returns an empty map when there is no uploader config', () => {
expect(buildAlbumConfigMap(undefined)).toEqual({})
})
})
describe('buildAlbumPhotos config attribution', () => {
it('uses the stored _configId when present, ignoring the url', () => {
const photos = buildAlbumPhotos(
[s3Item({
_configId: COS_ID,
imgUrl: 'https://myimg.example.org/img/photo.png'
})],
picBeds,
configMap
)
expect(photos[0].configId).toBe(COS_ID)
expect(photos[0].configName).toBe('cos')
})
it('infers the config for legacy items with no _configId', () => {
const photos = buildAlbumPhotos([s3Item()], picBeds, configMap)
expect(photos[0].configId).toBe(MY_IMG_ID)
expect(photos[0].configName).toBe('my-img')
})
it('resolves the display name from live config, not the upload-time snapshot', () => {
const photos = buildAlbumPhotos(
[s3Item({ _configId: MY_IMG_ID, _configName: 'old-name' })],
picBeds,
configMap
)
expect(photos[0].configName).toBe('my-img')
})
it('keeps the snapshotted name when the config no longer exists', () => {
const photos = buildAlbumPhotos(
[s3Item({ _configId: 'deleted-config', _configName: 'deleted' })],
picBeds,
configMap
)
expect(photos[0].configId).toBe('deleted-config')
expect(photos[0].configName).toBe('deleted')
})
it('leaves configId empty when nothing can be resolved', () => {
const photos = buildAlbumPhotos(
[s3Item({ imgUrl: 'https://somewhere-else.example.net/photo.png' })],
picBeds,
configMap
)
expect(photos[0].configId).toBe('')
})
it('works without a config map at all (cloud album path)', () => {
const photos = buildAlbumPhotos([s3Item()], picBeds)
expect(photos[0].configId).toBe('')
expect(photos[0].configName).toBe('')
})
})
describe('buildAlbumProviderFilters', () => {
const images = buildAlbumPhotos(
[
s3Item({ imgUrl: 'https://myimg.example.org/a.png' }),
s3Item({ imgUrl: 'https://myimg.example.org/b.png' }),
s3Item({ imgUrl: 'https://cos.example.org/c.png' }),
s3Item({ imgUrl: 'https://orphan.example.net/d.png' })
],
picBeds,
configMap
)
it('reports per-config counts and an unknown bucket', () => {
const filters = buildAlbumProviderFilters(images, picBeds, configMap)
const s3 = filters.find((item) => item.type === S3_TYPE)!
expect(s3.count).toBe(4)
expect(s3.configs).toEqual([
{ id: MY_IMG_ID, name: 'my-img', count: 2 },
{ id: COS_ID, name: 'cos', count: 1 }
])
expect(s3.unknownCount).toBe(1)
})
it('keeps photos from a deleted config reachable through a child node', () => {
const withDeletedConfig = buildAlbumPhotos(
[
s3Item({ _configId: MY_IMG_ID, _configName: 'my-img' }),
s3Item({ _configId: 'deleted-config', _configName: 'deleted' })
],
picBeds,
configMap
)
const s3 = buildAlbumProviderFilters(withDeletedConfig, picBeds, configMap)
.find((item) => item.type === S3_TYPE)!
expect(s3.count).toBe(2)
expect(s3.configs).toEqual([
{ id: MY_IMG_ID, name: 'my-img', count: 1 },
{ id: COS_ID, name: 'cos', count: 0 },
{ id: 'deleted-config', name: 'deleted', count: 1 }
])
expect(s3.unknownCount).toBe(0)
})
it('falls back to the config id when a deleted config has no snapshotted name', () => {
const withDeletedConfig = buildAlbumPhotos(
[s3Item({ _configId: 'deleted-config' })],
picBeds,
configMap
)
const s3 = buildAlbumProviderFilters(withDeletedConfig, picBeds, configMap)
.find((item) => item.type === S3_TYPE)!
expect(s3.configs).toEqual([
{ id: MY_IMG_ID, name: 'my-img', count: 0 },
{ id: COS_ID, name: 'cos', count: 0 },
{ id: 'deleted-config', name: 'deleted-config', count: 1 }
])
})
it('counts every photo of a provider exactly once across its children', () => {
const mixed = buildAlbumPhotos(
[
s3Item({ _configId: MY_IMG_ID }),
s3Item({ _configId: 'deleted-config', _configName: 'deleted' }),
s3Item({ imgUrl: 'https://orphan.example.net/d.png' })
],
picBeds,
configMap
)
const s3 = buildAlbumProviderFilters(mixed, picBeds, configMap)
.find((item) => item.type === S3_TYPE)!
const reachable = s3.configs.reduce((total, config) => total + config.count, 0) + s3.unknownCount
expect(reachable).toBe(s3.count)
})
it('reports zero counts for picbeds with no photos', () => {
const filters = buildAlbumProviderFilters(images, picBeds, configMap)
const smms = filters.find((item) => item.type === 'smms')!
expect(smms.count).toBe(0)
expect(smms.configs).toEqual([])
expect(smms.unknownCount).toBe(0)
})
it('excludes hidden picbeds', () => {
const filters = buildAlbumProviderFilters(
images,
[{ type: S3_TYPE, name: 'Amazon S3', visible: false }],
configMap
)
expect(filters).toEqual([])
})
})
describe('filterAlbumImages with config nav', () => {
const images = buildAlbumPhotos(
[
s3Item({ fileName: 'a.png', imgUrl: 'https://myimg.example.org/a.png' }),
s3Item({ fileName: 'b.png', imgUrl: 'https://cos.example.org/b.png' }),
s3Item({ fileName: 'c.png', imgUrl: 'https://orphan.example.net/c.png' })
],
picBeds,
configMap
)
it('filters to a single config', () => {
const result = filterAlbumImages(
images,
{ type: NavType.Config, value: COS_ID, providerType: S3_TYPE },
''
)
expect(result.map((image) => image.name)).toEqual(['b.png'])
})
it('filters to a config that no longer exists', () => {
const withDeletedConfig = buildAlbumPhotos(
[
s3Item({ fileName: 'a.png', _configId: MY_IMG_ID, _configName: 'my-img' }),
s3Item({ fileName: 'd.png', _configId: 'deleted-config', _configName: 'deleted' })
],
picBeds,
configMap
)
const result = filterAlbumImages(
withDeletedConfig,
{ type: NavType.Config, value: 'deleted-config', providerType: S3_TYPE },
''
)
expect(result.map((image) => image.name)).toEqual(['d.png'])
})
it('filters to the unknown bucket', () => {
const result = filterAlbumImages(
images,
{ type: NavType.Config, value: unknownConfigKey, providerType: S3_TYPE },
''
)
expect(result.map((image) => image.name)).toEqual(['c.png'])
})
it('returns nothing when providerType does not match the photo type', () => {
const result = filterAlbumImages(
images,
{ type: NavType.Config, value: MY_IMG_ID, providerType: 'smms' },
''
)
expect(result).toEqual([])
})
it('combines a config filter with the search query', () => {
const result = filterAlbumImages(
images,
{ type: NavType.Config, value: MY_IMG_ID, providerType: S3_TYPE },
'b'
)
expect(result).toEqual([])
})
it('still filters by provider across all its configs', () => {
const result = filterAlbumImages(
images,
{ type: NavType.Provider, value: S3_TYPE },
''
)
expect(result).toHaveLength(3)
})
})
@@ -249,15 +249,11 @@ describe('PluginDetailPanel — config tab persistence (issue diagnostic)', () =
expect(modeTrigger.textContent).toContain('advanced')
})
// Without the schema sync, verbosity/apiVersion values are saved but
// the basic-state choices don't include them, so they render as
// placeholder (field.name). This documents the current broken state
// and gives us a failing assertion to fix.
const verbosityTrigger = getFieldSelectTrigger('Verbosity')
const apiVersionTrigger = getFieldSelectTrigger('API version')
// After fix: these should contain 'debug' / 'v2'
expect(verbosityTrigger.textContent).toContain('debug')
expect(apiVersionTrigger.textContent).toContain('v2')
// Mode is hydrated synchronously, but verbosity/apiVersion only show the saved values once the async schema sync resolves with advanced-state choices; until then they render the placeholder (field.name), so wait for it instead of asserting right after mode.
await waitFor(() => {
expect(getFieldSelectTrigger('Verbosity').textContent).toContain('debug')
expect(getFieldSelectTrigger('API version').textContent).toContain('v2')
})
})
it('shows saved values end-to-end: user toggles mode, clicks Confirm, form retains new values', async () => {
@@ -326,7 +322,10 @@ describe('PluginDetailPanel — config tab persistence (issue diagnostic)', () =
expect(modeTrigger.textContent).toContain('advanced')
})
expect(getFieldSelectTrigger('Verbosity').textContent).toContain('debug')
expect(getFieldSelectTrigger('API version').textContent).toContain('v2')
// Same as above: dependent fields need the async schema sync to finish after the rerender.
await waitFor(() => {
expect(getFieldSelectTrigger('Verbosity').textContent).toContain('debug')
expect(getFieldSelectTrigger('API version').textContent).toContain('v2')
})
})
})
@@ -0,0 +1,93 @@
// @vitest-environment jsdom
import { useState } from 'react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key })
}))
const saveSettingsConfig = vi.fn(async (_path: string, _value?: unknown) => true)
vi.mock('@/components/main/settings/use-settings-save', () => ({
useSettingsSave: () => saveSettingsConfig
}))
import { SettingsServerDialog } from '@/components/main/settings/settings-dialog-server'
function ServerDialogHarness ({ initialSecret = '' }: { initialSecret?: string }) {
const [open, setOpen] = useState(true)
const [host, setHost] = useState('127.0.0.1')
const [port, setPort] = useState('36677')
const [enable, setEnable] = useState(true)
const [secret, setSecret] = useState(initialSecret)
return (
<>
<button type="button" onClick={() => setOpen(true)}>reopen</button>
<SettingsServerDialog
open={open}
onOpenChange={setOpen}
serverHostDraft={host}
serverPortDraft={port}
serverEnableDraft={enable}
serverSecretDraft={secret}
onServerHostDraftChange={setHost}
onServerPortDraftChange={setPort}
onServerEnableDraftChange={setEnable}
onServerSecretDraftChange={setSecret}
/>
</>
)
}
function getSecretInput () {
return screen.getByLabelText('SETTINGS_SET_SERVER_SECRET') as HTMLInputElement
}
describe('SettingsServerDialog — server secret', () => {
beforeEach(() => {
saveSettingsConfig.mockClear()
})
it('saves the trimmed secret together with host, port and enable', async () => {
render(<ServerDialogHarness />)
fireEvent.change(getSecretInput(), { target: { value: ' my-secret ' } })
fireEvent.click(screen.getByText('CONFIRM'))
await waitFor(() => expect(saveSettingsConfig).toHaveBeenCalledTimes(1))
expect(saveSettingsConfig).toHaveBeenCalledWith('settings.server', {
host: '127.0.0.1',
port: 36677,
enable: true,
secret: 'my-secret'
})
})
it('saves an empty secret when the field is cleared, which disables authentication', async () => {
render(<ServerDialogHarness initialSecret='old-secret' />)
fireEvent.change(getSecretInput(), { target: { value: ' ' } })
fireEvent.click(screen.getByText('CONFIRM'))
await waitFor(() => expect(saveSettingsConfig).toHaveBeenCalledTimes(1))
expect(saveSettingsConfig).toHaveBeenCalledWith('settings.server', expect.objectContaining({ secret: '' }))
})
it('masks the secret by default, can reveal it, and masks it again after reopening', async () => {
render(<ServerDialogHarness initialSecret='my-secret' />)
expect(getSecretInput().type).toBe('password')
fireEvent.click(screen.getByRole('button', { name: 'SETTINGS_SHOW_SECRET' }))
expect(getSecretInput().type).toBe('text')
fireEvent.click(screen.getByText('CANCEL'))
await waitFor(() => expect(screen.queryByLabelText('SETTINGS_SET_SERVER_SECRET')).toBeNull())
fireEvent.click(screen.getByText('reopen'))
await waitFor(() => expect(getSecretInput().type).toBe('password'))
})
})
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest'
import type { IConfig } from 'picgo'
import { normalizeSettingsConfig } from '@/store/utils'
function configWithServer (server: Record<string, unknown>): IConfig {
return { settings: { server } } as unknown as IConfig
}
describe('normalizeSettingsConfig — server', () => {
it('keeps the saved server secret so the settings dialog can show it again', () => {
const settings = normalizeSettingsConfig(configWithServer({
host: '127.0.0.1',
port: 36677,
enable: true,
secret: 'my-secret'
}))
expect(settings.server).toEqual({
host: '127.0.0.1',
port: 36677,
enable: true,
secret: 'my-secret'
})
})
it('falls back to an empty secret when none is saved or the value is not a string', () => {
expect(normalizeSettingsConfig(configWithServer({ host: '127.0.0.1', port: 36677, enable: true })).server.secret).toBe('')
expect(normalizeSettingsConfig(configWithServer({ host: '127.0.0.1', port: 36677, enable: true, secret: 123 })).server.secret).toBe('')
})
})
+66 -2
View File
@@ -6,11 +6,13 @@ import {
import type { IpcMainEvent, WebContents } from 'electron'
import { AsyncLocalStorage } from 'node:async_hooks'
import dayjs from 'dayjs'
import isEqual from 'lodash/isEqual'
import picgo from '@core/picgo'
import windowManager from 'apis/app/window/windowManager'
import { IWindowList } from '#/types/enum'
import util from 'util'
import type { IPicGo } from 'picgo'
import { resolveUploadOptions } from 'picgo'
import type { IPicGo, UploadOptions } from 'picgo'
import { showNotification, getClipboardFilePathList } from '~/main/utils/common'
import { GET_RENAME_FILE_NAME, RENAME_FILE_NAME } from '~/universal/events/constants'
import logger from '@core/picgo/logger'
@@ -94,6 +96,50 @@ const waitForRename = (
})
}
interface IUploaderConfigSnapshot {
uploader: string
configId: string
configName: string
}
/**
* Resolve the config selected for this request before uploading. Only uploads
* without a profile override use the globally active config as a fallback.
*/
const getActiveUploaderConfigSnapshot = (options?: UploadOptions): IUploaderConfigSnapshot | null => {
const resolved = resolveUploadOptions(picgo, options)
if (resolved) {
const config = resolved.config
return config?._id ? { uploader: resolved.uploader, configId: config._id, configName: config._configName ?? '' } : null
}
const type = picgo.getConfig<string>('picBed.uploader') || picgo.getConfig<string>('picBed.current')
if (!type) {
return null
}
const config = picgo.getConfig<IStringKeyMap>(`picBed.${type}`)
if (!config?._id || typeof config._id !== 'string') {
return null
}
return {
uploader: type,
configId: config._id,
configName: typeof config._configName === 'string' ? config._configName : ''
}
}
const applyUploaderConfigSnapshot = (
output: ImgInfo[],
snapshot: IUploaderConfigSnapshot | null
) => {
output.forEach((item) => {
delete item._configId
delete item._configName
if (!snapshot || item.type !== snapshot.uploader) return
item._configId = snapshot.configId
item._configName = snapshot.configName
})
}
class Uploader {
private readonly uploadTaskStorage = new AsyncLocalStorage<UploadTaskContext>()
constructor () {
@@ -211,8 +257,26 @@ class Uploader {
throw Error(T('PRIVACY_TIPS'))
}
const startTime = Date.now()
const output = await picgo.upload(capturedTask.input, capturedTask.options)
// Snapshot this request's uploader config before uploading, so concurrent
// uploads and config switches cannot mis-attribute album items.
const configSnapshot = getActiveUploaderConfigSnapshot(capturedTask.options)
const hasUploadSelector = Boolean(
capturedTask.options?.uploader || capturedTask.options?.configId || capturedTask.options?.configName
)
// 显式 selector 解析到已保存的配置时,读取剪贴板前就固定其 ID:Core 会在读取
// 剪贴板期间重新解析 options,不固定就会落到切换后的 profile 上。
// 无 selector 时仍需确认活动 mirror 与已保存配置一致,保留原上传目标。
const canPinConfig = configSnapshot !== null &&
Boolean(picgo.helper.uploader.get(configSnapshot.uploader)) &&
picgo.uploaderConfig.getConfigList(configSnapshot.uploader).some(config =>
config._id === configSnapshot.configId && (hasUploadSelector || isEqual(config, picgo.getConfig(`picBed.${configSnapshot.uploader}`)))
)
const uploadOptions = canPinConfig
? { ...capturedTask.options, uploader: configSnapshot.uploader, configId: configSnapshot.configId }
: capturedTask.options
const output = await picgo.upload(capturedTask.input, uploadOptions)
if (Array.isArray(output) && output.some((item: ImgInfo) => item.imgUrl)) {
applyUploaderConfigSnapshot(output, canPinConfig || hasUploadSelector ? configSnapshot : null)
if (context.active && isAvailableWebContents(capturedTask.webContents)) {
try {
await dataReportManager.reportUploadData(capturedTask.webContents, {
@@ -1,3 +1,5 @@
import { useState } from "react"
import { ChevronDownIcon, ChevronRightIcon } from "lucide-react"
import { useTranslation } from "react-i18next"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
@@ -12,6 +14,7 @@ import { useAppStore } from "@/store"
import { CloudSidebarSkeleton } from "./cloud-loading"
import {
NavType,
unknownConfigKey,
type AlbumProviderFilter,
type AlbumPhoto,
type NavContext,
@@ -33,6 +36,19 @@ type AlbumNavButtonProps = {
onClick: () => void
}
type AlbumConfigNavButtonProps = {
label: string
count: number
active: boolean
onClick: () => void
}
type AlbumConfigNode = {
key: string
label: string
count: number
}
type AlbumSidebarProps = {
images: AlbumPhoto[]
providers: AlbumProviderFilter[]
@@ -111,6 +127,50 @@ function AlbumNavButton({
)
}
function AlbumConfigNavButton({
label,
count,
active,
onClick,
}: AlbumConfigNavButtonProps) {
return (
<Button
variant="ghost"
className={cn(
"h-8 w-full cursor-pointer justify-between rounded-md px-2 transition-all duration-300",
"focus-visible:ring-0 focus-visible:border-transparent",
"focus-visible:bg-(--app-sidebar-item-hover-bg)",
active
? "bg-(--app-sidebar-item-active-bg) text-(--app-sidebar-item-active-color) hover:bg-(--app-sidebar-item-active-bg) hover:text-(--app-sidebar-item-active-color)"
: "text-muted-foreground hover:bg-(--app-sidebar-item-hover-bg) hover:text-(--app-sidebar-item-active-color)"
)}
onClick={onClick}
aria-current={active ? "page" : undefined}
>
<span className="flex min-w-0 items-center gap-2">
<span
className={cn(
"size-1.5 shrink-0 rounded-full",
active ? "bg-primary" : "bg-muted-foreground/40"
)}
/>
<span className="truncate text-xs">{label}</span>
</span>
<Badge
variant="secondary"
className={cn(
"h-4 rounded-full px-1.5 text-[10px] font-semibold",
active
? "bg-(--app-sidebar-item-active-bg) text-(--app-sidebar-item-active-color)"
: "bg-muted text-muted-foreground"
)}
>
{count}
</Badge>
</Button>
)
}
export function AlbumSidebar({
images,
providers,
@@ -123,11 +183,20 @@ export function AlbumSidebar({
onCloudRefresh,
}: AlbumSidebarProps) {
const { t } = useTranslation()
const [expandedProviders, setExpandedProviders] = useState<string[]>([])
const isCloud = albumSource === AlbumSource.CLOUD
const showCloudNav = isCloud && isCloudAvailable
const picBeds = useAppStore.use.picBeds()
const cloudStatsQuery = useCloudAlbumStatsQuery({ enabled: showCloudNav })
const toggleProviderExpanded = (type: string) => {
setExpandedProviders((prev) =>
prev.includes(type)
? prev.filter((value) => value !== type)
: [...prev, type]
)
}
const cloudStats = cloudStatsQuery.data
const cloudStatsError = showCloudNav && cloudStatsQuery.isError
const cloudProviderFilters: AlbumProviderFilter[] = (cloudStats?.types ?? []).map((stat) => {
@@ -136,6 +205,9 @@ export function AlbumSidebar({
type: stat.type,
name: bed?.name ?? stat.type,
count: stat.count,
// Cloud album items carry no uploader config id, so no config breakdown.
configs: [],
unknownCount: 0,
}
})
@@ -193,20 +265,86 @@ export function AlbumSidebar({
onFilterChange({ type: NavType.All, value: allPhotosKey })
}
/>
{displayProviders.map((provider) => (
<AlbumNavButton
key={provider.type}
label={provider.name}
count={provider.count}
active={
navContext.type === NavType.Provider &&
{displayProviders.map((provider) => {
const configNodes: AlbumConfigNode[] = [
...provider.configs.map((config) => ({
key: config.id,
label: config.name,
count: config.count,
})),
...(provider.unknownCount > 0
? [{
key: unknownConfigKey,
label: t("ALBUM_CONFIG_UNKNOWN"),
count: provider.unknownCount,
}]
: []),
]
const hasConfigNodes = configNodes.length > 0
const isExpanded = expandedProviders.includes(provider.type)
const isProviderActive =
navContext.type === NavType.Provider &&
navContext.value === provider.type
}
onClick={() =>
onFilterChange({ type: NavType.Provider, value: provider.type })
}
/>
))}
return (
<div key={provider.type}>
<div className="flex items-center gap-1">
<div className="min-w-0 flex-1">
<AlbumNavButton
label={provider.name}
count={provider.count}
active={isProviderActive}
onClick={() =>
onFilterChange({ type: NavType.Provider, value: provider.type })
}
/>
</div>
{hasConfigNodes ? (
<Button
type="button"
variant="ghost"
size="icon-xs"
className="text-muted-foreground shrink-0 hover:bg-(--app-sidebar-item-hover-bg) hover:text-(--app-sidebar-item-active-color)"
onClick={() => toggleProviderExpanded(provider.type)}
title={isExpanded ? t("PROVIDER_SIDEBAR_COLLAPSE") : t("PROVIDER_SIDEBAR_EXPAND")}
aria-label={isExpanded ? t("PROVIDER_SIDEBAR_COLLAPSE") : t("PROVIDER_SIDEBAR_EXPAND")}
aria-expanded={isExpanded}
>
{isExpanded ? (
<ChevronDownIcon className="size-4" />
) : (
<ChevronRightIcon className="size-4" />
)}
</Button>
) : null}
</div>
{hasConfigNodes && isExpanded ? (
<div className="border-sidebar-border/60 mt-1 ml-4 space-y-1 border-l pb-1 pl-2">
{configNodes.map((node) => (
<AlbumConfigNavButton
key={node.key}
label={node.label}
count={node.count}
active={
navContext.type === NavType.Config &&
navContext.providerType === provider.type &&
navContext.value === node.key
}
onClick={() =>
onFilterChange({
type: NavType.Config,
value: node.key,
providerType: provider.type,
})
}
/>
))}
</div>
) : null}
</div>
)
})}
</div>
) : (
<CloudFeatureHighlights />
@@ -1,6 +1,7 @@
import {
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react"
@@ -33,9 +34,12 @@ import {
useAlbumSelectionBox,
} from "./hooks/use-album-selection-box"
import {
buildAlbumConfigMap,
buildAlbumPhotos,
buildAlbumProviderFilters,
filterAlbumImages,
AlbumViewMode,
unknownConfigKey,
type AlbumProviderFilter,
type AlbumPhoto,
type NavContext,
@@ -82,6 +86,11 @@ export function PicGoAlbum() {
const masonryColumnCount =
useAlbumStore.use.masonryColumnCount() || GALLERY_MASONRY_COLUMN_COUNT_DEFAULT
const picBeds = useAppStore.use.picBeds()
const appConfig = useAppStore.use.appConfig()
const configMap = useMemo(
() => buildAlbumConfigMap(appConfig?.uploader),
[appConfig?.uploader]
)
const {
userInfo: cloudUserInfo,
isPaid: isCloudPaidUser,
@@ -115,19 +124,17 @@ export function PicGoAlbum() {
const displayImages = isCloud ? cloudItems : filteredImages
const masonryLayoutScopeKey = isCloud
? `cloud:${cloudTypeFilter || allPhotosKey}:${cloudSearch}`
: `local:${navContext.type}:${navContext.value}:${searchValue}`
: `local:${navContext.type}:${navContext.providerType ?? ""}:${navContext.value}:${searchValue}`
const imageMap = new Map(displayImages.map((image) => [image.id, image]))
const selectedSet = new Set(selectedIds)
const selectedImages = selectedIds
.map((id) => imageMap.get(id))
.filter((image): image is AlbumPhoto => Boolean(image))
const visibleProviders: AlbumProviderFilter[] = picBeds
.filter((item) => item.visible !== false)
.map((item) => ({
type: item.type,
name: item.name,
count: images.filter((image) => image.type === item.type).length,
}))
const visibleProviders: AlbumProviderFilter[] = buildAlbumProviderFilters(
images,
picBeds,
configMap
)
// TODO(v3-post-launch): Restore selected tag derivation when Tags inspector actions return.
// const selectedTags = getSelectedTags(selectedImages)
@@ -159,7 +166,7 @@ export function PicGoAlbum() {
}
const result = await cloudAlbumAdapter.list(query)
if (result.success && cloudFirstPageRequestIdRef.current === requestId) {
const photos = buildAlbumPhotos(result.data.items, picBeds)
const photos = buildAlbumPhotos(result.data.items, picBeds, configMap)
albumStoreActions.setCloudItems(photos)
albumStoreActions.setCloudTotal(result.data.total)
albumStoreActions.setCloudOffset(result.data.items.length)
@@ -232,7 +239,7 @@ export function PicGoAlbum() {
}
const result = await cloudAlbumAdapter.list(query)
if (result.success) {
const photos = buildAlbumPhotos(result.data.items, picBeds)
const photos = buildAlbumPhotos(result.data.items, picBeds, configMap)
albumStoreActions.appendCloudItems(photos)
albumStoreActions.setCloudOffset(state.cloudOffset + result.data.items.length)
albumStoreActions.setCloudHasMore(state.cloudOffset + result.data.items.length < result.data.total)
@@ -252,14 +259,14 @@ export function PicGoAlbum() {
try {
await appActions.ensureHydrated()
const albumItems = await albumAdapter.getAlbumItems()
setImages(buildAlbumPhotos(albumItems, picBeds))
setImages(buildAlbumPhotos(albumItems, picBeds, configMap))
} catch (error) {
console.error(error)
}
}
refreshAlbumPage()
}, [picBeds, refreshNonce, isCloud])
}, [picBeds, configMap, refreshNonce, isCloud])
// Cloud gallery data fetch — 免费用户切到 Cloud tab 时只看升级提示,不发列表请求
useEffect(() => {
@@ -724,7 +731,19 @@ export function PicGoAlbum() {
? (isCloud
? picBeds.find((bed) => bed.type === navContext.value)?.name ?? navContext.value
: visibleProviders.find((provider) => provider.type === navContext.value)?.name ?? navContext.value)
: navContext.value
: navContext.type === NavType.Config
? (() => {
const provider = visibleProviders.find(
(item) => item.type === navContext.providerType
)
const providerName = provider?.name ?? navContext.providerType ?? ""
const configName = navContext.value === unknownConfigKey
? t("ALBUM_CONFIG_UNKNOWN")
: provider?.configs.find((config) => config.id === navContext.value)?.name ??
navContext.value
return providerName ? `${providerName} / ${configName}` : configName
})()
: navContext.value
return (
<main className="flex min-h-0 min-w-0 flex-1 gap-4">
+247 -4
View File
@@ -18,12 +18,31 @@ export type AlbumPhoto = {
date: string
type: string
typeName?: string
/**
* `_id` of the uploader config this photo belongs to. Empty string when the
* config could not be determined (legacy item with no matching url prefix).
*/
configId: string
configName: string
raw: ImgInfo
collection: string
tags: string[]
isVideo: boolean
}
/** Sentinel nav value for photos whose uploader config could not be resolved. */
export const unknownConfigKey = '__unknown__'
export type AlbumConfigOption = {
id: string
name: string
/** Public url prefix, used to infer the config of legacy items. */
urlPrefix: string
}
/** Uploader config options grouped by picbed type. */
export type AlbumConfigMap = Record<string, AlbumConfigOption[]>
function resolveExtname (item: ImgInfo): string {
if (item.extname) {
return item.extname.toLowerCase()
@@ -52,6 +71,15 @@ export type AlbumProviderFilter = {
type: string
name: string
count: number
configs: AlbumConfigFilter[]
/** Photos of this provider whose config could not be resolved. */
unknownCount: number
}
export type AlbumConfigFilter = {
id: string
name: string
count: number
}
export const AlbumViewMode = {
@@ -65,6 +93,7 @@ export type AlbumViewMode =
export const NavType = {
All: 'all',
Provider: 'provider',
Config: 'config',
Collection: 'collection',
Tag: 'tag'
} as const
@@ -74,6 +103,8 @@ export type NavType = ValueOf<typeof NavType>
export type NavContext = {
type: NavType
value: string
/** Owning picbed type. Only set when `type` is `Config`. */
providerType?: string
}
export function filterAlbumImages (
@@ -88,9 +119,14 @@ export function filterAlbumImages (
? true
: navContext.type === NavType.Provider
? image.type === navContext.value
: navContext.type === NavType.Collection
? image.collection === navContext.value
: image.tags.includes(navContext.value)
: navContext.type === NavType.Config
? image.type === navContext.providerType &&
(navContext.value === unknownConfigKey
? !image.configId
: image.configId === navContext.value)
: navContext.type === NavType.Collection
? image.collection === navContext.value
: image.tags.includes(navContext.value)
const matchesSearch = query
? image.name.toLowerCase().includes(query) ||
@@ -139,9 +175,146 @@ function formatAlbumSize (size?: number) {
return size / 1024 / 1024
}
/**
* Field names picbeds use for their public url prefix. Each builtin uploader
* names it differently (`urlPrefix` for S3, `customUrl` for COS/OSS/GitHub,
* `url` for Qiniu/Upyun), and plugins are free to pick their own — an unknown
* name just means legacy items of that picbed stay unattributed.
*/
const URL_PREFIX_FIELDS = ['urlPrefix', 'customUrl', 'customDomain', 'url', 'host', 'domain']
function resolveConfigUrlPrefix (config: Record<string, unknown>): string {
for (const field of URL_PREFIX_FIELDS) {
const value = config[field]
if (typeof value === 'string' && value.trim()) {
return value
}
}
return ''
}
/** Derive per-picbed uploader config options from the app config. */
export function buildAlbumConfigMap (
uploaderConfig: Record<string, { configList?: Array<Record<string, unknown>> }> | undefined
): AlbumConfigMap {
if (!uploaderConfig) {
return {}
}
const result: AlbumConfigMap = {}
Object.entries(uploaderConfig).forEach(([type, item]) => {
const configList = item?.configList ?? []
const options = configList
.filter((config) => typeof config._id === 'string' && config._id)
.map((config) => ({
id: config._id as string,
name: typeof config._configName === 'string' && config._configName
? config._configName
: (config._id as string),
urlPrefix: resolveConfigUrlPrefix(config)
}))
if (options.length > 0) {
result[type] = options
}
})
return result
}
type UrlParts = {
/** `scheme://host[:port]`, lowercased — scheme and host are case-insensitive. */
origin: string
/** Path without a trailing slash, case preserved — object keys can be case-sensitive. */
path: string
}
/**
* Split a value into an origin and a path so they can be compared separately.
* Returns null for values without an absolute scheme+host, which cannot be
* matched against a photo url — a bare `host`/`domain` config field therefore
* claims nothing, exactly as it did before.
*/
function parseUrlParts (value: string): UrlParts | null {
const trimmed = value.trim()
if (!trimmed) {
return null
}
let parsed: URL
try {
parsed = new URL(trimmed)
} catch {
return null
}
if (!parsed.hostname) {
return null
}
return {
origin: `${parsed.protocol}//${parsed.host}`.toLowerCase(),
path: parsed.pathname.replace(/\/+$/, '')
}
}
/**
* Whether `target` falls under `prefix`. The origin must match exactly, so a
* host-only prefix cannot claim a lookalike host (`cdn.example.org` must not
* claim `cdn.example.org.other.net`), and a path prefix must end on a segment
* boundary, so `/foo` cannot claim `/foobar/photo.png`.
*/
function matchesUrlPrefix (target: UrlParts, prefix: UrlParts): boolean {
if (target.origin !== prefix.origin) {
return false
}
if (!prefix.path) {
return true
}
if (!target.path.startsWith(prefix.path)) {
return false
}
const next = target.path.charAt(prefix.path.length)
return next === '' || next === '/'
}
/**
* Infer which uploader config produced a legacy album item by matching its url
* against each config's public url prefix. Longest prefix wins, so configs that
* share a host but differ by path still resolve correctly. Inference is never
* persisted: it is recomputed on every load, so renaming a config or changing
* its domain immediately re-buckets old photos instead of leaving stale data.
*/
export function inferConfigIdFromUrl (
item: ImgInfo,
configs: AlbumConfigOption[]
): string {
// Prefer the original url: a rewritten `imgUrl` may point at a CDN that no
// longer resembles any config's prefix.
const target = parseUrlParts(item.originImgUrl || item.imgUrl || '')
if (!target) {
return ''
}
let matchedId = ''
let matchedLength = 0
configs.forEach((config) => {
const prefix = parseUrlParts(config.urlPrefix)
if (!prefix || !matchesUrlPrefix(target, prefix)) {
return
}
const length = prefix.origin.length + prefix.path.length
if (length > matchedLength) {
matchedId = config.id
matchedLength = length
}
})
return matchedId
}
export function buildAlbumPhotos (
items: ImgInfo[],
picBeds: IPicBedType[]
picBeds: IPicBedType[],
configMap: AlbumConfigMap = {}
) {
const picBedMap = new Map(picBeds.map((bed) => [bed.type, bed.name]))
@@ -150,6 +323,14 @@ export function buildAlbumPhotos (
const itemType = typeof item.type === 'string' ? item.type : ''
const timestamp = resolveTimestampValue(item.createdAt) || resolveTimestampValue(item.updatedAt) || undefined
const size = typeof item.size === 'number' ? item.size : undefined
const configs = configMap[itemType] ?? []
const configId = typeof item._configId === 'string' && item._configId
? item._configId
: inferConfigIdFromUrl(item, configs)
// Always resolve the display name from live config, so a renamed config is
// reflected in the album; fall back to the name snapshotted at upload time.
const configName = configs.find((config) => config.id === configId)?.name ??
(typeof item._configName === 'string' ? item._configName : '')
return {
id: index,
@@ -164,6 +345,8 @@ export function buildAlbumPhotos (
date: formatAlbumDate(timestamp),
type: itemType,
typeName: picBedMap.get(itemType) || itemType,
configId,
configName,
raw: item,
collection: '',
tags: [],
@@ -171,3 +354,63 @@ export function buildAlbumPhotos (
} satisfies AlbumPhoto
})
}
/**
* Build the sidebar's provider tree: one node per visible picbed, each with a
* child per uploader config plus an "unknown" bucket for photos that could not
* be attributed.
*
* A config can be deleted after its photos were uploaded. Those photos keep a
* non-empty `configId` that no longer appears in the config list, so they would
* otherwise be counted in the provider total but reachable through no child at
* all. They get a child of their own, labelled with the name snapshotted at
* upload time, which keeps `configs` + `unknownCount` a partition of `count`.
*/
export function buildAlbumProviderFilters (
images: AlbumPhoto[],
picBeds: IPicBedType[],
configMap: AlbumConfigMap = {}
): AlbumProviderFilter[] {
return picBeds
.filter((item) => item.visible !== false)
.map((item) => {
const typeImages = images.filter((image) => image.type === item.type)
const configs = configMap[item.type] ?? []
const knownIds = new Set(configs.map((config) => config.id))
const orphanedIds = new Map<string, { name: string, count: number }>()
typeImages.forEach((image) => {
if (!image.configId || knownIds.has(image.configId)) {
return
}
const entry = orphanedIds.get(image.configId)
if (entry) {
entry.count += 1
} else {
orphanedIds.set(image.configId, {
name: image.configName || image.configId,
count: 1
})
}
})
return {
type: item.type,
name: item.name,
count: typeImages.length,
configs: [
...configs.map((config) => ({
id: config.id,
name: config.name,
count: typeImages.filter((image) => image.configId === config.id).length
})),
...Array.from(orphanedIds, ([id, entry]) => ({
id,
name: entry.name,
count: entry.count
}))
],
unknownCount: typeImages.filter((image) => !image.configId).length
}
})
}
@@ -239,6 +239,10 @@ export function HistoryPanel ({
sizeMb: 0,
date: item.time,
type: item.raw.type || '',
// Preview-only: the history panel has no config sidebar, so carry
// whatever the record stored without inferring anything.
configId: typeof item.raw._configId === 'string' ? item.raw._configId : '',
configName: typeof item.raw._configName === 'string' ? item.raw._configName : '',
raw: item.raw,
collection: '',
tags: [],
@@ -1,4 +1,6 @@
import { useState } from "react"
import { useTranslation } from "react-i18next"
import { EyeIcon, EyeOffIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
@@ -21,9 +23,11 @@ interface SettingsServerDialogProps {
serverHostDraft: string
serverPortDraft: string
serverEnableDraft: boolean
serverSecretDraft: string
onServerHostDraftChange: (value: string) => void
onServerPortDraftChange: (value: string) => void
onServerEnableDraftChange: (value: boolean) => void
onServerSecretDraftChange: (value: string) => void
}
export function SettingsServerDialog({
@@ -32,12 +36,21 @@ export function SettingsServerDialog({
serverHostDraft,
serverPortDraft,
serverEnableDraft,
serverSecretDraft,
onServerHostDraftChange,
onServerPortDraftChange,
onServerEnableDraftChange,
onServerSecretDraftChange,
}: SettingsServerDialogProps) {
const { t } = useTranslation()
const saveSettingsConfig = useSettingsSave()
const [isSecretVisible, setIsSecretVisible] = useState(false)
// Mask the secret again whenever the dialog closes, so it always reopens hidden.
const handleOpenChange = (nextOpen: boolean) => {
if (!nextOpen) setIsSecretVisible(false)
onOpenChange(nextOpen)
}
const handleConfirm = async () => {
const port = Number(serverPortDraft)
@@ -45,16 +58,18 @@ export function SettingsServerDialog({
host: serverHostDraft,
port: Number.isFinite(port) ? port : 36677,
enable: serverEnableDraft,
// Core trims the secret and treats an empty value as "authentication disabled".
secret: serverSecretDraft.trim(),
}
const isSaved = await saveSettingsConfig("settings.server", nextServer)
if (isSaved) {
onOpenChange(false)
handleOpenChange(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{t("SETTINGS_SET_PICGO_SERVER")}</DialogTitle>
@@ -90,11 +105,40 @@ export function SettingsServerDialog({
placeholder={t("SETTINGS_TIP_PLACEHOLDER_PORT")}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="settings-server-secret">{t("SETTINGS_SET_SERVER_SECRET")}</Label>
<div className="relative">
<Input
id="settings-server-secret"
type={isSecretVisible ? "text" : "password"}
autoComplete="off"
className="pr-10"
value={serverSecretDraft}
onChange={(event) => onServerSecretDraftChange(event.target.value)}
placeholder={t("SETTINGS_TIP_PLACEHOLDER_SECRET")}
/>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="absolute top-1/2 right-1 -translate-y-1/2"
aria-label={t(isSecretVisible ? "SETTINGS_HIDE_SECRET" : "SETTINGS_SHOW_SECRET")}
onClick={() => setIsSecretVisible((prev) => !prev)}
>
{isSecretVisible ? (
<EyeOffIcon className="size-4" />
) : (
<EyeIcon className="size-4" />
)}
</Button>
</div>
<p className="text-xs text-muted-foreground">{t("SETTINGS_TIPS_SERVER_SECRET")}</p>
</div>
</>
) : null}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
<Button variant="outline" onClick={() => handleOpenChange(false)}>
{t("CANCEL")}
</Button>
<Button onClick={handleConfirm}>{t("CONFIRM")}</Button>
@@ -29,6 +29,7 @@ export function SettingsSectionNetwork({
const [serverHostDraft, setServerHostDraft] = useState(settingsConfig.server.host)
const [serverPortDraft, setServerPortDraft] = useState(String(settingsConfig.server.port))
const [serverEnableDraft, setServerEnableDraft] = useState(settingsConfig.server.enable)
const [serverSecretDraft, setServerSecretDraft] = useState(settingsConfig.server.secret ?? "")
return (
<>
@@ -65,6 +66,7 @@ export function SettingsSectionNetwork({
setServerHostDraft(settingsConfig.server.host)
setServerPortDraft(String(settingsConfig.server.port))
setServerEnableDraft(settingsConfig.server.enable)
setServerSecretDraft(settingsConfig.server.secret ?? "")
setServerDialogOpen(true)
}}
>
@@ -91,9 +93,11 @@ export function SettingsSectionNetwork({
serverHostDraft={serverHostDraft}
serverPortDraft={serverPortDraft}
serverEnableDraft={serverEnableDraft}
serverSecretDraft={serverSecretDraft}
onServerHostDraftChange={setServerHostDraft}
onServerPortDraftChange={setServerPortDraft}
onServerEnableDraftChange={setServerEnableDraft}
onServerSecretDraftChange={setServerSecretDraft}
/>
</>
)
@@ -38,6 +38,8 @@ export interface SettingsServerConfig {
port: number
host: string
enable: boolean
// Shared secret for `POST /upload`; empty or missing disables server authentication.
secret?: string
}
export interface SettingsShortcutItem {
@@ -338,6 +340,7 @@ export const defaultSettingsConfig: SettingsConfigState = {
port: 36677,
host: "127.0.0.1",
enable: true,
secret: "",
},
startupMode: IStartupMode.HIDE,
shortKey: defaultSettingsShortKeyMap,
@@ -417,7 +420,7 @@ const settingsSearchKeywordsByItemId: Record<string, string[]> = {
"upload-proxy": ["代理", "镜像", "鏡像", "上传代理", "上傳代理", "代理和镜像", "代理與鏡像"],
"plugin-proxy": ["插件代理", "外掛代理", "插件安装代理", "外掛安裝代理"],
"plugin-mirror": ["插件镜像", "外掛鏡像", "插件安装镜像", "外掛安裝鏡像", "镜像源", "鏡像源"],
server: ["服务", "服務", "服务端", "服務端", "端口", "埠", "主机", "主機"],
server: ["服务", "服務", "服务端", "服務端", "端口", "埠", "主机", "主機", "密钥", "金鑰", "鉴权", "驗證", "secret", "auth"],
"log-level": ["日志级别", "日誌級別", "日志等级", "日誌等級"],
"log-size": ["日志大小", "日誌大小", "日志文件大小", "日誌檔案大小"],
"open-config-file": ["配置文件", "設定檔", "配置檔", "打开配置", "開啟設定檔"],
+3 -1
View File
@@ -285,7 +285,9 @@ export function normalizeSettingsConfig (
server: {
host: normalizeString(rawServer.host, defaultSettingsConfig.server.host),
port: normalizeNumber(rawServer.port, defaultSettingsConfig.server.port),
enable: normalizeBoolean(rawServer.enable, defaultSettingsConfig.server.enable)
enable: normalizeBoolean(rawServer.enable, defaultSettingsConfig.server.enable),
// Keep the saved secret: the server dialog re-saves the whole `settings.server` object, so dropping it here would silently clear it (and disable authentication) on the next save.
secret: normalizeString(rawServer.secret, '')
},
startupMode: isSettingsStartupMode(rawSettings.startupMode)
? rawSettings.startupMode
+6
View File
@@ -229,6 +229,11 @@ interface ILocales {
SETTINGS_SET_SERVER_PORT: string
SETTINGS_TIP_PLACEHOLDER_HOST: string
SETTINGS_TIP_PLACEHOLDER_PORT: string
SETTINGS_SET_SERVER_SECRET: string
SETTINGS_TIP_PLACEHOLDER_SECRET: string
SETTINGS_TIPS_SERVER_SECRET: string
SETTINGS_SHOW_SECRET: string
SETTINGS_HIDE_SECRET: string
SETTINGS_LOG_LEVEL_ALL: string
SETTINGS_LOG_LEVEL_SUCCESS: string
SETTINGS_LOG_LEVEL_ERROR: string
@@ -409,6 +414,7 @@ interface ILocales {
PRIVACY_TIPS: string
QUIT: string
ALBUM_ALL_PHOTOS: string
ALBUM_CONFIG_UNKNOWN: string
ALBUM_COLLECTIONS: string
ALBUM_TAGS: string
ALBUM_MENU: string
+12
View File
@@ -42,6 +42,7 @@ interface IServerConfig {
port: number | string
host: string
enable: boolean
secret?: string
}
// Image && PicBed
@@ -86,6 +87,17 @@ interface ImgInfo {
* updated time, Date.now(). if item is updated, such as url rewrite, this will be updated. otherwise, it will be the same as createdAt.
*/
updatedAt?: number | string | Date
/**
* `_id` of the uploader config that produced this item. Absent on items
* uploaded before this field was introduced; the album falls back to
* inferring the config from the URL in that case.
*/
_configId?: string
/**
* `_configName` of the uploader config at upload time. Display-only snapshot:
* renaming a config does not rewrite historical items.
*/
_configName?: string
[propName: string]: any
}
+1
View File
@@ -23,6 +23,7 @@ interface ISettingForm {
port: number
host: string
enable: boolean
secret?: string
}
startupMode: import('#/types/enum').IStartupMode
}