From 026ab6df8f62738329500c54d40ef6151979a59d Mon Sep 17 00:00:00 2001 From: jakeaturner Date: Mon, 8 Jun 2026 00:56:41 +0000 Subject: [PATCH] feat(supply-depot): add custom launch URLs for apps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Let users override an app's "Open" link with a reverse-proxy or local-DNS address (e.g. https://jellyfin.myhomelab.net). Falls back to the default host+port when unset. Metadata-only — no container changes. --- admin/app/controllers/system_controller.ts | 32 +++++ admin/app/models/service.ts | 6 + admin/app/services/system_service.ts | 2 + admin/app/validators/system.ts | 28 +++++ ...776200000001_add_custom_url_to_services.ts | 22 ++++ admin/database/seeders/service_seeder.ts | 1 + admin/inertia/components/AppUrlModal.tsx | 113 ++++++++++++++++++ admin/inertia/lib/api.ts | 10 ++ admin/inertia/lib/navigation.ts | 28 ++++- admin/inertia/pages/home.tsx | 7 +- admin/inertia/pages/settings/apps.tsx | 4 +- admin/inertia/pages/supply-depot.tsx | 35 +++++- admin/start/routes.ts | 1 + admin/types/services.ts | 1 + 14 files changed, 282 insertions(+), 8 deletions(-) create mode 100644 admin/database/migrations/1776200000001_add_custom_url_to_services.ts create mode 100644 admin/inertia/components/AppUrlModal.tsx diff --git a/admin/app/controllers/system_controller.ts b/admin/app/controllers/system_controller.ts index 5443fe4..d08b8e6 100644 --- a/admin/app/controllers/system_controller.ts +++ b/admin/app/controllers/system_controller.ts @@ -20,6 +20,8 @@ import { updateCustomAppValidator, updateServiceValidator, setServiceAutoUpdateValidator, + setServiceCustomUrlValidator, + normalizeCustomUrl, } from '#validators/system' import { DEFAULT_CPUS, @@ -447,6 +449,36 @@ export default class SystemController { return response.send({ success: true, message: `Custom app ${payload.service_name} deleted` }) } + /** Set or clear an app's custom launch URL (works for curated and custom apps). Purely a + * metadata change — no container is touched. An empty/invalid value clears the override, after + * which the default host + port link is used again. */ + async setServiceCustomUrl({ request, response }: HttpContext) { + const payload = await request.validateUsing(setServiceCustomUrlValidator) + + const service = await Service.query().where('service_name', payload.service_name).first() + if (!service) { + return response.status(404).send({ success: false, message: `Service ${payload.service_name} not found` }) + } + // Hidden dependency services (e.g. Qdrant) aren't user-launchable, so they have no link to set. + if (service.is_dependency_service) { + return response.status(403).send({ success: false, message: 'This service cannot be configured.' }) + } + + // Reject a non-empty value that isn't a valid http(s) URL; an empty value clears the override. + const normalized = normalizeCustomUrl(payload.custom_url) + if (payload.custom_url && payload.custom_url.trim() && !normalized) { + return response.status(422).send({ + success: false, + message: 'Custom URL must be a valid http(s) address (e.g. https://jellyfin.myhomelab.net).', + }) + } + + service.custom_url = normalized + await service.save() + + return response.send({ success: true, custom_url: service.custom_url }) + } + /** Re-pull a custom app's image and recreate its container in place (preserving volumes). */ async updateCustomApp_pullLatest({ request, response }: HttpContext) { const payload = await request.validateUsing(installServiceValidator) diff --git a/admin/app/models/service.ts b/admin/app/models/service.ts index debd701..08c7711 100644 --- a/admin/app/models/service.ts +++ b/admin/app/models/service.ts @@ -59,6 +59,12 @@ export default class Service extends BaseModel { @column() declare ui_location: string | null + // User-set override for the launch ("Open") link (e.g. a reverse-proxy/local-DNS host like + // https://jellyfin.myhomelab.net). When null, the default host + port link derived from + // ui_location is used. Only affects user-facing links — never internal service-to-service URLs. + @column() + declare custom_url: string | null + @column() declare metadata: string | null diff --git a/admin/app/services/system_service.ts b/admin/app/services/system_service.ts index 21bb422..0fff635 100644 --- a/admin/app/services/system_service.ts +++ b/admin/app/services/system_service.ts @@ -302,6 +302,7 @@ export class SystemService { 'installed', 'installation_status', 'ui_location', + 'custom_url', 'friendly_name', 'description', 'icon', @@ -338,6 +339,7 @@ export class SystemService { installation_status: service.installation_status, status: status ? status.status : 'unknown', ui_location: service.ui_location || '', + custom_url: service.custom_url, powered_by: service.powered_by, display_order: service.display_order, container_image: service.container_image, diff --git a/admin/app/validators/system.ts b/admin/app/validators/system.ts index 84dfa74..8f2d5a6 100644 --- a/admin/app/validators/system.ts +++ b/admin/app/validators/system.ts @@ -95,6 +95,34 @@ export const customAppValidator = vine.compile( }) ) +// Set or clear an app's custom launch URL. A null/empty value clears the override; a non-empty +// value is normalized + validated to a http(s) URL by normalizeCustomUrl in the controller. +export const setServiceCustomUrlValidator = vine.compile( + vine.object({ + service_name: vine.string().trim(), + custom_url: vine.string().trim().nullable(), + }) +) + +/** + * Normalize a user-supplied custom app URL (backend twin of the inertia helper in + * lib/navigation.ts). Accepts a bare host or a full URL; prepends http:// when no scheme is + * present. Returns the normalized href, or null when empty (clears the override) or not a valid + * http(s) URL. Restricting to http/https blocks javascript:/data: from ever being stored. + */ +export function normalizeCustomUrl(input: string | null | undefined): string | null { + const trimmed = (input ?? '').trim() + if (!trimmed) return null + const withScheme = /^https?:\/\//i.test(trimmed) ? trimmed : `http://${trimmed}` + try { + const url = new URL(withScheme) + if (url.protocol !== 'http:' && url.protocol !== 'https:') return null + return url.href + } catch { + return null + } +} + export const deleteCustomAppValidator = vine.compile( vine.object({ service_name: vine.string().trim(), diff --git a/admin/database/migrations/1776200000001_add_custom_url_to_services.ts b/admin/database/migrations/1776200000001_add_custom_url_to_services.ts new file mode 100644 index 0000000..91a1cf2 --- /dev/null +++ b/admin/database/migrations/1776200000001_add_custom_url_to_services.ts @@ -0,0 +1,22 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +export default class extends BaseSchema { + protected tableName = 'services' + + async up() { + this.schema.alterTable(this.tableName, (table) => { + // User-set override for an app's launch ("Open") link, used when the instance sits behind a + // reverse proxy or local DNS (e.g. https://jellyfin.myhomelab.net). When null, the default + // host + port link (derived from ui_location) is used. Stored separately from ui_location so + // the default is always recoverable, and deliberately NOT synced by the service seeder so a + // curated app's override survives reseeds/upgrades. + table.string('custom_url').nullable() + }) + } + + async down() { + this.schema.alterTable(this.tableName, (table) => { + table.dropColumn('custom_url') + }) + } +} diff --git a/admin/database/seeders/service_seeder.ts b/admin/database/seeders/service_seeder.ts index b691cdc..ae7eac8 100644 --- a/admin/database/seeders/service_seeder.ts +++ b/admin/database/seeders/service_seeder.ts @@ -14,6 +14,7 @@ type ServiceSeedRecord = Omit< | 'update_checked_at' | 'metadata' | 'is_user_modified' + | 'custom_url' | 'auto_update_enabled' | 'available_update_first_seen_at' | 'auto_update_consecutive_failures' diff --git a/admin/inertia/components/AppUrlModal.tsx b/admin/inertia/components/AppUrlModal.tsx new file mode 100644 index 0000000..760a259 --- /dev/null +++ b/admin/inertia/components/AppUrlModal.tsx @@ -0,0 +1,113 @@ +import { useEffect, useState } from 'react' +import StyledModal from './StyledModal' +import StyledButton from './StyledButton' +import Input from './inputs/Input' +import { getServiceLink, normalizeCustomUrl } from '~/lib/navigation' +import { ServiceSlim } from '../../types/services' +import api from '~/lib/api' + +interface AppUrlModalProps { + open: boolean + /** The app whose launch URL is being configured. Null while closed. */ + service: ServiceSlim | null + onClose: () => void + /** Called after a successful save/clear so the parent can refresh the link. */ + onSaved: () => void + showError: (msg: string) => void +} + +/** + * Set or clear an app's custom launch URL — used when the instance sits behind a reverse proxy or + * local DNS (e.g. https://jellyfin.myhomelab.net). Leaving the field empty clears the override and + * reverts to the default host + port link. Works for both curated and custom apps. + */ +export default function AppUrlModal({ open, service, onClose, onSaved, showError }: AppUrlModalProps) { + const [value, setValue] = useState('') + const [submitting, setSubmitting] = useState(false) + + // Prefill from the app's stored override each time the modal opens for a service. + useEffect(() => { + if (open && service) setValue(service.custom_url ?? '') + }, [open, service]) + + const trimmed = value.trim() + const normalized = normalizeCustomUrl(value) + const isInvalid = trimmed.length > 0 && !normalized + // What clicking "Open" will actually resolve to once saved. + const previewLink = service ? getServiceLink(service.ui_location || '', value) : '' + const usingDefault = !normalized + + async function handleSave() { + if (!service || isInvalid) return + setSubmitting(true) + // Empty clears the override; the backend re-normalizes/validates the value too. + const result = await api.setServiceCustomUrl(service.service_name, trimmed ? trimmed : null) + setSubmitting(false) + if (!result?.success) { + showError('Failed to save custom URL.') + return + } + onSaved() + } + + const appName = service?.friendly_name || service?.service_name || 'this app' + + return ( + +
+

