mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-22 11:13:26 +00:00
fix: improve guest URL validation and error handling (addresses #427)
- Add client-side URL validation with instant feedback - Show validation errors inline below URL input fields - Prevent saving when URLs have validation errors - Improve error message extraction in API client - Handle incomplete URLs like 'https://emby.' gracefully - Backend already had validation, now frontend shows it properly
This commit is contained in:
@@ -17,6 +17,7 @@ export function GuestURLs(props: GuestURLsProps) {
|
||||
const [searchTerm, setSearchTerm] = createSignal('');
|
||||
const [loading, setLoading] = createSignal(false);
|
||||
const [initialLoad, setInitialLoad] = createSignal(true);
|
||||
const [urlErrors, setUrlErrors] = createSignal<Record<string, string>>({});
|
||||
|
||||
// Combine VMs and containers into a single list
|
||||
const allGuests = createMemo(() => {
|
||||
@@ -76,18 +77,29 @@ export function GuestURLs(props: GuestURLsProps) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const metadata = guestMetadata();
|
||||
const promises: Promise<void>[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
// Update each guest that has changes
|
||||
for (const [guestId, meta] of Object.entries(metadata)) {
|
||||
if (meta.customUrl !== undefined) {
|
||||
promises.push(GuestMetadataAPI.updateMetadata(guestId, { customUrl: meta.customUrl }));
|
||||
try {
|
||||
await GuestMetadataAPI.updateMetadata(guestId, { customUrl: meta.customUrl });
|
||||
} catch (err: any) {
|
||||
// Extract error message from response
|
||||
const errorMsg = err.message || err.toString();
|
||||
errors.push(`${guestId}: ${errorMsg}`);
|
||||
console.error(`Failed to save URL for ${guestId}:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
showSuccess('Guest URLs saved');
|
||||
props.setHasUnsavedChanges(false);
|
||||
if (errors.length > 0) {
|
||||
// Show specific validation errors
|
||||
showError(errors.join('\n'));
|
||||
} else {
|
||||
showSuccess('Guest URLs saved');
|
||||
props.setHasUnsavedChanges(false);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to save guest URLs:', err);
|
||||
showError('Failed to save guest URLs');
|
||||
@@ -96,6 +108,39 @@ export function GuestURLs(props: GuestURLsProps) {
|
||||
}
|
||||
};
|
||||
|
||||
// Validate URL format
|
||||
const validateURL = (url: string): string | null => {
|
||||
if (!url) return null; // Empty is valid
|
||||
|
||||
// Check for incomplete URLs like "https://emby."
|
||||
if (url.endsWith('.') && !url.includes('..')) {
|
||||
return 'URL appears incomplete - please enter a complete domain or IP address';
|
||||
}
|
||||
|
||||
// Check for missing protocol
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
return 'URL must start with http:// or https://';
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
|
||||
// Check for valid host
|
||||
if (!parsed.hostname) {
|
||||
return 'URL must include a valid hostname or IP address';
|
||||
}
|
||||
|
||||
// Check for incomplete hostnames
|
||||
if (parsed.hostname.endsWith('.') && !parsed.hostname.includes('..')) {
|
||||
return 'Hostname appears incomplete';
|
||||
}
|
||||
|
||||
return null; // Valid
|
||||
} catch (e) {
|
||||
return 'Invalid URL format';
|
||||
}
|
||||
};
|
||||
|
||||
// Update a guest's URL configuration
|
||||
const updateGuestURL = (guestId: string, url: string) => {
|
||||
setGuestMetadata({
|
||||
@@ -106,6 +151,16 @@ export function GuestURLs(props: GuestURLsProps) {
|
||||
}
|
||||
});
|
||||
|
||||
// Validate and update errors
|
||||
const error = validateURL(url);
|
||||
const errors = { ...urlErrors() };
|
||||
if (error) {
|
||||
errors[guestId] = error;
|
||||
} else {
|
||||
delete errors[guestId];
|
||||
}
|
||||
setUrlErrors(errors);
|
||||
|
||||
props.setHasUnsavedChanges(true);
|
||||
};
|
||||
|
||||
@@ -154,7 +209,7 @@ export function GuestURLs(props: GuestURLsProps) {
|
||||
</div>
|
||||
|
||||
{/* Save Button */}
|
||||
<Show when={props.hasUnsavedChanges()}>
|
||||
<Show when={props.hasUnsavedChanges() && Object.keys(urlErrors()).length === 0}>
|
||||
<div class="flex justify-end">
|
||||
<button type="button"
|
||||
onClick={saveURLs}
|
||||
@@ -215,6 +270,8 @@ export function GuestURLs(props: GuestURLsProps) {
|
||||
const meta = guestMetadata()[guestId];
|
||||
const url = getURL(guestId);
|
||||
|
||||
const urlError = urlErrors()[guestId];
|
||||
|
||||
return (
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-900/50 transition-colors">
|
||||
<td class="p-1 px-2">
|
||||
@@ -235,16 +292,27 @@ export function GuestURLs(props: GuestURLsProps) {
|
||||
{guest.vmid}
|
||||
</td>
|
||||
<td class="p-1 px-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="https://192.168.1.100:8006"
|
||||
value={meta?.customUrl || ''}
|
||||
onInput={(e) => updateGuestURL(guestId, e.currentTarget.value)}
|
||||
class="w-full min-w-[300px] px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded
|
||||
bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100
|
||||
focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
style="min-width: 300px;"
|
||||
/>
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="https://192.168.1.100:8006"
|
||||
value={meta?.customUrl || ''}
|
||||
onInput={(e) => updateGuestURL(guestId, e.currentTarget.value)}
|
||||
class={`w-full min-w-[300px] px-2 py-1 text-sm border rounded
|
||||
bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100
|
||||
focus:ring-2 focus:border-transparent ${
|
||||
urlError
|
||||
? 'border-red-500 dark:border-red-400 focus:ring-red-500'
|
||||
: 'border-gray-300 dark:border-gray-600 focus:ring-blue-500'
|
||||
}`}
|
||||
style="min-width: 300px;"
|
||||
/>
|
||||
<Show when={urlError}>
|
||||
<div class="text-xs text-red-600 dark:text-red-400 mt-1">
|
||||
{urlError}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</td>
|
||||
<td class="p-1 px-2">
|
||||
<div class="flex items-center gap-2">
|
||||
|
||||
@@ -185,7 +185,24 @@ class ApiClient {
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`API request failed: ${response.status} ${text}`);
|
||||
// Try to extract just the error message without HTTP status codes
|
||||
let errorMessage = text;
|
||||
|
||||
// If it looks like an HTML error page, try to extract the message
|
||||
if (text.includes('<pre>') && text.includes('</pre>')) {
|
||||
const match = text.match(/<pre>(.*?)<\/pre>/s);
|
||||
if (match) errorMessage = match[1];
|
||||
}
|
||||
|
||||
// If the backend sent a plain text error, use it directly
|
||||
if (!text.includes('<') && text.length < 200) {
|
||||
errorMessage = text;
|
||||
} else if (text.length > 200) {
|
||||
// For long responses, just use a generic message
|
||||
errorMessage = `Request failed with status ${response.status}`;
|
||||
}
|
||||
|
||||
throw new Error(errorMessage || `Request failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
|
||||
@@ -3,6 +3,7 @@ package api
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||
@@ -90,9 +91,28 @@ func (h *GuestMetadataHandler) HandleUpdateMetadata(w http.ResponseWriter, r *ht
|
||||
|
||||
// Validate URL if provided
|
||||
if meta.CustomURL != "" {
|
||||
// Basic URL validation - just check it starts with http:// or https://
|
||||
if !strings.HasPrefix(meta.CustomURL, "http://") && !strings.HasPrefix(meta.CustomURL, "https://") {
|
||||
http.Error(w, "Custom URL must start with http:// or https://", http.StatusBadRequest)
|
||||
// Parse and validate the URL
|
||||
parsedURL, err := url.Parse(meta.CustomURL)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid URL format: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Check scheme
|
||||
if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
|
||||
http.Error(w, "URL must use http:// or https:// scheme", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Check host is present and valid
|
||||
if parsedURL.Host == "" {
|
||||
http.Error(w, "Invalid URL: missing host/domain (e.g., use https://192.168.1.100:8006 or https://emby.local)", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Check for incomplete URLs like "https://emby."
|
||||
if strings.HasSuffix(parsedURL.Host, ".") && !strings.Contains(parsedURL.Host, "..") {
|
||||
http.Error(w, "Incomplete URL: '"+meta.CustomURL+"' - please enter a complete domain or IP address", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user