feat: add domain management functionality with CRUD operations and integrate into UI

This commit is contained in:
Wikid82
2025-11-21 16:15:39 -05:00
parent f6bd3ecb59
commit cf23ddb666
9 changed files with 298 additions and 47 deletions
@@ -0,0 +1,57 @@
package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
"gorm.io/gorm"
)
type DomainHandler struct {
DB *gorm.DB
}
func NewDomainHandler(db *gorm.DB) *DomainHandler {
return &DomainHandler{DB: db}
}
func (h *DomainHandler) List(c *gin.Context) {
var domains []models.Domain
if err := h.DB.Order("name asc").Find(&domains).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch domains"})
return
}
c.JSON(http.StatusOK, domains)
}
func (h *DomainHandler) Create(c *gin.Context) {
var input struct {
Name string `json:"name" binding:"required"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
domain := models.Domain{
Name: input.Name,
}
if err := h.DB.Create(&domain).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create domain"})
return
}
c.JSON(http.StatusCreated, domain)
}
func (h *DomainHandler) Delete(c *gin.Context) {
id := c.Param("id")
if err := h.DB.Where("uuid = ?", id).Delete(&models.Domain{}).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete domain"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Domain deleted"})
}
+7
View File
@@ -28,6 +28,7 @@ func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error {
&models.Setting{},
&models.ImportSession{},
&models.Notification{},
&models.Domain{},
); err != nil {
return fmt.Errorf("auto migrate: %w", err)
}
@@ -94,6 +95,12 @@ func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error {
protected.POST("/notifications/:id/read", notificationHandler.MarkAsRead)
protected.POST("/notifications/read-all", notificationHandler.MarkAllAsRead)
// Domains
domainHandler := handlers.NewDomainHandler(db)
protected.GET("/domains", domainHandler.List)
protected.POST("/domains", domainHandler.Create)
protected.DELETE("/domains/:id", domainHandler.Delete)
// Docker
dockerService, err := services.NewDockerService()
if err == nil { // Only register if Docker is available
+24
View File
@@ -0,0 +1,24 @@
package models
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
type Domain struct {
ID uint `json:"id" gorm:"primarykey"`
UUID string `json:"uuid" gorm:"uniqueIndex;not null"`
Name string `json:"name" gorm:"uniqueIndex;not null"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"`
}
func (d *Domain) BeforeCreate(tx *gorm.DB) (err error) {
if d.UUID == "" {
d.UUID = uuid.New().String()
}
return
}
+2
View File
@@ -15,6 +15,7 @@ import SystemSettings from './pages/SystemSettings'
import Account from './pages/Account'
import Backups from './pages/Backups'
import Logs from './pages/Logs'
import Domains from './pages/Domains'
import Login from './pages/Login'
import Setup from './pages/Setup'
@@ -37,6 +38,7 @@ export default function App() {
<Route index element={<Dashboard />} />
<Route path="proxy-hosts" element={<ProxyHosts />} />
<Route path="remote-servers" element={<RemoteServers />} />
<Route path="domains" element={<Domains />} />
<Route path="certificates" element={<Certificates />} />
<Route path="import" element={<ImportCaddy />} />
+22
View File
@@ -0,0 +1,22 @@
import client from './client'
export interface Domain {
id: number
uuid: string
name: string
created_at: string
}
export const getDomains = async (): Promise<Domain[]> => {
const { data } = await client.get<Domain[]>('/domains')
return data
}
export const createDomain = async (name: string): Promise<Domain> => {
const { data } = await client.post<Domain>('/domains', { name })
return data
}
export const deleteDomain = async (uuid: string): Promise<void> => {
await client.delete(`/domains/${uuid}`)
}
+1
View File
@@ -36,6 +36,7 @@ export default function Layout({ children }: LayoutProps) {
{ name: 'Dashboard', path: '/', icon: '📊' },
{ name: 'Proxy Hosts', path: '/proxy-hosts', icon: '🌐' },
{ name: 'Remote Servers', path: '/remote-servers', icon: '🖥️' },
{ name: 'Domains', path: '/domains', icon: '🌍' },
{ name: 'Certificates', path: '/certificates', icon: '🔒' },
{ name: 'Import Caddyfile', path: '/import', icon: '📥' },
{ name: 'Settings', path: '/settings/system', icon: '⚙️' },
+50 -47
View File
@@ -2,6 +2,7 @@ import { useState } from 'react'
import { CircleHelp } from 'lucide-react'
import type { ProxyHost } from '../api/proxyHosts'
import { useRemoteServers } from '../hooks/useRemoteServers'
import { useDomains } from '../hooks/useDomains'
import { useDocker } from '../hooks/useDocker'
interface ProxyHostFormProps {
@@ -27,7 +28,9 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
})
const { servers: remoteServers } = useRemoteServers()
const [connectionSource, setConnectionSource] = useState<'local' | string>('local')
const { domains } = useDomains()
const [connectionSource, setConnectionSource] = useState<'local' | 'custom' | string>('custom')
const [selectedDomain, setSelectedDomain] = useState('')
// Fetch containers based on selected source
// If 'local', host is undefined (which defaults to local socket in backend)
@@ -39,6 +42,7 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
const getDockerHostString = () => {
if (connectionSource === 'local') return undefined;
if (connectionSource === 'custom') return undefined;
const server = remoteServers.find(s => s.uuid === connectionSource);
if (!server) return undefined;
// Construct the Docker host string
@@ -63,18 +67,6 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
}
}
const handleServerSelect = (serverUuid: string) => {
const server = remoteServers.find(s => s.uuid === serverUuid)
if (server) {
setFormData({
...formData,
forward_host: server.host,
forward_port: server.port,
forward_scheme: 'http',
})
}
}
const handleContainerSelect = (containerId: string) => {
const container = dockerContainers.find(c => c.id === containerId)
if (container) {
@@ -83,11 +75,18 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
// Use the first exposed port if available, otherwise default to 80
const port = container.ports && container.ports.length > 0 ? container.ports[0].private_port : 80
let newDomainNames = formData.domain_names
if (selectedDomain) {
const subdomain = container.names[0].replace(/^\//, '')
newDomainNames = `${subdomain}.${selectedDomain}`
}
setFormData({
...formData,
forward_host: host,
forward_port: port,
forward_scheme: 'http',
domain_names: newDomainNames,
})
}
}
@@ -109,42 +108,43 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
)}
{/* Domain Names */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Domain Names (comma-separated)
</label>
<input
type="text"
required
value={formData.domain_names}
onChange={e => setFormData({ ...formData, domain_names: e.target.value })}
placeholder="example.com, www.example.com"
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Remote Server Quick Select */}
{remoteServers.length > 0 && (
<div className="space-y-4">
{domains.length > 0 && (
<div>
<label htmlFor="quick-select-server" className="block text-sm font-medium text-gray-300 mb-2">
Quick Select: Remote Server
<label htmlFor="base-domain" className="block text-sm font-medium text-gray-300 mb-2">
Base Domain (Auto-fill)
</label>
<select
id="quick-select-server"
onChange={e => handleServerSelect(e.target.value)}
id="base-domain"
value={selectedDomain}
onChange={e => setSelectedDomain(e.target.value)}
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">-- Select a server --</option>
{remoteServers.map(server => (
<option key={server.uuid} value={server.uuid}>
{server.name} ({server.host}:{server.port})
<option value="">-- Select a base domain --</option>
{domains.map(domain => (
<option key={domain.uuid} value={domain.name}>
{domain.name}
</option>
))}
</select>
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Domain Names (comma-separated)
</label>
<input
type="text"
required
value={formData.domain_names}
onChange={e => setFormData({ ...formData, domain_names: e.target.value })}
placeholder="example.com, www.example.com"
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Docker Container Quick Select */}
<div>
<label htmlFor="connection-source" className="block text-sm font-medium text-gray-300 mb-2">
@@ -156,6 +156,7 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
onChange={e => setConnectionSource(e.target.value)}
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="custom">Custom / Manual</option>
<option value="local">Local (Docker Socket)</option>
{remoteServers
.filter(s => s.provider === 'docker' && s.enabled)
@@ -176,11 +177,13 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
<select
id="quick-select-docker"
onChange={e => handleContainerSelect(e.target.value)}
disabled={dockerLoading}
disabled={dockerLoading || connectionSource === 'custom'}
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
>
<option value="">
{dockerLoading ? 'Loading containers...' : '-- Select a container --'}
{connectionSource === 'custom'
? 'Select a source to view containers'
: (dockerLoading ? 'Loading containers...' : '-- Select a container --')}
</option>
{dockerContainers.map(container => (
<option key={container.id} value={container.id}>
@@ -188,7 +191,7 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
</option>
))}
</select>
{dockerError && (
{dockerError && connectionSource !== 'custom' && (
<p className="text-xs text-red-400 mt-1">
Failed to connect: {(dockerError as Error).message}
</p>
@@ -247,7 +250,7 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
className="w-4 h-4 text-blue-600 bg-gray-900 border-gray-700 rounded focus:ring-blue-500"
/>
<span className="text-sm text-gray-300">Force SSL</span>
<div title="Redirects all HTTP traffic to HTTPS" className="text-gray-500 hover:text-gray-300 cursor-help">
<div title="Redirects all HTTP traffic to HTTPS. This ensures that all connections to your site are encrypted, preventing eavesdropping." className="text-gray-500 hover:text-gray-300 cursor-help">
<CircleHelp size={14} />
</div>
</label>
@@ -259,7 +262,7 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
className="w-4 h-4 text-blue-600 bg-gray-900 border-gray-700 rounded focus:ring-blue-500"
/>
<span className="text-sm text-gray-300">HTTP/2 Support</span>
<div title="Enables HTTP/2 support for better performance" className="text-gray-500 hover:text-gray-300 cursor-help">
<div title="Enables the HTTP/2 protocol. This can improve performance by allowing multiple requests to be sent over a single connection." className="text-gray-500 hover:text-gray-300 cursor-help">
<CircleHelp size={14} />
</div>
</label>
@@ -271,7 +274,7 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
className="w-4 h-4 text-blue-600 bg-gray-900 border-gray-700 rounded focus:ring-blue-500"
/>
<span className="text-sm text-gray-300">HSTS Enabled</span>
<div title="Enables HTTP Strict Transport Security (HSTS)" className="text-gray-500 hover:text-gray-300 cursor-help">
<div title="Enables HTTP Strict Transport Security. This tells browsers to ONLY connect to your site using HTTPS for a specified period, preventing downgrade attacks." className="text-gray-500 hover:text-gray-300 cursor-help">
<CircleHelp size={14} />
</div>
</label>
@@ -283,7 +286,7 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
className="w-4 h-4 text-blue-600 bg-gray-900 border-gray-700 rounded focus:ring-blue-500"
/>
<span className="text-sm text-gray-300">HSTS Subdomains</span>
<div title="Applies HSTS to all subdomains" className="text-gray-500 hover:text-gray-300 cursor-help">
<div title="Applies the HSTS policy to all subdomains as well. Use this only if you are sure all your subdomains support HTTPS." className="text-gray-500 hover:text-gray-300 cursor-help">
<CircleHelp size={14} />
</div>
</label>
@@ -295,7 +298,7 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
className="w-4 h-4 text-blue-600 bg-gray-900 border-gray-700 rounded focus:ring-blue-500"
/>
<span className="text-sm text-gray-300">Block Exploits</span>
<div title="Blocks common exploit attempts (XSS, SQLi, etc.)" className="text-gray-500 hover:text-gray-300 cursor-help">
<div title="Blocks common web attacks like SQL Injection (SQLi) and Cross-Site Scripting (XSS) by filtering malicious request patterns." className="text-gray-500 hover:text-gray-300 cursor-help">
<CircleHelp size={14} />
</div>
</label>
@@ -307,7 +310,7 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
className="w-4 h-4 text-blue-600 bg-gray-900 border-gray-700 rounded focus:ring-blue-500"
/>
<span className="text-sm text-gray-300">Websockets Support</span>
<div title="Enables WebSocket support for real-time applications" className="text-gray-500 hover:text-gray-300 cursor-help">
<div title="Allows WebSocket connections to pass through the proxy. This is required for real-time applications like chat apps, notifications, or live dashboards." className="text-gray-500 hover:text-gray-300 cursor-help">
<CircleHelp size={14} />
</div>
</label>
+33
View File
@@ -0,0 +1,33 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import * as api from '../api/domains'
export function useDomains() {
const queryClient = useQueryClient()
const { data: domains = [], isLoading, error } = useQuery({
queryKey: ['domains'],
queryFn: api.getDomains,
})
const createMutation = useMutation({
mutationFn: api.createDomain,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['domains'] })
},
})
const deleteMutation = useMutation({
mutationFn: api.deleteDomain,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['domains'] })
},
})
return {
domains,
isLoading,
error,
createDomain: createMutation.mutateAsync,
deleteDomain: deleteMutation.mutateAsync,
}
}
+102
View File
@@ -0,0 +1,102 @@
import { useState } from 'react'
import { useDomains } from '../hooks/useDomains'
import { Trash2, Plus, Globe } from 'lucide-react'
export default function Domains() {
const { domains, isLoading, error, createDomain, deleteDomain } = useDomains()
const [newDomain, setNewDomain] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const handleAdd = async (e: React.FormEvent) => {
e.preventDefault()
if (!newDomain.trim()) return
setIsSubmitting(true)
try {
await createDomain(newDomain)
setNewDomain('')
} catch (err) {
alert('Failed to create domain')
} finally {
setIsSubmitting(false)
}
}
const handleDelete = async (uuid: string) => {
if (confirm('Are you sure you want to delete this domain?')) {
try {
await deleteDomain(uuid)
} catch (err) {
alert('Failed to delete domain')
}
}
}
if (isLoading) return <div className="p-8 text-white">Loading...</div>
if (error) return <div className="p-8 text-red-400">Error loading domains</div>
return (
<div className="p-8">
<div className="flex items-center justify-between mb-6">
<h1 className="text-3xl font-bold text-white">Domains</h1>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{/* Add New Domain Card */}
<div className="bg-dark-card border border-gray-800 rounded-lg p-6">
<h3 className="text-lg font-medium text-white mb-4 flex items-center gap-2">
<Plus size={20} />
Add Domain
</h3>
<form onSubmit={handleAdd} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-400 mb-1">
Domain Name
</label>
<input
type="text"
value={newDomain}
onChange={(e) => setNewDomain(e.target.value)}
placeholder="example.com"
className="w-full bg-gray-900 border border-gray-700 rounded px-3 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<button
type="submit"
disabled={isSubmitting || !newDomain.trim()}
className="w-full bg-blue-600 hover:bg-blue-700 text-white rounded py-2 font-medium transition-colors disabled:opacity-50"
>
{isSubmitting ? 'Adding...' : 'Add Domain'}
</button>
</form>
</div>
{/* Domain List */}
{domains.map((domain) => (
<div key={domain.uuid} className="bg-dark-card border border-gray-800 rounded-lg p-6 flex flex-col justify-between">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-900/30 rounded-lg text-blue-400">
<Globe size={24} />
</div>
<div>
<h3 className="text-lg font-medium text-white">{domain.name}</h3>
<p className="text-sm text-gray-500">
Added {new Date(domain.created_at).toLocaleDateString()}
</p>
</div>
</div>
<button
onClick={() => handleDelete(domain.uuid)}
className="text-gray-500 hover:text-red-400 transition-colors"
title="Delete Domain"
>
<Trash2 size={20} />
</button>
</div>
</div>
))}
</div>
</div>
)
}