+ Set where {appName} opens from — useful + if you reach it through a reverse proxy or local DNS. Leave this empty to use the default + address ({service?.ui_location ? `host + port ${service.ui_location}` : 'host + port'}). +

+ +
+
+ setValue(e.target.value)} + error={isInvalid} + className="flex-1 min-w-0" + /> + {value.length > 0 && ( + setValue('')} className="mb-1.5"> + Clear + + )} +
+ {isInvalid ? ( +

+ Enter a valid http(s) address (e.g. https://jellyfin.myhomelab.net). A bare host like + "jellyfin.lan" becomes http://jellyfin.lan. +

+ ) : ( + <> +

+ No scheme? We'll default to http://.

+

+ Opens as:{' '} + {previewLink} + {usingDefault ? ' (default)' : ''} +

+ + )} +
+
+
+ ) +} diff --git a/admin/inertia/lib/api.ts b/admin/inertia/lib/api.ts index 6b9a4de..73b0d37 100644 --- a/admin/inertia/lib/api.ts +++ b/admin/inertia/lib/api.ts @@ -1049,6 +1049,16 @@ class API { })() } + async setServiceCustomUrl(service_name: string, custom_url: string | null) { + return catchInternal(async () => { + const response = await this.client.put<{ success: boolean; custom_url: string | null }>( + '/system/services/custom-url', + { service_name, custom_url } + ) + return response.data + })() + } + async deleteCustomApp(service_name: string, remove_image = false) { return catchInternal(async () => { const response = await this.client.delete<{ success: boolean; message: string }>( diff --git a/admin/inertia/lib/navigation.ts b/admin/inertia/lib/navigation.ts index b88255e..88eea02 100644 --- a/admin/inertia/lib/navigation.ts +++ b/admin/inertia/lib/navigation.ts @@ -1,6 +1,32 @@ +/** + * Normalize a user-supplied custom app URL. Accepts a bare host ("jellyfin.lan", + * "10.0.0.5:8096") or a full URL; when no scheme is present, http:// is prepended + * (LAN resources are usually plain HTTP). Returns the normalized href, or null when + * the input is empty (clears the override) or not a valid http(s) URL. Restricting to + * http/https is the XSS guard — javascript:/data: URLs never make it into an href. + */ +export function normalizeCustomUrl(input: string | null | undefined): string | null { + const trimmed = (input ?? '').trim(); + if (!trimmed) return null; + const withScheme = /^https?:\/\//i.test(trimmed) ? trimmed : `http://${trimmed}`; + try { + const url = new URL(withScheme); + if (url.protocol !== 'http:' && url.protocol !== 'https:') return null; + return url.href; + } catch { + return null; + } +} + +export function getServiceLink(ui_location: string, customUrl?: string | null): string { + // A user-set custom URL (reverse proxy / local DNS) overrides the computed default. Only + // accepted when it normalizes to a valid http(s) URL — otherwise fall through to the default. + const normalizedCustom = normalizeCustomUrl(customUrl); + if (normalizedCustom) { + return normalizedCustom; + } -export function getServiceLink(ui_location: string): string { // "https:8480" / "http:8480" — an explicit scheme + port served on the current host. Checked // before the URL parse below because new URL("https:8480") would mis-parse 8480 as the host. const schemePort = ui_location.match(/^(https?):(\d+)$/); diff --git a/admin/inertia/pages/home.tsx b/admin/inertia/pages/home.tsx index b9581bc..bf38fc1 100644 --- a/admin/inertia/pages/home.tsx +++ b/admin/inertia/pages/home.tsx @@ -101,12 +101,15 @@ export default function Home(props: { // Add installed services (non-dependency services only) props.system.services - .filter((service) => service.installed && service.ui_location) + .filter((service) => service.installed && (service.ui_location || service.custom_url)) .forEach((service) => { items.push({ // Inject custom AI Assistant name if this is the chat service label: service.service_name === SERVICE_NAMES.OLLAMA && aiAssistantName ? aiAssistantName : (service.friendly_name || service.service_name), - to: service.ui_location ? getServiceLink(service.ui_location) : '#', + to: + service.ui_location || service.custom_url + ? getServiceLink(service.ui_location || '', service.custom_url) + : '#', target: '_blank', description: service.description || diff --git a/admin/inertia/pages/settings/apps.tsx b/admin/inertia/pages/settings/apps.tsx index 48260c2..c5c5fbf 100644 --- a/admin/inertia/pages/settings/apps.tsx +++ b/admin/inertia/pages/settings/apps.tsx @@ -253,7 +253,7 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[] { - window.open(getServiceLink(record.ui_location || 'unknown'), '_blank') + window.open(getServiceLink(record.ui_location || 'unknown', record.custom_url), '_blank') }} > Open @@ -374,7 +374,7 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[] title: 'Location', render: (record) => ( (null) const [customAppOpen, setCustomAppOpen] = useState(false) const [editApp, setEditApp] = useState(null) + // App whose custom launch URL is being configured (null while the modal is closed). + const [urlApp, setUrlApp] = useState(null) const [removeImage, setRemoveImage] = useState(false) // Optimistic per-app auto-update toggle state, keyed by service_name. Lets the // toggle reflect instantly without a full page reload (props come from Inertia). @@ -305,6 +309,17 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim setTimeout(() => window.location.reload(), 1500) } + function handleSetUrl(service: ServiceSlim) { + setOpenDropdown(null) + setUrlApp(service) + } + + function handleUrlSaved() { + setUrlApp(null) + // Reload so the new link flows through to the card, /home, and settings. + window.location.reload() + } + // ── Install modal helpers ───────────────────────────────────────────────── const hasPreflightWarnings = (preflight?.portConflicts.length ?? 0) > 0 || (preflight?.resourceWarnings.length ?? 0) > 0 @@ -438,6 +453,7 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim onLogs={() => setModal({ type: 'logs', service })} onStats={() => setModal({ type: 'stats', service })} onEdit={() => handleEdit(service)} + onSetUrl={() => handleSetUrl(service)} onUpdate={() => handleUpdate(service)} onUpdateVersion={() => setModal({ type: 'update', service })} autoUpdateEnabled={ @@ -471,6 +487,7 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim onLogs={() => setModal({ type: 'logs', service })} onStats={() => setModal({ type: 'stats', service })} onEdit={() => handleEdit(service)} + onSetUrl={() => handleSetUrl(service)} onUpdate={() => handleUpdate(service)} onUpdateVersion={() => setModal({ type: 'update', service })} /> @@ -695,6 +712,15 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim onCreated={handleEdited} showError={showError} /> + + {/* Custom launch URL modal */} + setUrlApp(null)} + onSaved={handleUrlSaved} + showError={showError} + /> ) } @@ -715,6 +741,7 @@ interface AppCardProps { onLogs: () => void onStats: () => void onEdit: () => void + onSetUrl: () => void onUpdate: () => void onUpdateVersion: () => void // Installed-only: per-app auto-update preference + toggle handler. @@ -738,6 +765,7 @@ function AppCard({ onLogs, onStats, onEdit, + onSetUrl, onUpdate, onUpdateVersion, autoUpdateEnabled, @@ -878,10 +906,10 @@ function AppCard({ {service.installed ? ( <> - {/* Open button */} - {service.ui_location && ( + {/* Open button — shown when the app has a default location or a user-set custom URL */} + {(service.ui_location || service.custom_url) && ( } label="Logs" onClick={onLogs} /> } label="Stats" onClick={onStats} /> } label="Edit" onClick={onEdit} /> + } label="Set custom URL" onClick={onSetUrl} /> {!service.is_custom && onToggleAutoUpdate ? ( autoUpdateMasterEnabled ? (