From 9cd7a39f0920b6ef8da90dcc796a821de0827443 Mon Sep 17 00:00:00 2001 From: Christian Byrne Date: Sun, 2 Nov 2025 21:23:56 -0800 Subject: [PATCH] load template workflow via URL query param (#6546) ## Summary Adds support for loading templates via URL query parameters. Users can now share direct links to templates. To test: 1. checkout this branch 2. start dev server on port 5173 3. go to http://localhost:5173/?template=image_qwen_image_edit_2509 **Examples:** - `/?template=default` - Loads template with default source - `/?template=flux_simple&source=custom` - Loads from custom source Includes error handling with toast notifications for invalid templates and comprehensive test coverage. --------- Co-authored-by: Christian Byrne --- pnpm-lock.yaml | 6 - src/locales/en/main.json | 3 + .../composables/useTemplateUrlLoader.ts | 96 ++++++++ src/views/GraphView.vue | 7 + .../composables/useTemplateUrlLoader.test.ts | 220 ++++++++++++++++++ 5 files changed, 326 insertions(+), 6 deletions(-) create mode 100644 src/platform/workflow/templates/composables/useTemplateUrlLoader.ts create mode 100644 tests-ui/tests/platform/workflow/templates/composables/useTemplateUrlLoader.test.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a8112d3f0..9fde29d3e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,15 +12,9 @@ catalogs: '@eslint/js': specifier: ^9.35.0 version: 9.35.0 - '@iconify-json/lucide': - specifier: ^1.1.178 - version: 1.2.66 '@iconify/json': specifier: ^2.2.380 version: 2.2.380 - '@iconify/tailwind': - specifier: ^1.1.3 - version: 1.2.0 '@intlify/eslint-plugin-vue-i18n': specifier: ^4.1.0 version: 4.1.0 diff --git a/src/locales/en/main.json b/src/locales/en/main.json index b7e4c4b9a..13da80acc 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -780,6 +780,9 @@ "vramLowToHigh": "VRAM Usage (Low to High)", "modelSizeLowToHigh": "Model Size (Low to High)", "default": "Default" + }, + "error": { + "templateNotFound": "Template \"{templateName}\" not found" } }, "graphCanvasMenu": { diff --git a/src/platform/workflow/templates/composables/useTemplateUrlLoader.ts b/src/platform/workflow/templates/composables/useTemplateUrlLoader.ts new file mode 100644 index 000000000..c23e570e6 --- /dev/null +++ b/src/platform/workflow/templates/composables/useTemplateUrlLoader.ts @@ -0,0 +1,96 @@ +import { useToast } from 'primevue/usetoast' +import { useI18n } from 'vue-i18n' +import { useRoute } from 'vue-router' + +import { useTemplateWorkflows } from './useTemplateWorkflows' + +/** + * Composable for loading templates from URL query parameters + * + * Supports URLs like: + * - /?template=flux_simple (loads with default source) + * - /?template=flux_simple&source=custom (loads from custom source) + * + * Input validation: + * - Template and source parameters must match: ^[a-zA-Z0-9_-]+$ + * - Invalid formats are rejected with console warnings + */ +export function useTemplateUrlLoader() { + const route = useRoute() + const { t } = useI18n() + const toast = useToast() + const templateWorkflows = useTemplateWorkflows() + + /** + * Validates parameter format to prevent path traversal and injection attacks + */ + const isValidParameter = (param: string): boolean => { + return /^[a-zA-Z0-9_-]+$/.test(param) + } + + /** + * Loads template from URL query parameters if present + * Handles errors internally and shows appropriate user feedback + */ + const loadTemplateFromUrl = async () => { + const templateParam = route.query.template + + if (!templateParam || typeof templateParam !== 'string') { + return + } + + // Validate template name format + if (!isValidParameter(templateParam)) { + console.warn( + `[useTemplateUrlLoader] Invalid template parameter format: ${templateParam}` + ) + return + } + + const sourceParam = (route.query.source as string | undefined) || 'default' + + // Validate source parameter format + if (!isValidParameter(sourceParam)) { + console.warn( + `[useTemplateUrlLoader] Invalid source parameter format: ${sourceParam}` + ) + return + } + + // Load template with error handling + try { + await templateWorkflows.loadTemplates() + + const success = await templateWorkflows.loadWorkflowTemplate( + templateParam, + sourceParam + ) + + if (!success) { + toast.add({ + severity: 'error', + summary: t('g.error'), + detail: t('templateWorkflows.error.templateNotFound', { + templateName: templateParam + }), + life: 3000 + }) + } + } catch (error) { + console.error( + '[useTemplateUrlLoader] Failed to load template from URL:', + error + ) + toast.add({ + severity: 'error', + summary: t('g.error'), + detail: t('g.errorLoadingTemplate'), + life: 3000 + }) + } + } + + return { + loadTemplateFromUrl + } +} diff --git a/src/views/GraphView.vue b/src/views/GraphView.vue index 93b290f91..bc7639015 100644 --- a/src/views/GraphView.vue +++ b/src/views/GraphView.vue @@ -53,6 +53,7 @@ import { useSettingStore } from '@/platform/settings/settingStore' import { useTelemetry } from '@/platform/telemetry' import { useFrontendVersionMismatchWarning } from '@/platform/updates/common/useFrontendVersionMismatchWarning' import { useVersionCompatibilityStore } from '@/platform/updates/common/versionCompatibilityStore' +import { useTemplateUrlLoader } from '@/platform/workflow/templates/composables/useTemplateUrlLoader' import type { StatusWsMessageStatus } from '@/schemas/apiSchema' import { api } from '@/scripts/api' import { app } from '@/scripts/app' @@ -80,6 +81,9 @@ setupAutoQueueHandler() useProgressFavicon() useBrowserTabTitle() +// Template URL loading +const { loadTemplateFromUrl } = useTemplateUrlLoader() + const { t } = useI18n() const toast = useToast() const settingStore = useSettingStore() @@ -349,6 +353,9 @@ const onGraphReady = () => { tabCountChannel.postMessage({ type: 'heartbeat', tabId: currentTabId }) } + // Load template from URL if present + void loadTemplateFromUrl() + // Setting values now available after comfyApp.setup. // Load keybindings. wrapWithErrorHandling(useKeybindingService().registerUserKeybindings)() diff --git a/tests-ui/tests/platform/workflow/templates/composables/useTemplateUrlLoader.test.ts b/tests-ui/tests/platform/workflow/templates/composables/useTemplateUrlLoader.test.ts new file mode 100644 index 000000000..5ed3b94ce --- /dev/null +++ b/tests-ui/tests/platform/workflow/templates/composables/useTemplateUrlLoader.test.ts @@ -0,0 +1,220 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { useTemplateUrlLoader } from '@/platform/workflow/templates/composables/useTemplateUrlLoader' + +/** + * Unit tests for useTemplateUrlLoader composable + * + * Tests the behavior of loading templates via URL query parameters: + * - ?template=flux_simple loads the template + * - ?template=flux_simple&source=custom loads from custom source + * - Invalid template shows error toast + * - Input validation for template and source parameters + */ + +// Mock vue-router +let mockQueryParams: Record = {} + +vi.mock('vue-router', () => ({ + useRoute: vi.fn(() => ({ + query: mockQueryParams + })) +})) + +// Mock template workflows composable +const mockLoadTemplates = vi.fn().mockResolvedValue(true) +const mockLoadWorkflowTemplate = vi.fn().mockResolvedValue(true) + +vi.mock( + '@/platform/workflow/templates/composables/useTemplateWorkflows', + () => ({ + useTemplateWorkflows: () => ({ + loadTemplates: mockLoadTemplates, + loadWorkflowTemplate: mockLoadWorkflowTemplate + }) + }) +) + +// Mock toast +const mockToastAdd = vi.fn() +vi.mock('primevue/usetoast', () => ({ + useToast: () => ({ + add: mockToastAdd + }) +})) + +// Mock i18n +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ + t: vi.fn((key: string, params?: any) => { + if (key === 'g.error') return 'Error' + if (key === 'templateWorkflows.error.templateNotFound') { + return `Template "${params?.templateName}" not found` + } + if (key === 'g.errorLoadingTemplate') return 'Failed to load template' + return key + }) + }) +})) + +describe('useTemplateUrlLoader', () => { + beforeEach(() => { + vi.clearAllMocks() + mockQueryParams = {} + }) + + it('does not load template when no query param present', () => { + mockQueryParams = {} + + const { loadTemplateFromUrl } = useTemplateUrlLoader() + void loadTemplateFromUrl() + + expect(mockLoadTemplates).not.toHaveBeenCalled() + expect(mockLoadWorkflowTemplate).not.toHaveBeenCalled() + }) + + it('loads template when query param is present', async () => { + mockQueryParams = { template: 'flux_simple' } + + const { loadTemplateFromUrl } = useTemplateUrlLoader() + await loadTemplateFromUrl() + + expect(mockLoadTemplates).toHaveBeenCalledTimes(1) + expect(mockLoadWorkflowTemplate).toHaveBeenCalledWith( + 'flux_simple', + 'default' + ) + }) + + it('uses default source when source param is not provided', async () => { + mockQueryParams = { template: 'flux_simple' } + + const { loadTemplateFromUrl } = useTemplateUrlLoader() + await loadTemplateFromUrl() + + expect(mockLoadWorkflowTemplate).toHaveBeenCalledWith( + 'flux_simple', + 'default' + ) + }) + + it('uses custom source when source param is provided', async () => { + mockQueryParams = { template: 'custom-template', source: 'custom-module' } + + const { loadTemplateFromUrl } = useTemplateUrlLoader() + await loadTemplateFromUrl() + + expect(mockLoadWorkflowTemplate).toHaveBeenCalledWith( + 'custom-template', + 'custom-module' + ) + }) + + it('shows error toast when template loading fails', async () => { + mockQueryParams = { template: 'invalid-template' } + mockLoadWorkflowTemplate.mockResolvedValueOnce(false) + + const { loadTemplateFromUrl } = useTemplateUrlLoader() + await loadTemplateFromUrl() + + expect(mockToastAdd).toHaveBeenCalledWith({ + severity: 'error', + summary: 'Error', + detail: 'Template "invalid-template" not found', + life: 3000 + }) + }) + + it('handles array query params correctly', () => { + // Vue Router can return string[] for duplicate params + mockQueryParams = { template: ['first', 'second'] as any } + + const { loadTemplateFromUrl } = useTemplateUrlLoader() + void loadTemplateFromUrl() + + // Should not load when param is an array + expect(mockLoadTemplates).not.toHaveBeenCalled() + }) + + it('rejects invalid template parameter with special characters', () => { + // Test path traversal attempt + mockQueryParams = { template: '../../../etc/passwd' } + + const { loadTemplateFromUrl } = useTemplateUrlLoader() + void loadTemplateFromUrl() + + // Should not load invalid template + expect(mockLoadTemplates).not.toHaveBeenCalled() + }) + + it('rejects invalid template parameter with slash', () => { + mockQueryParams = { template: 'path/to/template' } + + const { loadTemplateFromUrl } = useTemplateUrlLoader() + void loadTemplateFromUrl() + + // Should not load invalid template + expect(mockLoadTemplates).not.toHaveBeenCalled() + }) + + it('accepts valid template parameter formats', async () => { + const validTemplates = [ + 'flux_simple', + 'flux-kontext-dev', + 'template123', + 'My_Template-2' + ] + + for (const template of validTemplates) { + vi.clearAllMocks() + mockQueryParams = { template } + + const { loadTemplateFromUrl } = useTemplateUrlLoader() + await loadTemplateFromUrl() + + expect(mockLoadWorkflowTemplate).toHaveBeenCalledWith(template, 'default') + } + }) + + it('rejects invalid source parameter with special characters', () => { + mockQueryParams = { template: 'flux_simple', source: '../malicious' } + + const { loadTemplateFromUrl } = useTemplateUrlLoader() + void loadTemplateFromUrl() + + // Should not load with invalid source + expect(mockLoadTemplates).not.toHaveBeenCalled() + }) + + it('accepts valid source parameter formats', async () => { + const validSources = ['default', 'custom-module', 'my_source', 'source123'] + + for (const source of validSources) { + vi.clearAllMocks() + mockQueryParams = { template: 'flux_simple', source } + + const { loadTemplateFromUrl } = useTemplateUrlLoader() + await loadTemplateFromUrl() + + expect(mockLoadWorkflowTemplate).toHaveBeenCalledWith( + 'flux_simple', + source + ) + } + }) + + it('shows error toast when exception is thrown', async () => { + mockQueryParams = { template: 'flux_simple' } + mockLoadTemplates.mockRejectedValueOnce(new Error('Network error')) + + const { loadTemplateFromUrl } = useTemplateUrlLoader() + await loadTemplateFromUrl() + + expect(mockToastAdd).toHaveBeenCalledWith({ + severity: 'error', + summary: 'Error', + detail: 'Failed to load template', + life: 3000 + }) + }) +})