🐛 Fix(gui): protect persisted provider credentials

This commit is contained in:
PiEgg
2026-07-21 14:21:10 +08:00
committed by GitHub
parent e410afa9bd
commit 77f1ed16b7
11 changed files with 671 additions and 59 deletions
@@ -0,0 +1,93 @@
import { describe, expect, it } from 'vitest'
import { createSchemaOnlyUploaderContext } from '~/main/utils/schemaOnlyUploaderContext'
type ConfigRecord = Record<string, unknown>
function isConfigRecord(value: unknown): value is ConfigRecord {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function getByPath(value: unknown, path: string): unknown {
return path.split('.').reduce<unknown>((current, key) => {
if (!isConfigRecord(current)) {
return undefined
}
return current[key]
}, value)
}
describe('createSchemaOnlyUploaderContext', () => {
it('hides only the target uploader configuration without mutating the source', () => {
const config = {
picBed: {
current: 'tcyun',
tcyun: {
secretId: 'existing-secret-id',
secretKey: 'existing-secret-key'
},
github: {
token: 'github-token'
}
},
uploader: {
tcyun: {
defaultId: 'config-1'
},
github: {
defaultId: 'config-2'
}
},
settings: {
proxy: 'http://localhost:7890'
}
}
const context = {
marker: 'original-context',
getConfig<T>(name?: string): T {
return (name ? getByPath(config, name) : config) as T
}
}
const schemaContext = createSchemaOnlyUploaderContext(context, 'tcyun')
expect(schemaContext.getConfig('picBed.tcyun')).toBeUndefined()
expect(schemaContext.getConfig('picBed.tcyun.secretKey')).toBeUndefined()
expect(schemaContext.getConfig('uploader.tcyun')).toBeUndefined()
expect(schemaContext.getConfig('settings.proxy')).toBe('http://localhost:7890')
expect(schemaContext.marker).toBe('original-context')
expect(schemaContext.getConfig<ConfigRecord>('picBed')).toEqual({
current: 'tcyun',
github: {
token: 'github-token'
}
})
expect(schemaContext.getConfig<ConfigRecord>('uploader')).toEqual({
github: {
defaultId: 'config-2'
}
})
expect(schemaContext.getConfig<ConfigRecord>()).toEqual({
picBed: {
current: 'tcyun',
github: {
token: 'github-token'
}
},
uploader: {
github: {
defaultId: 'config-2'
}
},
settings: {
proxy: 'http://localhost:7890'
}
})
expect(context.getConfig('picBed.tcyun')).toEqual({
secretId: 'existing-secret-id',
secretKey: 'existing-secret-key'
})
})
})
@@ -0,0 +1,178 @@
// @vitest-environment jsdom
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
ensureExpanded: vi.fn(),
ensureHydrated: vi.fn(async () => {}),
navigate: vi.fn(),
refreshConfigSchema: vi.fn(),
setHydrating: vi.fn()
}))
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key })
}))
vi.mock('sonner', () => ({
toast: {
error: vi.fn(),
success: vi.fn(),
warning: vi.fn()
}
}))
vi.mock('@tanstack/react-router', () => ({
useNavigate: () => mocks.navigate,
useSearch: () => ({
uploader: 'tcyun',
configId: 'config-1'
})
}))
vi.mock('@/adapters/plugins', () => ({
pluginsAdapter: {
refreshConfigSchema: mocks.refreshConfigSchema
}
}))
vi.mock('@/store', () => ({
appActions: {
ensureHydrated: mocks.ensureHydrated
},
providerStoreActions: {
ensureExpanded: mocks.ensureExpanded,
setHydrating: mocks.setHydrating
},
useAppStore: {
use: {
appConfig: () => ({
picBed: {
uploader: 'tcyun'
},
uploader: {
tcyun: {
defaultId: 'config-1',
configList: [
{
_id: 'config-1',
_configName: 'Existing Config',
_createdAt: 1700000000000,
_updatedAt: 1700000000000,
secretKey: 'existing-secret-key'
}
]
}
}
}),
providers: () => [
{
id: 'tcyun',
name: 'Tencent Cloud',
visible: true,
isDefaultUploader: true
}
],
hasHydrated: () => true
}
},
useProviderStore: {
use: {
isHydrating: () => false
}
}
}))
vi.mock('@/components/main/providers/provider-sidebar', () => ({
ProviderSidebar: ({
onCreateIntent
}: {
onCreateIntent: (uploaderId: string) => void
}) => (
<button type='button' onClick={() => onCreateIntent('tcyun')}>
Open create dialog
</button>
)
}))
vi.mock('@/components/main/providers/provider-config-panel', () => ({
ProviderConfigPanel: ({
draftConfigMap
}: {
draftConfigMap: Record<string, unknown>
}) => (
<output data-testid='draft-config'>{JSON.stringify(draftConfigMap)}</output>
)
}))
vi.mock('@/components/main/providers/provider-config-name-dialog', () => ({
ProviderConfigNameDialog: ({
state,
onSubmit
}: {
state: { name: string } | null
onSubmit: () => Promise<void>
}) => state
? (
<button type='button' onClick={async () => await onSubmit()}>
Submit create dialog
</button>
)
: null
}))
vi.mock('@/components/main/providers/provider-delete-config-dialog', () => ({
ProviderDeleteConfigDialog: () => null
}))
import { PicGoProviders } from '@/components/main/providers/picgo-providers'
describe('PicGoProviders create config', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.refreshConfigSchema.mockResolvedValue([
{
name: 'version',
type: 'list',
choices: ['v4', 'v5'],
default: 'v5',
required: false
},
{
name: 'secretKey',
type: 'password',
default: '',
required: true
}
])
})
it('requests schema-only defaults and creates an empty credential draft', async () => {
render(<PicGoProviders />)
fireEvent.click(screen.getByRole('button', { name: 'Open create dialog' }))
fireEvent.click(screen.getByRole('button', { name: 'Submit create dialog' }))
await waitFor(() => {
expect(mocks.refreshConfigSchema).toHaveBeenCalledWith({
target: 'uploader',
uploaderName: 'tcyun',
draftValues: {},
schemaOnly: true
})
})
await waitFor(() => {
const draftMap = JSON.parse(
screen.getByTestId('draft-config').textContent ?? '{}'
) as Record<string, Record<string, unknown>>
expect(draftMap.tcyun).toMatchObject({
_configName: 'New Config',
_isDraft: true,
version: 'v5',
secretKey: ''
})
})
})
})
@@ -4,6 +4,13 @@ import { act, render, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { evaluatePluginConfig } from 'picgo'
const routerMocks = vi.hoisted(() => ({
search: {
uploader: 'picgo-plugin-test',
configId: 'config-1'
}
}))
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key })
}))
@@ -15,10 +22,7 @@ vi.mock('@tanstack/react-router', async () => {
return {
...actual,
useNavigate: () => vi.fn(),
useSearch: () => ({
uploader: 'picgo-plugin-test',
configId: 'config-1'
})
useSearch: () => routerMocks.search
}
})
@@ -39,6 +43,7 @@ vi.mock('@/store/providers/actions', () => ({
}))
import { ProviderConfigPanel } from '@/components/main/providers/provider-config-panel'
import type { ProviderDraftConfigItem } from '@/components/main/providers/types'
import { useAppStore } from '@/store/app-store'
import { useProviderStoreBase as useProviderStore } from '@/store/providers/store'
import { pluginsAdapter } from '@/adapters/plugins'
@@ -46,6 +51,11 @@ import { normalizePluginConfigSchema } from '@/components/common/normalize-plugi
import { IPasteStyle, IStartupMode } from '~/universal/types/enum'
import { buildCascadeRawSchema } from '../fixtures/cascade-fixture'
beforeEach(() => {
routerMocks.search.uploader = 'picgo-plugin-test'
routerMocks.search.configId = 'config-1'
})
const baseAppConfig = {
picBed: {
uploader: 'picgo-plugin-test',
@@ -88,17 +98,21 @@ const baseAppConfig = {
needReload: false
}
function setupStoreWithSavedConfig(savedConfig: Record<string, unknown>) {
function setupStoreWithSavedConfig(
savedConfig: Record<string, unknown>,
schemaOverride?: unknown[]
) {
// Mimics what the main process sends at startup: schema evaluated with
// empty answers, so the plugin's `default(answers)` for downstream fields
// computes against synthAnswers defaults — NOT the user's saved values.
// Provider-config-panel then has to issue an initial sync to bring the
// schema in line with the saved values.
const initialSchema = evaluatePluginConfig(
buildCascadeRawSchema(savedConfig.region as string) as Parameters<
typeof evaluatePluginConfig
>[0]
) as unknown[]
const initialSchema = schemaOverride ??
evaluatePluginConfig(
buildCascadeRawSchema(savedConfig.region as string) as Parameters<
typeof evaluatePluginConfig
>[0]
) as unknown[]
useAppStore.setState({
defaultPicBed: 'picgo-plugin-test',
@@ -182,10 +196,12 @@ function getFieldSelectTrigger(label: string) {
return trigger as HTMLElement
}
function renderPanel() {
function renderPanel(
draftConfigMap: Record<string, ProviderDraftConfigItem | undefined> = {}
) {
return render(
<ProviderConfigPanel
draftConfigMap={{}}
draftConfigMap={draftConfigMap}
setDraftConfigMap={vi.fn()}
onCreateConfigIntent={vi.fn()}
onDeleteConfigIntent={vi.fn()}
@@ -363,3 +379,119 @@ describe('ProviderConfigPanel — editor field rendering', () => {
})
})
})
describe('ProviderConfigPanel — saved password rendering', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('keeps saved password fields masked without a reveal button', async () => {
const passwordSchema = [
{
name: 'secret',
type: 'password',
alias: 'Secret',
required: true,
message: 'Enter secret'
}
]
setupStoreWithSavedConfig(
{ secret: 'saved-secret' },
passwordSchema
)
vi.mocked(pluginsAdapter.refreshConfigSchema).mockResolvedValue(
normalizePluginConfigSchema(passwordSchema)
)
renderPanel()
await waitFor(() => {
const label = screen.getByText('Secret')
const field = label.closest('[data-slot="field"]')
if (!field) throw new Error('No field container for password field')
const input = field.querySelector('input')
expect(input?.value).toBe('saved-secret')
expect(input?.type).toBe('password')
expect(field.querySelector('button')).toBeNull()
})
})
})
describe('ProviderConfigPanel — draft schema rendering', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('keeps schema refreshes isolated from persisted uploader defaults', async () => {
const draftId = 'draft:picgo-plugin-test'
const cachedSchema = [
{
name: 'version',
type: 'list',
choices: ['v4', 'v5'],
default: 'v5',
required: false
},
{
name: 'secret',
type: 'password',
alias: 'Secret',
default: 'persisted-secret',
required: true
}
]
const cleanSchema = [
cachedSchema[0],
{
...cachedSchema[1],
default: ''
}
]
setupStoreWithSavedConfig(
{
version: 'v5',
secret: 'persisted-secret'
},
cachedSchema
)
routerMocks.search.configId = draftId
vi.mocked(pluginsAdapter.refreshConfigSchema).mockResolvedValue(
normalizePluginConfigSchema(cleanSchema)
)
renderPanel({
'picgo-plugin-test': {
_id: draftId,
_configName: 'New Config',
_createdAt: 1700000000001,
_updatedAt: 1700000000001,
_isDraft: true,
version: 'v5',
secret: ''
}
})
await waitFor(() => {
expect(pluginsAdapter.refreshConfigSchema).toHaveBeenCalledWith(
expect.objectContaining({
target: 'uploader',
uploaderName: 'picgo-plugin-test',
schemaOnly: true
})
)
})
await waitFor(() => {
const label = screen.getByText('Secret')
const field = label.closest('[data-slot="field"]')
if (!field) throw new Error('No field container for draft password field')
const input = field.querySelector('input')
expect(input?.value).toBe('')
expect(input?.type).toBe('password')
expect(field.querySelector('button')).not.toBeNull()
})
})
})
@@ -116,6 +116,88 @@ describe('SchemaFormFields editor field', () => {
})
describe('SchemaFormFields password field', () => {
const passwordSchema: ProviderPluginConfig[] = [
{
name: 'secret',
type: 'password',
required: true,
alias: 'Secret'
}
]
it('allows password reveal by default', () => {
renderSchema(passwordSchema, { secret: 'saved-secret' })
const input = screen.getByDisplayValue('saved-secret') as HTMLInputElement
expect(input.type).toBe('password')
fireEvent.click(screen.getByRole('button'))
expect(input.type).toBe('text')
})
it('keeps the password masked and removes the reveal button when disabled', () => {
const onValueChange = vi.fn()
render(
<SchemaFormFields
schema={passwordSchema}
values={{ secret: 'saved-secret' }}
allowPasswordReveal={false}
onValueChange={onValueChange}
/>
)
const input = screen.getByDisplayValue('saved-secret') as HTMLInputElement
expect(input.type).toBe('password')
expect(screen.queryByRole('button')).toBeNull()
fireEvent.change(input, { target: { value: 'replacement-secret' } })
expect(onValueChange).toHaveBeenCalledWith('secret', 'replacement-secret')
})
it('clears visible password state when reveal becomes disabled', () => {
const onValueChange = vi.fn()
const { rerender } = render(
<SchemaFormFields
schema={passwordSchema}
values={{ secret: 'saved-secret' }}
allowPasswordReveal
onValueChange={onValueChange}
/>
)
fireEvent.click(screen.getByRole('button'))
expect(
(screen.getByDisplayValue('saved-secret') as HTMLInputElement).type
).toBe('text')
rerender(
<SchemaFormFields
schema={passwordSchema}
values={{ secret: 'saved-secret' }}
allowPasswordReveal={false}
onValueChange={onValueChange}
/>
)
expect(
(screen.getByDisplayValue('saved-secret') as HTMLInputElement).type
).toBe('password')
rerender(
<SchemaFormFields
schema={passwordSchema}
values={{ secret: 'saved-secret' }}
allowPasswordReveal
onValueChange={onValueChange}
/>
)
expect(
(screen.getByDisplayValue('saved-secret') as HTMLInputElement).type
).toBe('password')
})
})
describe('SchemaFormFields unknown field type', () => {
it('does not render any input control for an unrecognized type, label only', () => {
renderSchema([
+11 -2
View File
@@ -12,6 +12,7 @@ import { notifyAppConfigUpdated } from '~/main/utils/appConfigNotifier'
import { dialog } from 'electron'
import windowManager from '~/main/apis/app/window/windowManager'
import { IWindowList } from '#/types/enum'
import { createSchemaOnlyUploaderContext } from '~/main/utils/schemaOnlyUploaderContext'
const README_FILE_CANDIDATES = ['README.md', 'readme.md', 'Readme.md'] as const
@@ -210,7 +211,10 @@ pluginsRouter
} else if (payload.target === 'uploader') {
const handler = picgo.helper.uploader.get(payload.uploaderName)
if (handler?.config) {
rawSchema = handler.config(picgo)
const configContext = payload.schemaOnly
? createSchemaOnlyUploaderContext(picgo, payload.uploaderName)
: picgo
rawSchema = handler.config(configContext)
}
}
@@ -235,7 +239,12 @@ pluginsRouter
type IRefreshConfigSchemaArgs =
| { target: 'plugin', pluginFullName: string, draftValues?: Record<string, unknown> }
| { target: 'transformer', pluginFullName: string, draftValues?: Record<string, unknown> }
| { target: 'uploader', uploaderName: string, draftValues?: Record<string, unknown> }
| {
target: 'uploader'
uploaderName: string
draftValues?: Record<string, unknown>
schemaOnly?: boolean
}
export {
pluginsRouter
@@ -0,0 +1,87 @@
interface ConfigReadableContext {
getConfig<T>(name?: string): T
}
type ConfigRecord = Record<string, unknown>
function isConfigRecord(value: unknown): value is ConfigRecord {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function omitUploaderConfig(
value: unknown,
uploaderName: string
): unknown {
if (!isConfigRecord(value)) {
return value
}
const nextValue = { ...value }
delete nextValue[uploaderName]
return nextValue
}
function sanitizeFullConfig(value: unknown, uploaderName: string): unknown {
if (!isConfigRecord(value)) {
return value
}
const nextValue = { ...value }
if ('picBed' in value) {
nextValue.picBed = omitUploaderConfig(value.picBed, uploaderName)
}
if ('uploader' in value) {
nextValue.uploader = omitUploaderConfig(value.uploader, uploaderName)
}
return nextValue
}
export function createSchemaOnlyUploaderContext<T extends ConfigReadableContext>(
context: T,
uploaderName: string
): T {
const hiddenConfigPaths = [
`picBed.${uploaderName}`,
`uploader.${uploaderName}`
]
const getConfig = <V>(name?: string): V => {
if (
name &&
hiddenConfigPaths.some(
(configPath) => name === configPath || name.startsWith(`${configPath}.`)
)
) {
return undefined as V
}
if (name === 'picBed' || name === 'uploader') {
return omitUploaderConfig(
context.getConfig<unknown>(name),
uploaderName
) as V
}
if (name === undefined) {
return sanitizeFullConfig(
context.getConfig<unknown>(),
uploaderName
) as V
}
return context.getConfig<V>(name)
}
return new Proxy(context, {
get(target, property, receiver) {
if (property === 'getConfig') {
return getConfig
}
return Reflect.get(target, property, receiver)
}
})
}
+6 -1
View File
@@ -9,7 +9,12 @@ import { normalizePluginConfigSchema } from '@/components/common/normalize-plugi
export type IRefreshConfigSchemaArgs =
| { target: 'plugin', pluginFullName: string, draftValues: Record<string, unknown> }
| { target: 'transformer', pluginFullName: string, draftValues: Record<string, unknown> }
| { target: 'uploader', uploaderName: string, draftValues: Record<string, unknown> }
| {
target: 'uploader'
uploaderName: string
draftValues: Record<string, unknown>
schemaOnly?: boolean
}
interface PluginInstallResult {
success: boolean
@@ -39,6 +39,7 @@ interface SchemaFormFieldsProps {
schema: ProviderPluginConfig[]
values: SchemaFormValues
fieldErrors?: SchemaFieldErrorMap
allowPasswordReveal?: boolean
onValueChange: (name: string, value: unknown) => void
}
@@ -49,6 +50,14 @@ interface CheckboxFieldProps {
onValueChange: (name: string, value: unknown) => void
}
interface PasswordFieldProps {
field: ProviderPluginConfig
selectedValue: unknown
isInvalid: boolean
allowPasswordReveal: boolean
onValueChange: (name: string, value: unknown) => void
}
function renderSanitizedTips(markdown: string) {
const parsed = marked.parse(markdown)
const html = typeof parsed === "string" ? parsed : markdown
@@ -151,16 +160,51 @@ function CheckboxField({
)
}
function PasswordField({
field,
selectedValue,
isInvalid,
allowPasswordReveal,
onValueChange,
}: PasswordFieldProps) {
const [isPasswordVisible, setIsPasswordVisible] = useState(false)
return (
<div className="relative">
<Input
value={String(selectedValue ?? "")}
type={isPasswordVisible ? "text" : "password"}
placeholder={field.message || field.name}
className={allowPasswordReveal ? "pr-10" : undefined}
aria-invalid={isInvalid}
onChange={(event) => onValueChange(field.name, event.target.value)}
/>
{allowPasswordReveal && (
<Button
type="button"
variant="ghost"
size="icon-xs"
className="absolute top-1/2 right-1 -translate-y-1/2"
onClick={() => setIsPasswordVisible((prev) => !prev)}
>
{isPasswordVisible ? (
<EyeOffIcon className="size-4" />
) : (
<EyeIcon className="size-4" />
)}
</Button>
)}
</div>
)
}
export function SchemaFormFields({
schema,
values,
fieldErrors = {},
allowPasswordReveal = true,
onValueChange,
}: SchemaFormFieldsProps) {
const [visiblePasswords, setVisiblePasswords] = useState<Record<string, boolean>>(
{}
)
return (
<div className="space-y-5">
{schema.map((field) => {
@@ -169,7 +213,6 @@ export function SchemaFormFields({
const optionValueMap = new Map(
choices.map((choice) => [String(choice.value), choice.value] as const)
)
const isPasswordVisible = Boolean(visiblePasswords[field.name])
const fieldError = fieldErrors[field.name]
const isInvalid = Boolean(fieldError)
@@ -230,34 +273,14 @@ export function SchemaFormFields({
)}
{field.type === "password" && (
<div className="relative">
<Input
value={String(value ?? "")}
type={isPasswordVisible ? "text" : "password"}
placeholder={field.message || field.name}
className="pr-10"
aria-invalid={isInvalid}
onChange={(event) => onValueChange(field.name, event.target.value)}
/>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="absolute top-1/2 right-1 -translate-y-1/2"
onClick={() => {
setVisiblePasswords((prev) => ({
...prev,
[field.name]: !prev[field.name],
}))
}}
>
{isPasswordVisible ? (
<EyeOffIcon className="size-4" />
) : (
<EyeIcon className="size-4" />
)}
</Button>
</div>
<PasswordField
key={`${field.name}:${allowPasswordReveal ? "reveal" : "locked"}`}
field={field}
selectedValue={value}
isInvalid={isInvalid}
allowPasswordReveal={allowPasswordReveal}
onValueChange={onValueChange}
/>
)}
{field.type === "list" && (
@@ -3,6 +3,7 @@ import { useNavigate, useSearch } from "@tanstack/react-router"
import { useTranslation } from "react-i18next"
import { toast } from "sonner"
import { pluginsAdapter } from "@/adapters/plugins"
import { appActions, providerStoreActions, useAppStore, useProviderStore } from "@/store"
import { ProviderConfigNameDialog } from "./provider-config-name-dialog"
import { ProviderConfigPanel } from "./provider-config-panel"
@@ -35,7 +36,6 @@ export function PicGoProviders() {
const appConfig = useAppStore.use.appConfig()
const providers = useAppStore.use.providers()
const providerSchemas = useAppStore.use.providerSchemas()
const hasHydrated = useAppStore.use.hasHydrated()
const isLoadingUploaders = useProviderStore.use.isHydrating()
@@ -167,14 +167,12 @@ export function PicGoProviders() {
const handleCreateConfigDraft = async (uploaderId: string, configName: string) => {
try {
const resolvedSchema =
(await providerStoreActions.ensureSchema(uploaderId)).config ??
providerSchemas[uploaderId]?.config
if (!resolvedSchema) {
toast.error(t("FAILED"))
return
}
const resolvedSchema = await pluginsAdapter.refreshConfigSchema({
target: "uploader",
uploaderName: uploaderId,
draftValues: {},
schemaOnly: true,
})
const now = Date.now()
const draftId = createDraftConfigId(uploaderId)
@@ -300,9 +300,11 @@ export function ProviderConfigPanel({
</div>
<ProviderFormFields
key={`${selectedUploaderId}:${selectedConfig._id}`}
schema={schema}
values={formValues}
fieldErrors={fieldErrors}
allowPasswordReveal={isDraftSelected}
onValueChange={handleValueChange}
/>
@@ -335,4 +337,3 @@ export function ProviderConfigPanel({
</AppMainCard>
)
}
@@ -78,9 +78,10 @@ export function useProviderConfigForm({
target: "uploader",
uploaderName: refreshTarget.uploaderName,
draftValues,
schemaOnly: isDraftSelected,
})
},
[]
[isDraftSelected]
)
// Re-hydrate when the persisted config's _updatedAt changes (initial load,
@@ -113,6 +114,9 @@ export function useProviderConfigForm({
.then((nextSchema) => {
if (cancelled) return
setLiveSchema(nextSchema)
if (isDraftSelected) {
setValues(buildFormValues(nextSchema, selectedConfig))
}
})
.catch((error) => {
if (cancelled) return