Feature: System settings in DB - configurable backend URLs and API key
This commit is contained in:
parent
a64ee3b944
commit
5b503e6dba
@ -59,6 +59,10 @@ func (h *Handler) SetupRoutes(mux *http.ServeMux, hub *ws.Hub) {
|
|||||||
mux.HandleFunc("POST /api/v1/apikeys", h.createAPIKey)
|
mux.HandleFunc("POST /api/v1/apikeys", h.createAPIKey)
|
||||||
mux.HandleFunc("DELETE /api/v1/apikeys/{id}", h.deleteAPIKey)
|
mux.HandleFunc("DELETE /api/v1/apikeys/{id}", h.deleteAPIKey)
|
||||||
|
|
||||||
|
// System Settings
|
||||||
|
mux.HandleFunc("GET /api/v1/settings", h.getSettings)
|
||||||
|
mux.HandleFunc("PUT /api/v1/settings", h.updateSettings)
|
||||||
|
|
||||||
// Audit-Log
|
// Audit-Log
|
||||||
mux.HandleFunc("GET /api/v1/audit", h.getAuditLogs)
|
mux.HandleFunc("GET /api/v1/audit", h.getAuditLogs)
|
||||||
|
|
||||||
|
|||||||
58
backend/api/settings.go
Normal file
58
backend/api/settings.go
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GET /api/v1/settings
|
||||||
|
func (h *Handler) getSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
|
settings, err := h.db.GetSettings()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Settings laden fehlgeschlagen")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{"data": settings})
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT /api/v1/settings (JWT only - checked via context)
|
||||||
|
func (h *Handler) updateSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// JWT-only: check that user context exists
|
||||||
|
claims := r.Context().Value(UserContextKey)
|
||||||
|
if claims == nil {
|
||||||
|
writeError(w, http.StatusForbidden, "Nur mit JWT-Authentifizierung erlaubt")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var body map[string]string
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "Ungueltige Anfrage")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
allowedKeys := map[string]bool{
|
||||||
|
"backend_url_internal": true,
|
||||||
|
"backend_url_external": true,
|
||||||
|
"api_key": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
var changed []string
|
||||||
|
for k, v := range body {
|
||||||
|
if !allowedKeys[k] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := h.db.SetSetting(k, v); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, fmt.Sprintf("Setting '%s' speichern fehlgeschlagen", k))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
changed = append(changed, k)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(changed) > 0 {
|
||||||
|
h.auditLog(r, "settings.update", "settings", "", "", "Geaendert: "+strings.Join(changed, ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"message": "Settings aktualisiert"})
|
||||||
|
}
|
||||||
@ -216,6 +216,17 @@ func (d *Database) migrate() error {
|
|||||||
)`)
|
)`)
|
||||||
d.db.Exec(`CREATE INDEX IF NOT EXISTS idx_audit_log_timestamp ON audit_log(timestamp DESC)`)
|
d.db.Exec(`CREATE INDEX IF NOT EXISTS idx_audit_log_timestamp ON audit_log(timestamp DESC)`)
|
||||||
|
|
||||||
|
// System Settings Tabelle
|
||||||
|
d.db.Exec(`CREATE TABLE IF NOT EXISTS system_settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
)`)
|
||||||
|
// Default-Keys anlegen (leere Werte)
|
||||||
|
for _, k := range []string{"backend_url_internal", "backend_url_external", "api_key"} {
|
||||||
|
d.db.Exec(`INSERT INTO system_settings (key, value) VALUES ($1, '') ON CONFLICT DO NOTHING`, k)
|
||||||
|
}
|
||||||
|
|
||||||
// Retention Policy (90 Tage)
|
// Retention Policy (90 Tage)
|
||||||
d.setupRetention()
|
d.setupRetention()
|
||||||
|
|
||||||
@ -1197,6 +1208,42 @@ func (d *Database) GetAuditLogs(limit int, offset int, action string, agentID st
|
|||||||
return entries, total, rows.Err()
|
return entries, total, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- System Settings ---
|
||||||
|
|
||||||
|
func (d *Database) GetSettings() (map[string]string, error) {
|
||||||
|
rows, err := d.db.Query("SELECT key, value FROM system_settings ORDER BY key")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
settings := make(map[string]string)
|
||||||
|
for rows.Next() {
|
||||||
|
var k, v string
|
||||||
|
if err := rows.Scan(&k, &v); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
settings[k] = v
|
||||||
|
}
|
||||||
|
return settings, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Database) GetSetting(key string) (string, error) {
|
||||||
|
var value string
|
||||||
|
err := d.db.QueryRow("SELECT value FROM system_settings WHERE key = $1", key).Scan(&value)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Database) SetSetting(key, value string) error {
|
||||||
|
_, err := d.db.Exec(`
|
||||||
|
INSERT INTO system_settings (key, value, updated_at) VALUES ($1, $2, NOW())
|
||||||
|
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
|
||||||
|
`, key, value)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func (d *Database) MigrateConfigAPIKeys(configKeys []string) {
|
func (d *Database) MigrateConfigAPIKeys(configKeys []string) {
|
||||||
for _, key := range configKeys {
|
for _, key := range configKeys {
|
||||||
var exists bool
|
var exists bool
|
||||||
|
|||||||
@ -236,6 +236,15 @@ class ApiClient {
|
|||||||
return this.delete(`/api/v1/apikeys/${id}`)
|
return this.delete(`/api/v1/apikeys/${id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// System Settings
|
||||||
|
getSettings() {
|
||||||
|
return this.get('/api/v1/settings')
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSettings(data) {
|
||||||
|
return this.put('/api/v1/settings', data)
|
||||||
|
}
|
||||||
|
|
||||||
// Audit Log
|
// Audit Log
|
||||||
getAuditLogs(params = {}) {
|
getAuditLogs(params = {}) {
|
||||||
const q = new URLSearchParams()
|
const q = new URLSearchParams()
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'
|
|||||||
import api from '../api/client'
|
import api from '../api/client'
|
||||||
import StatusBadge from './StatusBadge'
|
import StatusBadge from './StatusBadge'
|
||||||
import { copyToClipboard } from '../utils/clipboard'
|
import { copyToClipboard } from '../utils/clipboard'
|
||||||
import { BACKEND_HOST } from '../config'
|
import { useSettingsStore } from '../stores/settings'
|
||||||
import {
|
import {
|
||||||
X, Cpu, MemoryStick, HardDrive, Clock, Network, Shield, Route,
|
X, Cpu, MemoryStick, HardDrive, Clock, Network, Shield, Route,
|
||||||
Globe, Key, Download, Terminal, Wifi, Search, Plus, Trash2, Copy, Check, ExternalLink, Unplug, Plug, MonitorSmartphone,
|
Globe, Key, Download, Terminal, Wifi, Search, Plus, Trash2, Copy, Check, ExternalLink, Unplug, Plug, MonitorSmartphone,
|
||||||
@ -572,7 +572,7 @@ function TunnelTab({ agentId, agent }) {
|
|||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [sshCopied, setSshCopied] = useState(null)
|
const [sshCopied, setSshCopied] = useState(null)
|
||||||
|
|
||||||
const backendHost = BACKEND_HOST
|
const backendHost = useSettingsStore.getState().getBackendHost()
|
||||||
|
|
||||||
const loadTunnels = () => {
|
const loadTunnels = () => {
|
||||||
api.getTunnels(agentId)
|
api.getTunnels(agentId)
|
||||||
|
|||||||
@ -1,11 +1,14 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Copy, Check, Terminal, Monitor } from 'lucide-react'
|
import { Copy, Check, Terminal, Monitor } from 'lucide-react'
|
||||||
import { copyToClipboard } from '../utils/clipboard'
|
import { copyToClipboard } from '../utils/clipboard'
|
||||||
import { BACKEND_URL, API_KEY } from '../config'
|
import { useSettingsStore } from '../stores/settings'
|
||||||
|
|
||||||
export default function InstallGuide({ agentName, platform: initialPlatform }) {
|
export default function InstallGuide({ agentName, platform: initialPlatform }) {
|
||||||
const [platform, setPlatform] = useState(initialPlatform || 'freebsd')
|
const [platform, setPlatform] = useState(initialPlatform || 'freebsd')
|
||||||
const [copied, setCopied] = useState(null)
|
const [copied, setCopied] = useState(null)
|
||||||
|
const { settings } = useSettingsStore()
|
||||||
|
const BACKEND_URL = settings.backend_url_external || settings.backend_url_internal || ''
|
||||||
|
const API_KEY = settings.api_key || ''
|
||||||
|
|
||||||
const copyText = (text, id) => {
|
const copyText = (text, id) => {
|
||||||
copyToClipboard(text)
|
copyToClipboard(text)
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import api from '../api/client'
|
import api from '../api/client'
|
||||||
import { BACKEND_HOST } from '../config'
|
import { useSettingsStore } from '../stores/settings'
|
||||||
import StatusBadge from './StatusBadge'
|
import StatusBadge from './StatusBadge'
|
||||||
import {
|
import {
|
||||||
X, Cpu, MemoryStick, HardDrive, Clock, Network, Server, Monitor,
|
X, Cpu, MemoryStick, HardDrive, Clock, Network, Server, Monitor,
|
||||||
@ -674,7 +674,7 @@ function TunnelTab({ agentId, agent, webguiPort = 8006 }) {
|
|||||||
const [customPort, setCustomPort] = useState('')
|
const [customPort, setCustomPort] = useState('')
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
const backendHost = BACKEND_HOST
|
const backendHost = useSettingsStore.getState().getBackendHost()
|
||||||
|
|
||||||
const loadTunnels = () => {
|
const loadTunnels = () => {
|
||||||
api.getTunnels(agentId)
|
api.getTunnels(agentId)
|
||||||
|
|||||||
@ -9,9 +9,10 @@ const PLATFORMS = [
|
|||||||
{ id: 'windows', label: 'Windows', icon: Cpu },
|
{ id: 'windows', label: 'Windows', icon: Cpu },
|
||||||
]
|
]
|
||||||
|
|
||||||
import { BACKEND_URL } from '../config'
|
import { useSettingsStore } from '../stores/settings'
|
||||||
|
|
||||||
export default function Firmware() {
|
export default function Firmware() {
|
||||||
|
const BACKEND_URL = useSettingsStore((s) => s.settings.backend_url_external || s.settings.backend_url_internal || '')
|
||||||
const [firmwareData, setFirmwareData] = useState(null)
|
const [firmwareData, setFirmwareData] = useState(null)
|
||||||
const [installerData, setInstallerData] = useState(null)
|
const [installerData, setInstallerData] = useState(null)
|
||||||
const [agents, setAgents] = useState([])
|
const [agents, setAgents] = useState([])
|
||||||
|
|||||||
@ -2,8 +2,8 @@ import { useEffect, useState } from 'react'
|
|||||||
import api from '../api/client'
|
import api from '../api/client'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
import { copyToClipboard } from '../utils/clipboard'
|
import { copyToClipboard } from '../utils/clipboard'
|
||||||
import { Plus, Trash2, KeyRound, ShieldAlert, Copy, Check, Key } from 'lucide-react'
|
import { Plus, Trash2, KeyRound, ShieldAlert, Copy, Check, Key, Save, Settings } from 'lucide-react'
|
||||||
import { BACKEND_HOST } from '../config'
|
import { useSettingsStore } from '../stores/settings'
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const [users, setUsers] = useState([])
|
const [users, setUsers] = useState([])
|
||||||
@ -204,22 +204,8 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Info */}
|
{/* System Settings */}
|
||||||
<div className="bg-gray-900 rounded-lg border border-gray-800 overflow-hidden">
|
<SystemSettingsSection />
|
||||||
<div className="px-4 py-3 border-b border-gray-800">
|
|
||||||
<span className="text-sm font-medium text-gray-300">System</span>
|
|
||||||
</div>
|
|
||||||
<div className="px-4 py-3 space-y-1 text-sm">
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-gray-500">Backend</span>
|
|
||||||
<span className="text-gray-400">{BACKEND_HOST + ':8443'}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-gray-500">Authentifizierung</span>
|
|
||||||
<span className="text-gray-400">JWT (8h Token) + API-Keys</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -375,3 +361,111 @@ function APIKeysSection() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SystemSettingsSection() {
|
||||||
|
const { settings, fetchSettings } = useSettingsStore()
|
||||||
|
const [form, setForm] = useState({})
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [msg, setMsg] = useState('')
|
||||||
|
const [copied, setCopied] = useState(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchSettings()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setForm({
|
||||||
|
backend_url_internal: settings.backend_url_internal || '',
|
||||||
|
backend_url_external: settings.backend_url_external || '',
|
||||||
|
api_key: settings.api_key || '',
|
||||||
|
})
|
||||||
|
}, [settings])
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true)
|
||||||
|
setMsg('')
|
||||||
|
try {
|
||||||
|
await api.updateSettings(form)
|
||||||
|
await fetchSettings()
|
||||||
|
setMsg('Gespeichert')
|
||||||
|
setTimeout(() => setMsg(''), 3000)
|
||||||
|
} catch (e) {
|
||||||
|
setMsg('Fehler: ' + e.message)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyText = (text, id) => {
|
||||||
|
copyToClipboard(text)
|
||||||
|
setCopied(id)
|
||||||
|
setTimeout(() => setCopied(null), 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-900 rounded-lg border border-gray-800 overflow-hidden">
|
||||||
|
<div className="px-4 py-3 border-b border-gray-800 flex items-center gap-2">
|
||||||
|
<Settings className="w-4 h-4 text-gray-500" />
|
||||||
|
<span className="text-sm font-medium text-gray-300">System-Einstellungen</span>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 py-4 space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-500">Backend URL (intern)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.backend_url_internal || ''}
|
||||||
|
onChange={(e) => setForm({ ...form, backend_url_internal: e.target.value })}
|
||||||
|
placeholder="https://192.168.85.13:8443"
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-white text-sm focus:outline-none focus:border-orange-500 mt-1"
|
||||||
|
/>
|
||||||
|
<div className="text-[10px] text-gray-600 mt-0.5">Fuer Tunnel-Zugriff und lokale Verbindungen</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-500">Backend URL (extern)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.backend_url_external || ''}
|
||||||
|
onChange={(e) => setForm({ ...form, backend_url_external: e.target.value })}
|
||||||
|
placeholder="https://example.dyndns.org:8443"
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-white text-sm focus:outline-none focus:border-orange-500 mt-1"
|
||||||
|
/>
|
||||||
|
<div className="text-[10px] text-gray-600 mt-0.5">Fuer Remote-Firewalls in der Installationsanleitung</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-500">API-Key</label>
|
||||||
|
<div className="flex gap-2 mt-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.api_key || ''}
|
||||||
|
onChange={(e) => setForm({ ...form, api_key: e.target.value })}
|
||||||
|
className="flex-1 bg-gray-800 border border-gray-700 rounded px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-orange-500"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => copyText(form.api_key, 'sys-apikey')}
|
||||||
|
className="px-3 py-2 bg-gray-800 hover:bg-gray-700 rounded text-gray-400 transition-colors"
|
||||||
|
title="Kopieren"
|
||||||
|
>
|
||||||
|
{copied === 'sys-apikey' ? <Check className="w-4 h-4 text-green-400" /> : <Copy className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-gray-600 mt-0.5">Wird in Installationsanleitungen angezeigt</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-orange-600 hover:bg-orange-700 disabled:bg-gray-700 disabled:text-gray-500 rounded text-sm text-white transition-colors"
|
||||||
|
>
|
||||||
|
<Save className="w-4 h-4" />
|
||||||
|
{saving ? 'Speichere...' : 'Speichern'}
|
||||||
|
</button>
|
||||||
|
{msg && (
|
||||||
|
<span className={`text-sm ${msg.startsWith('Fehler') ? 'text-red-400' : 'text-green-400'}`}>
|
||||||
|
{msg}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import api from '../api/client'
|
import api from '../api/client'
|
||||||
|
import { useSettingsStore } from './settings'
|
||||||
|
|
||||||
export const useAuthStore = create((set) => ({
|
export const useAuthStore = create((set) => ({
|
||||||
user: null,
|
user: null,
|
||||||
@ -9,6 +10,8 @@ export const useAuthStore = create((set) => ({
|
|||||||
login: async (username, password) => {
|
login: async (username, password) => {
|
||||||
const data = await api.login(username, password)
|
const data = await api.login(username, password)
|
||||||
set({ user: data.user, isAuthenticated: true })
|
set({ user: data.user, isAuthenticated: true })
|
||||||
|
// Load system settings after login
|
||||||
|
useSettingsStore.getState().fetchSettings()
|
||||||
return data
|
return data
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -25,6 +28,7 @@ export const useAuthStore = create((set) => ({
|
|||||||
try {
|
try {
|
||||||
const data = await api.me()
|
const data = await api.me()
|
||||||
set({ user: data.user || data, isAuthenticated: true, loading: false })
|
set({ user: data.user || data, isAuthenticated: true, loading: false })
|
||||||
|
useSettingsStore.getState().fetchSettings()
|
||||||
} catch {
|
} catch {
|
||||||
api.logout()
|
api.logout()
|
||||||
set({ user: null, isAuthenticated: false, loading: false })
|
set({ user: null, isAuthenticated: false, loading: false })
|
||||||
|
|||||||
30
frontend/src/stores/settings.js
Normal file
30
frontend/src/stores/settings.js
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
import api from '../api/client'
|
||||||
|
|
||||||
|
export const useSettingsStore = create((set, get) => ({
|
||||||
|
settings: {},
|
||||||
|
loaded: false,
|
||||||
|
|
||||||
|
fetchSettings: async () => {
|
||||||
|
try {
|
||||||
|
const resp = await api.getSettings()
|
||||||
|
set({ settings: resp.data || {}, loaded: true })
|
||||||
|
} catch {
|
||||||
|
// ignore - settings not available before login
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getSetting: (key, fallback = '') => {
|
||||||
|
return get().settings[key] || fallback
|
||||||
|
},
|
||||||
|
|
||||||
|
getBackendHost: () => {
|
||||||
|
const url = get().settings.backend_url_internal || ''
|
||||||
|
try {
|
||||||
|
return new URL(url).hostname
|
||||||
|
} catch {
|
||||||
|
// fallback: strip protocol and port manually
|
||||||
|
return url.replace(/^https?:\/\//, '').replace(/:\d+$/, '')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}))
|
||||||
Loading…
x
Reference in New Issue
Block a user