Skip to content

Commit 657eadb

Browse files
comfy-pr-botchristian-byrneChristian Byrne
authored
[backport rh-test] load template workflow via URL query param (#6553)
Backport of #6546 to `rh-test` Automatically created by backport workflow. ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-6553-backport-rh-test-load-template-workflow-via-URL-query-param-2a06d73d36508134b560dadeb00a4510) by [Unito](https://www.unito.io) Co-authored-by: Christian Byrne <[email protected]> Co-authored-by: Christian Byrne <[email protected]>
1 parent 56412a4 commit 657eadb

File tree

5 files changed

+326
-6
lines changed

5 files changed

+326
-6
lines changed

pnpm-lock.yaml

Lines changed: 0 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/locales/en/main.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1049,6 +1049,9 @@
10491049
"vramLowToHigh": "VRAM Usage (Low to High)",
10501050
"modelSizeLowToHigh": "Model Size (Low to High)",
10511051
"default": "Default"
1052+
},
1053+
"error": {
1054+
"templateNotFound": "Template \"{templateName}\" not found"
10521055
}
10531056
},
10541057
"graphCanvasMenu": {
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { useToast } from 'primevue/usetoast'
2+
import { useI18n } from 'vue-i18n'
3+
import { useRoute } from 'vue-router'
4+
5+
import { useTemplateWorkflows } from './useTemplateWorkflows'
6+
7+
/**
8+
* Composable for loading templates from URL query parameters
9+
*
10+
* Supports URLs like:
11+
* - /?template=flux_simple (loads with default source)
12+
* - /?template=flux_simple&source=custom (loads from custom source)
13+
*
14+
* Input validation:
15+
* - Template and source parameters must match: ^[a-zA-Z0-9_-]+$
16+
* - Invalid formats are rejected with console warnings
17+
*/
18+
export function useTemplateUrlLoader() {
19+
const route = useRoute()
20+
const { t } = useI18n()
21+
const toast = useToast()
22+
const templateWorkflows = useTemplateWorkflows()
23+
24+
/**
25+
* Validates parameter format to prevent path traversal and injection attacks
26+
*/
27+
const isValidParameter = (param: string): boolean => {
28+
return /^[a-zA-Z0-9_-]+$/.test(param)
29+
}
30+
31+
/**
32+
* Loads template from URL query parameters if present
33+
* Handles errors internally and shows appropriate user feedback
34+
*/
35+
const loadTemplateFromUrl = async () => {
36+
const templateParam = route.query.template
37+
38+
if (!templateParam || typeof templateParam !== 'string') {
39+
return
40+
}
41+
42+
// Validate template name format
43+
if (!isValidParameter(templateParam)) {
44+
console.warn(
45+
`[useTemplateUrlLoader] Invalid template parameter format: ${templateParam}`
46+
)
47+
return
48+
}
49+
50+
const sourceParam = (route.query.source as string | undefined) || 'default'
51+
52+
// Validate source parameter format
53+
if (!isValidParameter(sourceParam)) {
54+
console.warn(
55+
`[useTemplateUrlLoader] Invalid source parameter format: ${sourceParam}`
56+
)
57+
return
58+
}
59+
60+
// Load template with error handling
61+
try {
62+
await templateWorkflows.loadTemplates()
63+
64+
const success = await templateWorkflows.loadWorkflowTemplate(
65+
templateParam,
66+
sourceParam
67+
)
68+
69+
if (!success) {
70+
toast.add({
71+
severity: 'error',
72+
summary: t('g.error'),
73+
detail: t('templateWorkflows.error.templateNotFound', {
74+
templateName: templateParam
75+
}),
76+
life: 3000
77+
})
78+
}
79+
} catch (error) {
80+
console.error(
81+
'[useTemplateUrlLoader] Failed to load template from URL:',
82+
error
83+
)
84+
toast.add({
85+
severity: 'error',
86+
summary: t('g.error'),
87+
detail: t('g.errorLoadingTemplate'),
88+
life: 3000
89+
})
90+
}
91+
}
92+
93+
return {
94+
loadTemplateFromUrl
95+
}
96+
}

src/views/GraphView.vue

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ import { useSettingStore } from '@/platform/settings/settingStore'
5151
import { useTelemetry } from '@/platform/telemetry'
5252
import { useFrontendVersionMismatchWarning } from '@/platform/updates/common/useFrontendVersionMismatchWarning'
5353
import { useVersionCompatibilityStore } from '@/platform/updates/common/versionCompatibilityStore'
54+
import { useTemplateUrlLoader } from '@/platform/workflow/templates/composables/useTemplateUrlLoader'
5455
import type { StatusWsMessageStatus } from '@/schemas/apiSchema'
5556
import { api } from '@/scripts/api'
5657
import { app } from '@/scripts/app'
@@ -77,6 +78,9 @@ setupAutoQueueHandler()
7778
useProgressFavicon()
7879
useBrowserTabTitle()
7980
81+
// Template URL loading
82+
const { loadTemplateFromUrl } = useTemplateUrlLoader()
83+
8084
const { t } = useI18n()
8185
const toast = useToast()
8286
const settingStore = useSettingStore()
@@ -343,6 +347,9 @@ const onGraphReady = () => {
343347
tabCountChannel.postMessage({ type: 'heartbeat', tabId: currentTabId })
344348
}
345349
350+
// Load template from URL if present
351+
void loadTemplateFromUrl()
352+
346353
// Setting values now available after comfyApp.setup.
347354
// Load keybindings.
348355
wrapWithErrorHandling(useKeybindingService().registerUserKeybindings)()
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest'
2+
3+
import { useTemplateUrlLoader } from '@/platform/workflow/templates/composables/useTemplateUrlLoader'
4+
5+
/**
6+
* Unit tests for useTemplateUrlLoader composable
7+
*
8+
* Tests the behavior of loading templates via URL query parameters:
9+
* - ?template=flux_simple loads the template
10+
* - ?template=flux_simple&source=custom loads from custom source
11+
* - Invalid template shows error toast
12+
* - Input validation for template and source parameters
13+
*/
14+
15+
// Mock vue-router
16+
let mockQueryParams: Record<string, string | undefined> = {}
17+
18+
vi.mock('vue-router', () => ({
19+
useRoute: vi.fn(() => ({
20+
query: mockQueryParams
21+
}))
22+
}))
23+
24+
// Mock template workflows composable
25+
const mockLoadTemplates = vi.fn().mockResolvedValue(true)
26+
const mockLoadWorkflowTemplate = vi.fn().mockResolvedValue(true)
27+
28+
vi.mock(
29+
'@/platform/workflow/templates/composables/useTemplateWorkflows',
30+
() => ({
31+
useTemplateWorkflows: () => ({
32+
loadTemplates: mockLoadTemplates,
33+
loadWorkflowTemplate: mockLoadWorkflowTemplate
34+
})
35+
})
36+
)
37+
38+
// Mock toast
39+
const mockToastAdd = vi.fn()
40+
vi.mock('primevue/usetoast', () => ({
41+
useToast: () => ({
42+
add: mockToastAdd
43+
})
44+
}))
45+
46+
// Mock i18n
47+
vi.mock('vue-i18n', () => ({
48+
useI18n: () => ({
49+
t: vi.fn((key: string, params?: any) => {
50+
if (key === 'g.error') return 'Error'
51+
if (key === 'templateWorkflows.error.templateNotFound') {
52+
return `Template "${params?.templateName}" not found`
53+
}
54+
if (key === 'g.errorLoadingTemplate') return 'Failed to load template'
55+
return key
56+
})
57+
})
58+
}))
59+
60+
describe('useTemplateUrlLoader', () => {
61+
beforeEach(() => {
62+
vi.clearAllMocks()
63+
mockQueryParams = {}
64+
})
65+
66+
it('does not load template when no query param present', () => {
67+
mockQueryParams = {}
68+
69+
const { loadTemplateFromUrl } = useTemplateUrlLoader()
70+
void loadTemplateFromUrl()
71+
72+
expect(mockLoadTemplates).not.toHaveBeenCalled()
73+
expect(mockLoadWorkflowTemplate).not.toHaveBeenCalled()
74+
})
75+
76+
it('loads template when query param is present', async () => {
77+
mockQueryParams = { template: 'flux_simple' }
78+
79+
const { loadTemplateFromUrl } = useTemplateUrlLoader()
80+
await loadTemplateFromUrl()
81+
82+
expect(mockLoadTemplates).toHaveBeenCalledTimes(1)
83+
expect(mockLoadWorkflowTemplate).toHaveBeenCalledWith(
84+
'flux_simple',
85+
'default'
86+
)
87+
})
88+
89+
it('uses default source when source param is not provided', async () => {
90+
mockQueryParams = { template: 'flux_simple' }
91+
92+
const { loadTemplateFromUrl } = useTemplateUrlLoader()
93+
await loadTemplateFromUrl()
94+
95+
expect(mockLoadWorkflowTemplate).toHaveBeenCalledWith(
96+
'flux_simple',
97+
'default'
98+
)
99+
})
100+
101+
it('uses custom source when source param is provided', async () => {
102+
mockQueryParams = { template: 'custom-template', source: 'custom-module' }
103+
104+
const { loadTemplateFromUrl } = useTemplateUrlLoader()
105+
await loadTemplateFromUrl()
106+
107+
expect(mockLoadWorkflowTemplate).toHaveBeenCalledWith(
108+
'custom-template',
109+
'custom-module'
110+
)
111+
})
112+
113+
it('shows error toast when template loading fails', async () => {
114+
mockQueryParams = { template: 'invalid-template' }
115+
mockLoadWorkflowTemplate.mockResolvedValueOnce(false)
116+
117+
const { loadTemplateFromUrl } = useTemplateUrlLoader()
118+
await loadTemplateFromUrl()
119+
120+
expect(mockToastAdd).toHaveBeenCalledWith({
121+
severity: 'error',
122+
summary: 'Error',
123+
detail: 'Template "invalid-template" not found',
124+
life: 3000
125+
})
126+
})
127+
128+
it('handles array query params correctly', () => {
129+
// Vue Router can return string[] for duplicate params
130+
mockQueryParams = { template: ['first', 'second'] as any }
131+
132+
const { loadTemplateFromUrl } = useTemplateUrlLoader()
133+
void loadTemplateFromUrl()
134+
135+
// Should not load when param is an array
136+
expect(mockLoadTemplates).not.toHaveBeenCalled()
137+
})
138+
139+
it('rejects invalid template parameter with special characters', () => {
140+
// Test path traversal attempt
141+
mockQueryParams = { template: '../../../etc/passwd' }
142+
143+
const { loadTemplateFromUrl } = useTemplateUrlLoader()
144+
void loadTemplateFromUrl()
145+
146+
// Should not load invalid template
147+
expect(mockLoadTemplates).not.toHaveBeenCalled()
148+
})
149+
150+
it('rejects invalid template parameter with slash', () => {
151+
mockQueryParams = { template: 'path/to/template' }
152+
153+
const { loadTemplateFromUrl } = useTemplateUrlLoader()
154+
void loadTemplateFromUrl()
155+
156+
// Should not load invalid template
157+
expect(mockLoadTemplates).not.toHaveBeenCalled()
158+
})
159+
160+
it('accepts valid template parameter formats', async () => {
161+
const validTemplates = [
162+
'flux_simple',
163+
'flux-kontext-dev',
164+
'template123',
165+
'My_Template-2'
166+
]
167+
168+
for (const template of validTemplates) {
169+
vi.clearAllMocks()
170+
mockQueryParams = { template }
171+
172+
const { loadTemplateFromUrl } = useTemplateUrlLoader()
173+
await loadTemplateFromUrl()
174+
175+
expect(mockLoadWorkflowTemplate).toHaveBeenCalledWith(template, 'default')
176+
}
177+
})
178+
179+
it('rejects invalid source parameter with special characters', () => {
180+
mockQueryParams = { template: 'flux_simple', source: '../malicious' }
181+
182+
const { loadTemplateFromUrl } = useTemplateUrlLoader()
183+
void loadTemplateFromUrl()
184+
185+
// Should not load with invalid source
186+
expect(mockLoadTemplates).not.toHaveBeenCalled()
187+
})
188+
189+
it('accepts valid source parameter formats', async () => {
190+
const validSources = ['default', 'custom-module', 'my_source', 'source123']
191+
192+
for (const source of validSources) {
193+
vi.clearAllMocks()
194+
mockQueryParams = { template: 'flux_simple', source }
195+
196+
const { loadTemplateFromUrl } = useTemplateUrlLoader()
197+
await loadTemplateFromUrl()
198+
199+
expect(mockLoadWorkflowTemplate).toHaveBeenCalledWith(
200+
'flux_simple',
201+
source
202+
)
203+
}
204+
})
205+
206+
it('shows error toast when exception is thrown', async () => {
207+
mockQueryParams = { template: 'flux_simple' }
208+
mockLoadTemplates.mockRejectedValueOnce(new Error('Network error'))
209+
210+
const { loadTemplateFromUrl } = useTemplateUrlLoader()
211+
await loadTemplateFromUrl()
212+
213+
expect(mockToastAdd).toHaveBeenCalledWith({
214+
severity: 'error',
215+
summary: 'Error',
216+
detail: 'Failed to load template',
217+
life: 3000
218+
})
219+
})
220+
})

0 commit comments

Comments
 (0)