feat: add managed domain functionality with matching certificate feature

- Implemented managed domain CRUD operations in the backend with appropriate service and controller logic.
- Added matching logic for managed domains to automatically suggest certificates based on domain input.
- Enhanced the frontend to support managed domain management, including form handling and displaying match results.
- Updated header component to include new managed domain routes.
- Added tests for managed domain lifecycle and matching logic.
This commit is contained in:
ryan
2026-03-10 13:07:16 +08:00
parent 2185326f2f
commit 7be2da0c19
12 changed files with 1343 additions and 456 deletions
+3 -2
View File
@@ -1,9 +1,10 @@
package common
import (
"github.com/google/uuid"
"sync"
"time"
"github.com/google/uuid"
)
var StartTime = time.Now().Unix() // unit: second
@@ -16,7 +17,7 @@ var HomePageLink = ""
// Any options with "Secret", "Token" in its key won't be return by GetOptions
var SessionSecret = uuid.New().String()
var SQLitePath = "gin-template.db"
var SQLitePath = "atsflare.db"
var OptionMap map[string]string
var OptionMapRWMutex sync.RWMutex
+121
View File
@@ -0,0 +1,121 @@
package controller
import (
"encoding/json"
"gin-template/service"
"github.com/gin-gonic/gin"
"net/http"
"strconv"
"strings"
)
func GetManagedDomains(c *gin.Context) {
domains, err := service.ListManagedDomains()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": domains,
})
}
func CreateManagedDomain(c *gin.Context) {
var input service.ManagedDomainInput
if err := json.NewDecoder(c.Request.Body).Decode(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "无效的参数",
})
return
}
domain, err := service.CreateManagedDomain(input)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": domain,
})
}
func UpdateManagedDomain(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "无效的参数",
})
return
}
var input service.ManagedDomainInput
if err = json.NewDecoder(c.Request.Body).Decode(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "无效的参数",
})
return
}
domain, err := service.UpdateManagedDomain(uint(id), input)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": domain,
})
}
func DeleteManagedDomain(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "无效的参数",
})
return
}
if err = service.DeleteManagedDomain(uint(id)); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
})
}
func MatchManagedDomainCertificate(c *gin.Context) {
domain := strings.TrimSpace(c.Query("domain"))
result, err := service.MatchManagedDomainCertificate(domain)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": result,
})
}
+5 -1
View File
@@ -1,8 +1,8 @@
package model
import (
"github.com/glebarez/sqlite"
"gin-template/common"
"github.com/glebarez/sqlite"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"os"
@@ -84,6 +84,10 @@ func InitDB() (err error) {
if err != nil {
return err
}
err = db.AutoMigrate(&ManagedDomain{})
if err != nil {
return err
}
err = createRootAccountIfNeed()
return err
} else {
+41
View File
@@ -0,0 +1,41 @@
package model
import "time"
type ManagedDomain struct {
ID uint `json:"id" gorm:"primaryKey"`
Domain string `json:"domain" gorm:"uniqueIndex;size:255;not null"`
CertID *uint `json:"cert_id"`
Enabled bool `json:"enabled" gorm:"not null;default:true"`
Remark string `json:"remark" gorm:"size:255"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func ListManagedDomains() (domains []*ManagedDomain, err error) {
err = DB.Order("id desc").Find(&domains).Error
return domains, err
}
func ListEnabledManagedDomainsWithCertificate() (domains []*ManagedDomain, err error) {
err = DB.Where("enabled = ? AND cert_id IS NOT NULL", true).Order("id desc").Find(&domains).Error
return domains, err
}
func GetManagedDomainByID(id uint) (*ManagedDomain, error) {
domain := &ManagedDomain{}
err := DB.First(domain, id).Error
return domain, err
}
func (domain *ManagedDomain) Insert() error {
return DB.Create(domain).Error
}
func (domain *ManagedDomain) Update() error {
return DB.Save(domain).Error
}
func (domain *ManagedDomain) Delete() error {
return DB.Delete(domain).Error
}
+9
View File
@@ -70,6 +70,15 @@ func SetApiRouter(router *gin.Engine) {
proxyRoute.PUT("/:id", controller.UpdateProxyRoute)
proxyRoute.DELETE("/:id", controller.DeleteProxyRoute)
}
managedDomainRoute := apiRouter.Group("/managed-domains")
managedDomainRoute.Use(middleware.AdminAuth())
{
managedDomainRoute.GET("/", controller.GetManagedDomains)
managedDomainRoute.GET("/match", controller.MatchManagedDomainCertificate)
managedDomainRoute.POST("/", controller.CreateManagedDomain)
managedDomainRoute.PUT("/:id", controller.UpdateManagedDomain)
managedDomainRoute.DELETE("/:id", controller.DeleteManagedDomain)
}
tlsCertificateRoute := apiRouter.Group("/tls-certificates")
tlsCertificateRoute.Use(middleware.AdminAuth())
{
@@ -0,0 +1,112 @@
package router_test
import (
"gin-template/common"
"gin-template/router"
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/cookie"
"github.com/gin-gonic/gin"
"net/http"
"testing"
)
func TestPhase2ManagedDomainLifecycle(t *testing.T) {
gin.SetMode(gin.TestMode)
common.RedisEnabled = false
setupTestDB(t)
engine := gin.New()
engine.Use(sessions.Sessions("session", cookie.NewStore([]byte("test-secret"))))
router.SetApiRouter(engine)
token := prepareRootToken(t)
wildcardCertPEM, wildcardKeyPEM := generateCertificatePairForRouterTest(t, []string{"*.example.com"})
exactCertPEM, exactKeyPEM := generateCertificatePairForRouterTest(t, []string{"api.example.com"})
wildcardResp := performJSONRequest(t, engine, token, http.MethodPost, "/api/tls-certificates/", map[string]any{
"name": "wildcard-cert",
"cert_pem": wildcardCertPEM,
"key_pem": wildcardKeyPEM,
})
var wildcardCertificate map[string]any
decodeResponseData(t, wildcardResp, &wildcardCertificate)
exactResp := performJSONRequest(t, engine, token, http.MethodPost, "/api/tls-certificates/", map[string]any{
"name": "exact-cert",
"cert_pem": exactCertPEM,
"key_pem": exactKeyPEM,
})
var exactCertificate map[string]any
decodeResponseData(t, exactResp, &exactCertificate)
wildcardID := uint(wildcardCertificate["id"].(float64))
exactID := uint(exactCertificate["id"].(float64))
createWildcard := performJSONRequest(t, engine, token, http.MethodPost, "/api/managed-domains/", map[string]any{
"domain": "*.example.com",
"cert_id": wildcardID,
"enabled": true,
"remark": "wildcard binding",
})
var wildcardDomain map[string]any
decodeResponseData(t, createWildcard, &wildcardDomain)
createExact := performJSONRequest(t, engine, token, http.MethodPost, "/api/managed-domains/", map[string]any{
"domain": "api.example.com",
"cert_id": exactID,
"enabled": true,
"remark": "exact binding",
})
var exactDomain map[string]any
decodeResponseData(t, createExact, &exactDomain)
listResp := performJSONRequest(t, engine, token, http.MethodGet, "/api/managed-domains/", nil)
var domains []map[string]any
decodeResponseData(t, listResp, &domains)
if len(domains) != 2 {
t.Fatalf("expected 2 managed domains, got %d", len(domains))
}
matchResp := performJSONRequest(t, engine, token, http.MethodGet, "/api/managed-domains/match?domain=api.example.com", nil)
var matchResult map[string]any
decodeResponseData(t, matchResp, &matchResult)
if matched, ok := matchResult["matched"].(bool); !ok || !matched {
t.Fatalf("expected exact domain to be matched, got %#v", matchResult)
}
candidate, ok := matchResult["candidate"].(map[string]any)
if !ok {
t.Fatalf("expected candidate payload, got %#v", matchResult["candidate"])
}
if candidate["match_type"] != "exact" {
t.Fatalf("expected exact match type, got %#v", candidate["match_type"])
}
if uint(candidate["certificate_id"].(float64)) != exactID {
t.Fatalf("expected exact certificate id %d, got %#v", exactID, candidate["certificate_id"])
}
updateResp := performJSONRequest(t, engine, token, http.MethodPut, "/api/managed-domains/"+toString(uint(exactDomain["id"].(float64))), map[string]any{
"domain": "api.example.com",
"cert_id": exactID,
"enabled": false,
"remark": "disabled exact binding",
})
decodeResponseData(t, updateResp, &exactDomain)
matchResp = performJSONRequest(t, engine, token, http.MethodGet, "/api/managed-domains/match?domain=api.example.com", nil)
decodeResponseData(t, matchResp, &matchResult)
candidate, ok = matchResult["candidate"].(map[string]any)
if !ok {
t.Fatalf("expected wildcard fallback candidate, got %#v", matchResult["candidate"])
}
if candidate["match_type"] != "wildcard" {
t.Fatalf("expected wildcard fallback, got %#v", candidate["match_type"])
}
if uint(candidate["certificate_id"].(float64)) != wildcardID {
t.Fatalf("expected wildcard certificate id %d, got %#v", wildcardID, candidate["certificate_id"])
}
deleteResp := performJSONRequest(t, engine, token, http.MethodDelete, "/api/managed-domains/"+toString(uint(wildcardDomain["id"].(float64))), nil)
if !deleteResp.Success {
t.Fatalf("expected delete success, got %s", deleteResp.Message)
}
}
+227
View File
@@ -0,0 +1,227 @@
package service
import (
"errors"
"fmt"
"gin-template/model"
"sort"
"strings"
"unicode"
)
const (
ManagedDomainMatchTypeExact = "exact"
ManagedDomainMatchTypeWildcard = "wildcard"
)
type ManagedDomainInput struct {
Domain string `json:"domain"`
CertID *uint `json:"cert_id"`
Enabled bool `json:"enabled"`
Remark string `json:"remark"`
}
type ManagedDomainMatchCandidate struct {
ManagedDomainID uint `json:"managed_domain_id"`
Domain string `json:"domain"`
MatchType string `json:"match_type"`
CertificateID uint `json:"certificate_id"`
CertificateName string `json:"certificate_name"`
}
type ManagedDomainMatchResult struct {
Domain string `json:"domain"`
Matched bool `json:"matched"`
Candidate *ManagedDomainMatchCandidate `json:"candidate,omitempty"`
Candidates []ManagedDomainMatchCandidate `json:"candidates"`
}
func ListManagedDomains() ([]*model.ManagedDomain, error) {
return model.ListManagedDomains()
}
func CreateManagedDomain(input ManagedDomainInput) (*model.ManagedDomain, error) {
domain, err := buildManagedDomain(nil, input)
if err != nil {
return nil, err
}
if err = domain.Insert(); err != nil {
if isUniqueConstraintError(err) {
return nil, errors.New("域名已存在")
}
return nil, err
}
return domain, nil
}
func UpdateManagedDomain(id uint, input ManagedDomainInput) (*model.ManagedDomain, error) {
domain, err := model.GetManagedDomainByID(id)
if err != nil {
return nil, err
}
domain, err = buildManagedDomain(domain, input)
if err != nil {
return nil, err
}
if err = domain.Update(); err != nil {
if isUniqueConstraintError(err) {
return nil, errors.New("域名已存在")
}
return nil, err
}
return domain, nil
}
func DeleteManagedDomain(id uint) error {
domain, err := model.GetManagedDomainByID(id)
if err != nil {
return err
}
return domain.Delete()
}
func MatchManagedDomainCertificate(rawDomain string) (*ManagedDomainMatchResult, error) {
domain := normalizeManagedDomain(rawDomain)
if err := validateManagedDomainPattern(domain); err != nil {
return nil, err
}
managedDomains, err := model.ListEnabledManagedDomainsWithCertificate()
if err != nil {
return nil, err
}
candidates := make([]ManagedDomainMatchCandidate, 0)
for _, item := range managedDomains {
if item.CertID == nil || *item.CertID == 0 {
continue
}
matchType := detectManagedDomainMatchType(item.Domain, domain)
if matchType == "" {
continue
}
certificate, err := model.GetTLSCertificateByID(*item.CertID)
if err != nil {
return nil, fmt.Errorf("托管域名 %s 关联证书不存在", item.Domain)
}
candidates = append(candidates, ManagedDomainMatchCandidate{
ManagedDomainID: item.ID,
Domain: item.Domain,
MatchType: matchType,
CertificateID: certificate.ID,
CertificateName: certificate.Name,
})
}
sortManagedDomainCandidates(candidates)
result := &ManagedDomainMatchResult{
Domain: domain,
Matched: len(candidates) > 0,
Candidates: candidates,
}
if len(candidates) > 0 {
candidate := candidates[0]
result.Candidate = &candidate
}
return result, nil
}
func buildManagedDomain(existing *model.ManagedDomain, input ManagedDomainInput) (*model.ManagedDomain, error) {
domain := normalizeManagedDomain(input.Domain)
remark := strings.TrimSpace(input.Remark)
if err := validateManagedDomainPattern(domain); err != nil {
return nil, err
}
if input.CertID != nil && *input.CertID != 0 {
if _, err := model.GetTLSCertificateByID(*input.CertID); err != nil {
return nil, errors.New("所选证书不存在")
}
} else {
input.CertID = nil
}
if existing == nil {
existing = &model.ManagedDomain{}
}
existing.Domain = domain
existing.CertID = input.CertID
existing.Enabled = input.Enabled
existing.Remark = remark
return existing, nil
}
func normalizeManagedDomain(domain string) string {
return strings.ToLower(strings.TrimSpace(domain))
}
func validateManagedDomainPattern(domain string) error {
if domain == "" {
return errors.New("域名不能为空")
}
if strings.Contains(domain, "://") || strings.Contains(domain, "/") {
return errors.New("域名格式不合法")
}
if strings.Contains(domain, "*") {
if !strings.HasPrefix(domain, "*.") || strings.Count(domain, "*") != 1 {
return errors.New("通配符域名仅支持 *.example.com 格式")
}
return validateHostname(strings.TrimPrefix(domain, "*."))
}
return validateHostname(domain)
}
func validateHostname(domain string) error {
if domain == "" {
return errors.New("域名不能为空")
}
if len(domain) > 253 {
return errors.New("域名格式不合法")
}
labels := strings.Split(domain, ".")
if len(labels) < 2 {
return errors.New("域名格式不合法")
}
for _, label := range labels {
if len(label) == 0 || len(label) > 63 {
return errors.New("域名格式不合法")
}
if label[0] == '-' || label[len(label)-1] == '-' {
return errors.New("域名格式不合法")
}
for _, r := range label {
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-' {
continue
}
return errors.New("域名格式不合法")
}
}
return nil
}
func detectManagedDomainMatchType(pattern string, domain string) string {
if pattern == domain {
return ManagedDomainMatchTypeExact
}
if !strings.HasPrefix(pattern, "*.") {
return ""
}
suffix := strings.TrimPrefix(pattern, "*.")
if !strings.HasSuffix(domain, "."+suffix) {
return ""
}
prefix := strings.TrimSuffix(domain, "."+suffix)
if prefix == "" || strings.Contains(prefix, ".") {
return ""
}
return ManagedDomainMatchTypeWildcard
}
func sortManagedDomainCandidates(candidates []ManagedDomainMatchCandidate) {
sort.Slice(candidates, func(i int, j int) bool {
left := candidates[i]
right := candidates[j]
if left.MatchType != right.MatchType {
return left.MatchType == ManagedDomainMatchTypeExact
}
if len(left.Domain) != len(right.Domain) {
return len(left.Domain) > len(right.Domain)
}
return left.ManagedDomainID < right.ManagedDomainID
})
}
@@ -0,0 +1,109 @@
package service
import "testing"
func TestMatchManagedDomainCertificatePrefersExactMatch(t *testing.T) {
setupServiceTestDB(t)
wildcardCertPEM, wildcardKeyPEM := generateCertificatePair(t, []string{"*.example.com"})
wildcardCert, err := CreateTLSCertificate(TLSCertificateInput{
Name: "wildcard-cert",
CertPEM: wildcardCertPEM,
KeyPEM: wildcardKeyPEM,
})
if err != nil {
t.Fatalf("failed to create wildcard certificate: %v", err)
}
exactCertPEM, exactKeyPEM := generateCertificatePair(t, []string{"api.example.com"})
exactCert, err := CreateTLSCertificate(TLSCertificateInput{
Name: "exact-cert",
CertPEM: exactCertPEM,
KeyPEM: exactKeyPEM,
})
if err != nil {
t.Fatalf("failed to create exact certificate: %v", err)
}
if _, err = CreateManagedDomain(ManagedDomainInput{
Domain: "*.example.com",
CertID: &wildcardCert.ID,
Enabled: true,
}); err != nil {
t.Fatalf("failed to create wildcard managed domain: %v", err)
}
if _, err = CreateManagedDomain(ManagedDomainInput{
Domain: "api.example.com",
CertID: &exactCert.ID,
Enabled: true,
}); err != nil {
t.Fatalf("failed to create exact managed domain: %v", err)
}
result, err := MatchManagedDomainCertificate("api.example.com")
if err != nil {
t.Fatalf("MatchManagedDomainCertificate failed: %v", err)
}
if !result.Matched || result.Candidate == nil {
t.Fatal("expected exact domain to be matched")
}
if result.Candidate.MatchType != ManagedDomainMatchTypeExact {
t.Fatalf("expected exact match first, got %s", result.Candidate.MatchType)
}
if result.Candidate.CertificateID != exactCert.ID {
t.Fatalf("expected exact certificate %d, got %d", exactCert.ID, result.Candidate.CertificateID)
}
if len(result.Candidates) != 2 {
t.Fatalf("expected 2 match candidates, got %d", len(result.Candidates))
}
}
func TestMatchManagedDomainCertificateSupportsWildcard(t *testing.T) {
setupServiceTestDB(t)
certPEM, keyPEM := generateCertificatePair(t, []string{"*.example.com"})
certificate, err := CreateTLSCertificate(TLSCertificateInput{
Name: "wildcard-cert",
CertPEM: certPEM,
KeyPEM: keyPEM,
})
if err != nil {
t.Fatalf("failed to create certificate: %v", err)
}
if _, err = CreateManagedDomain(ManagedDomainInput{
Domain: "*.example.com",
CertID: &certificate.ID,
Enabled: true,
}); err != nil {
t.Fatalf("failed to create managed domain: %v", err)
}
result, err := MatchManagedDomainCertificate("edge.example.com")
if err != nil {
t.Fatalf("MatchManagedDomainCertificate failed: %v", err)
}
if !result.Matched || result.Candidate == nil {
t.Fatal("expected wildcard domain to be matched")
}
if result.Candidate.MatchType != ManagedDomainMatchTypeWildcard {
t.Fatalf("expected wildcard match, got %s", result.Candidate.MatchType)
}
deepResult, err := MatchManagedDomainCertificate("deep.edge.example.com")
if err != nil {
t.Fatalf("MatchManagedDomainCertificate failed: %v", err)
}
if deepResult.Matched {
t.Fatal("expected single-level wildcard not to match deep subdomain")
}
}
func TestCreateManagedDomainRejectsInvalidWildcard(t *testing.T) {
setupServiceTestDB(t)
_, err := CreateManagedDomain(ManagedDomainInput{
Domain: "*.*.example.com",
Enabled: true,
})
if err == nil {
t.Fatal("expected invalid wildcard domain to fail")
}
}
+232 -223
View File
@@ -1,223 +1,232 @@
import React, { lazy, Suspense, useContext, useEffect } from 'react';
import { Route, Routes } from 'react-router-dom';
import Loading from './components/Loading';
import User from './pages/User';
import { PrivateRoute } from './components/PrivateRoute';
import RegisterForm from './components/RegisterForm';
import LoginForm from './components/LoginForm';
import NotFound from './pages/NotFound';
import Setting from './pages/Setting';
import EditUser from './pages/User/EditUser';
import AddUser from './pages/User/AddUser';
import { API, showError, showNotice } from './helpers';
import PasswordResetForm from './components/PasswordResetForm';
import GitHubOAuth from './components/GitHubOAuth';
import PasswordResetConfirm from './components/PasswordResetConfirm';
import { UserContext } from './context/User';
import { StatusContext } from './context/Status';
import File from './pages/File';
import ProxyRoute from './pages/ProxyRoute';
import ConfigVersion from './pages/ConfigVersion';
import Node from './pages/Node';
import ApplyLog from './pages/ApplyLog';
import TLSCertificate from './pages/TLSCertificate';
const Home = lazy(() => import('./pages/Home'));
const About = lazy(() => import('./pages/About'));
function App() {
const [userState, userDispatch] = useContext(UserContext);
const [statusState, statusDispatch] = useContext(StatusContext);
const loadUser = () => {
let user = localStorage.getItem('user');
if (user) {
let data = JSON.parse(user);
userDispatch({ type: 'login', payload: data });
}
};
const loadStatus = async () => {
const res = await API.get('/api/status');
const { success, data } = res.data;
if (success) {
console.log(
`GitHub 仓库地址:https://github.com/songquanpeng/gin-template`
);
localStorage.setItem('status', JSON.stringify(data));
statusDispatch({ type: 'set', payload: data });
localStorage.setItem('system_name', data.system_name);
localStorage.setItem('footer_html', data.footer_html);
localStorage.setItem('home_page_link', data.home_page_link);
if (
data.version !== process.env.REACT_APP_VERSION &&
data.version !== 'v0.0.0' &&
process.env.REACT_APP_VERSION !== ''
) {
showNotice(
`新版本可用:${data.version},请使用快捷键 Shift + F5 刷新页面`
);
}
} else {
showError('无法正常连接至服务器!');
}
};
useEffect(() => {
loadUser();
loadStatus().then();
}, []);
return (
<Routes>
<Route
path='/'
element={
<Suspense fallback={<Loading></Loading>}>
<Home />
</Suspense>
}
/>
<Route
path='/proxy-route'
element={
<PrivateRoute>
<ProxyRoute />
</PrivateRoute>
}
/>
<Route
path='/config-version'
element={
<PrivateRoute>
<ConfigVersion />
</PrivateRoute>
}
/>
<Route
path='/node'
element={
<PrivateRoute>
<Node />
</PrivateRoute>
}
/>
<Route
path='/apply-log'
element={
<PrivateRoute>
<ApplyLog />
</PrivateRoute>
}
/>
<Route
path='/tls-certificate'
element={
<PrivateRoute>
<TLSCertificate />
</PrivateRoute>
}
/>
<Route
path='/file'
element={
<PrivateRoute>
<File />
</PrivateRoute>
}
/>
<Route
path='/user'
element={
<PrivateRoute>
<User />
</PrivateRoute>
}
/>
<Route
path='/user/edit/:id'
element={
<Suspense fallback={<Loading></Loading>}>
<EditUser />
</Suspense>
}
/>
<Route
path='/user/edit'
element={
<Suspense fallback={<Loading></Loading>}>
<EditUser />
</Suspense>
}
/>
<Route
path='/user/add'
element={
<Suspense fallback={<Loading></Loading>}>
<AddUser />
</Suspense>
}
/>
<Route
path='/user/reset'
element={
<Suspense fallback={<Loading></Loading>}>
<PasswordResetConfirm />
</Suspense>
}
/>
<Route
path='/login'
element={
<Suspense fallback={<Loading></Loading>}>
<LoginForm />
</Suspense>
}
/>
<Route
path='/register'
element={
<Suspense fallback={<Loading></Loading>}>
<RegisterForm />
</Suspense>
}
/>
<Route
path='/reset'
element={
<Suspense fallback={<Loading></Loading>}>
<PasswordResetForm />
</Suspense>
}
/>
<Route
path='/oauth/github'
element={
<Suspense fallback={<Loading></Loading>}>
<GitHubOAuth />
</Suspense>
}
/>
<Route
path='/setting'
element={
<PrivateRoute>
<Suspense fallback={<Loading></Loading>}>
<Setting />
</Suspense>
</PrivateRoute>
}
/>
<Route
path='/about'
element={
<Suspense fallback={<Loading></Loading>}>
<About />
</Suspense>
}
/>
<Route path='*' element={<NotFound />} />
</Routes>
);
}
export default App;
import React, { lazy, Suspense, useContext, useEffect } from 'react';
import { Route, Routes } from 'react-router-dom';
import Loading from './components/Loading';
import User from './pages/User';
import { PrivateRoute } from './components/PrivateRoute';
import RegisterForm from './components/RegisterForm';
import LoginForm from './components/LoginForm';
import NotFound from './pages/NotFound';
import Setting from './pages/Setting';
import EditUser from './pages/User/EditUser';
import AddUser from './pages/User/AddUser';
import { API, showError, showNotice } from './helpers';
import PasswordResetForm from './components/PasswordResetForm';
import GitHubOAuth from './components/GitHubOAuth';
import PasswordResetConfirm from './components/PasswordResetConfirm';
import { UserContext } from './context/User';
import { StatusContext } from './context/Status';
import File from './pages/File';
import ProxyRoute from './pages/ProxyRoute';
import ConfigVersion from './pages/ConfigVersion';
import Node from './pages/Node';
import ApplyLog from './pages/ApplyLog';
import TLSCertificate from './pages/TLSCertificate';
import ManagedDomain from './pages/ManagedDomain';
const Home = lazy(() => import('./pages/Home'));
const About = lazy(() => import('./pages/About'));
function App() {
const [userState, userDispatch] = useContext(UserContext);
const [statusState, statusDispatch] = useContext(StatusContext);
const loadUser = () => {
let user = localStorage.getItem('user');
if (user) {
let data = JSON.parse(user);
userDispatch({ type: 'login', payload: data });
}
};
const loadStatus = async () => {
const res = await API.get('/api/status');
const { success, data } = res.data;
if (success) {
console.log(
`GitHub 仓库地址:https://github.com/songquanpeng/gin-template`
);
localStorage.setItem('status', JSON.stringify(data));
statusDispatch({ type: 'set', payload: data });
localStorage.setItem('system_name', data.system_name);
localStorage.setItem('footer_html', data.footer_html);
localStorage.setItem('home_page_link', data.home_page_link);
if (
data.version !== process.env.REACT_APP_VERSION &&
data.version !== 'v0.0.0' &&
process.env.REACT_APP_VERSION !== ''
) {
showNotice(
`新版本可用:${data.version},请使用快捷键 Shift + F5 刷新页面`
);
}
} else {
showError('无法正常连接至服务器!');
}
};
useEffect(() => {
loadUser();
loadStatus().then();
}, []);
return (
<Routes>
<Route
path='/'
element={
<Suspense fallback={<Loading></Loading>}>
<Home />
</Suspense>
}
/>
<Route
path='/proxy-route'
element={
<PrivateRoute>
<ProxyRoute />
</PrivateRoute>
}
/>
<Route
path='/config-version'
element={
<PrivateRoute>
<ConfigVersion />
</PrivateRoute>
}
/>
<Route
path='/node'
element={
<PrivateRoute>
<Node />
</PrivateRoute>
}
/>
<Route
path='/apply-log'
element={
<PrivateRoute>
<ApplyLog />
</PrivateRoute>
}
/>
<Route
path='/managed-domain'
element={
<PrivateRoute>
<ManagedDomain />
</PrivateRoute>
}
/>
<Route
path='/tls-certificate'
element={
<PrivateRoute>
<TLSCertificate />
</PrivateRoute>
}
/>
<Route
path='/file'
element={
<PrivateRoute>
<File />
</PrivateRoute>
}
/>
<Route
path='/user'
element={
<PrivateRoute>
<User />
</PrivateRoute>
}
/>
<Route
path='/user/edit/:id'
element={
<Suspense fallback={<Loading></Loading>}>
<EditUser />
</Suspense>
}
/>
<Route
path='/user/edit'
element={
<Suspense fallback={<Loading></Loading>}>
<EditUser />
</Suspense>
}
/>
<Route
path='/user/add'
element={
<Suspense fallback={<Loading></Loading>}>
<AddUser />
</Suspense>
}
/>
<Route
path='/user/reset'
element={
<Suspense fallback={<Loading></Loading>}>
<PasswordResetConfirm />
</Suspense>
}
/>
<Route
path='/login'
element={
<Suspense fallback={<Loading></Loading>}>
<LoginForm />
</Suspense>
}
/>
<Route
path='/register'
element={
<Suspense fallback={<Loading></Loading>}>
<RegisterForm />
</Suspense>
}
/>
<Route
path='/reset'
element={
<Suspense fallback={<Loading></Loading>}>
<PasswordResetForm />
</Suspense>
}
/>
<Route
path='/oauth/github'
element={
<Suspense fallback={<Loading></Loading>}>
<GitHubOAuth />
</Suspense>
}
/>
<Route
path='/setting'
element={
<PrivateRoute>
<Suspense fallback={<Loading></Loading>}>
<Setting />
</Suspense>
</PrivateRoute>
}
/>
<Route
path='/about'
element={
<Suspense fallback={<Loading></Loading>}>
<About />
</Suspense>
}
/>
<Route path='*' element={<NotFound />} />
</Routes>
);
}
export default App;
+236 -230
View File
@@ -1,230 +1,236 @@
import React, { useContext, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { UserContext } from '../context/User';
import {
Button,
Container,
Dropdown,
Icon,
Menu,
Segment,
} from 'semantic-ui-react';
import { API, getSystemName, isAdmin, isMobile, showSuccess } from '../helpers';
import '../index.css';
// Header Buttons
const headerButtons = [
{
name: '首页',
to: '/',
icon: 'home',
},
{
name: '规则',
to: '/proxy-route',
icon: 'sitemap',
admin: true,
},
{
name: '版本',
to: '/config-version',
icon: 'history',
admin: true,
},
{
name: '节点',
to: '/node',
icon: 'server',
admin: true,
},
{
name: '记录',
to: '/apply-log',
icon: 'tasks',
admin: true,
},
{
name: '证书',
to: '/tls-certificate',
icon: 'lock',
admin: true,
},
{
name: '文件',
to: '/file',
icon: 'file',
admin: true,
},
{
name: '用户',
to: '/user',
icon: 'user',
admin: true,
},
{
name: '设置',
to: '/setting',
icon: 'setting',
},
{
name: '关于',
to: '/about',
icon: 'info circle',
},
];
const Header = () => {
const [userState, userDispatch] = useContext(UserContext);
let navigate = useNavigate();
const [showSidebar, setShowSidebar] = useState(false);
const systemName = getSystemName();
async function logout() {
setShowSidebar(false);
await API.get('/api/user/logout');
showSuccess('注销成功!');
userDispatch({ type: 'logout' });
localStorage.removeItem('user');
navigate('/login');
}
const toggleSidebar = () => {
setShowSidebar(!showSidebar);
};
const renderButtons = (isMobile) => {
return headerButtons.map((button) => {
if (button.admin && !isAdmin()) return <></>;
if (isMobile) {
return (
<Menu.Item
onClick={() => {
navigate(button.to);
setShowSidebar(false);
}}
>
{button.name}
</Menu.Item>
);
}
return (
<Menu.Item key={button.name} as={Link} to={button.to}>
<Icon name={button.icon} />
{button.name}
</Menu.Item>
);
});
};
if (isMobile()) {
return (
<>
<Menu
borderless
size='large'
style={
showSidebar
? {
borderBottom: 'none',
marginBottom: '0',
borderTop: 'none',
height: '51px',
}
: { borderTop: 'none', height: '52px' }
}
>
<Container>
<Menu.Item as={Link} to='/'>
<img
src='/logo.png'
alt='logo'
style={{ marginRight: '0.75em' }}
/>
<div style={{ fontSize: '20px' }}>
<b>{systemName}</b>
</div>
</Menu.Item>
<Menu.Menu position='right'>
<Menu.Item onClick={toggleSidebar}>
<Icon name={showSidebar ? 'close' : 'sidebar'} />
</Menu.Item>
</Menu.Menu>
</Container>
</Menu>
{showSidebar ? (
<Segment style={{ marginTop: 0, borderTop: '0' }}>
<Menu secondary vertical style={{ width: '100%', margin: 0 }}>
{renderButtons(true)}
<Menu.Item>
{userState.user ? (
<Button onClick={logout}>注销</Button>
) : (
<>
<Button
onClick={() => {
setShowSidebar(false);
navigate('/login');
}}
>
登录
</Button>
<Button
onClick={() => {
setShowSidebar(false);
navigate('/register');
}}
>
注册
</Button>
</>
)}
</Menu.Item>
</Menu>
</Segment>
) : (
<></>
)}
</>
);
}
return (
<>
<Menu borderless style={{ borderTop: 'none' }}>
<Container>
<Menu.Item as={Link} to='/' className={'hide-on-mobile'}>
<img src='/logo.png' alt='logo' style={{ marginRight: '0.75em' }} />
<div style={{ fontSize: '20px' }}>
<b>{systemName}</b>
</div>
</Menu.Item>
{renderButtons(false)}
<Menu.Menu position='right'>
{userState.user ? (
<Dropdown
text={userState.user.username}
pointing
className='link item'
>
<Dropdown.Menu>
<Dropdown.Item onClick={logout}>注销</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
) : (
<Menu.Item
name='登录'
as={Link}
to='/login'
className='btn btn-link'
/>
)}
</Menu.Menu>
</Container>
</Menu>
</>
);
};
export default Header;
import React, { useContext, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { UserContext } from '../context/User';
import {
Button,
Container,
Dropdown,
Icon,
Menu,
Segment,
} from 'semantic-ui-react';
import { API, getSystemName, isAdmin, isMobile, showSuccess } from '../helpers';
import '../index.css';
// Header Buttons
const headerButtons = [
{
name: '首页',
to: '/',
icon: 'home',
},
{
name: '规则',
to: '/proxy-route',
icon: 'sitemap',
admin: true,
},
{
name: '版本',
to: '/config-version',
icon: 'history',
admin: true,
},
{
name: '节点',
to: '/node',
icon: 'server',
admin: true,
},
{
name: '记录',
to: '/apply-log',
icon: 'tasks',
admin: true,
},
{
name: '证书',
to: '/tls-certificate',
icon: 'lock',
admin: true,
},
{
name: '域名',
to: '/managed-domain',
icon: 'globe',
admin: true,
},
{
name: '文件',
to: '/file',
icon: 'file',
admin: true,
},
{
name: '用户',
to: '/user',
icon: 'user',
admin: true,
},
{
name: '设置',
to: '/setting',
icon: 'setting',
},
{
name: '关于',
to: '/about',
icon: 'info circle',
},
];
const Header = () => {
const [userState, userDispatch] = useContext(UserContext);
let navigate = useNavigate();
const [showSidebar, setShowSidebar] = useState(false);
const systemName = getSystemName();
async function logout() {
setShowSidebar(false);
await API.get('/api/user/logout');
showSuccess('注销成功!');
userDispatch({ type: 'logout' });
localStorage.removeItem('user');
navigate('/login');
}
const toggleSidebar = () => {
setShowSidebar(!showSidebar);
};
const renderButtons = (isMobile) => {
return headerButtons.map((button) => {
if (button.admin && !isAdmin()) return <></>;
if (isMobile) {
return (
<Menu.Item
onClick={() => {
navigate(button.to);
setShowSidebar(false);
}}
>
{button.name}
</Menu.Item>
);
}
return (
<Menu.Item key={button.name} as={Link} to={button.to}>
<Icon name={button.icon} />
{button.name}
</Menu.Item>
);
});
};
if (isMobile()) {
return (
<>
<Menu
borderless
size='large'
style={
showSidebar
? {
borderBottom: 'none',
marginBottom: '0',
borderTop: 'none',
height: '51px',
}
: { borderTop: 'none', height: '52px' }
}
>
<Container>
<Menu.Item as={Link} to='/'>
<img
src='/logo.png'
alt='logo'
style={{ marginRight: '0.75em' }}
/>
<div style={{ fontSize: '20px' }}>
<b>{systemName}</b>
</div>
</Menu.Item>
<Menu.Menu position='right'>
<Menu.Item onClick={toggleSidebar}>
<Icon name={showSidebar ? 'close' : 'sidebar'} />
</Menu.Item>
</Menu.Menu>
</Container>
</Menu>
{showSidebar ? (
<Segment style={{ marginTop: 0, borderTop: '0' }}>
<Menu secondary vertical style={{ width: '100%', margin: 0 }}>
{renderButtons(true)}
<Menu.Item>
{userState.user ? (
<Button onClick={logout}>注销</Button>
) : (
<>
<Button
onClick={() => {
setShowSidebar(false);
navigate('/login');
}}
>
登录
</Button>
<Button
onClick={() => {
setShowSidebar(false);
navigate('/register');
}}
>
注册
</Button>
</>
)}
</Menu.Item>
</Menu>
</Segment>
) : (
<></>
)}
</>
);
}
return (
<>
<Menu borderless style={{ borderTop: 'none' }}>
<Container>
<Menu.Item as={Link} to='/' className={'hide-on-mobile'}>
<img src='/logo.png' alt='logo' style={{ marginRight: '0.75em' }} />
<div style={{ fontSize: '20px' }}>
<b>{systemName}</b>
</div>
</Menu.Item>
{renderButtons(false)}
<Menu.Menu position='right'>
{userState.user ? (
<Dropdown
text={userState.user.username}
pointing
className='link item'
>
<Dropdown.Menu>
<Dropdown.Item onClick={logout}>注销</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
) : (
<Menu.Item
name='登录'
as={Link}
to='/login'
className='btn btn-link'
/>
)}
</Menu.Menu>
</Container>
</Menu>
</>
);
};
export default Header;
@@ -0,0 +1,198 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Button, Dropdown, Form, Header, Label, Segment, Table } from 'semantic-ui-react';
import { API, formatDateTime, showError, showSuccess } from '../../helpers';
const initialForm = {
domain: '',
cert_id: '',
enabled: true,
remark: '',
};
const ManagedDomain = () => {
const [domains, setDomains] = useState([]);
const [certificates, setCertificates] = useState([]);
const [loading, setLoading] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [form, setForm] = useState(initialForm);
const [editingId, setEditingId] = useState(null);
const loadCertificates = async () => {
const res = await API.get('/api/tls-certificates/');
const { success, message, data } = res.data;
if (success) {
setCertificates(data || []);
} else {
showError(message);
}
};
const loadDomains = async () => {
setLoading(true);
const res = await API.get('/api/managed-domains/');
const { success, message, data } = res.data;
if (success) {
setDomains(data || []);
} else {
showError(message);
}
setLoading(false);
};
useEffect(() => {
loadCertificates().then();
loadDomains().then();
}, []);
const certificateMap = useMemo(() => {
const map = new Map();
certificates.forEach((certificate) => {
map.set(certificate.id, certificate);
});
return map;
}, [certificates]);
const certificateOptions = certificates.map((certificate) => ({
key: certificate.id,
value: certificate.id,
text: certificate.name,
}));
const resetForm = () => {
setForm(initialForm);
setEditingId(null);
};
const submitManagedDomain = async () => {
setSubmitting(true);
const payload = {
domain: form.domain.trim(),
cert_id: form.cert_id ? Number(form.cert_id) : null,
enabled: form.enabled,
remark: form.remark.trim(),
};
const res = editingId
? await API.put(`/api/managed-domains/${editingId}`, payload)
: await API.post('/api/managed-domains/', payload);
const { success, message } = res.data;
if (success) {
showSuccess(editingId ? '域名规则已更新' : '域名规则已创建');
resetForm();
await loadDomains();
} else {
showError(message);
}
setSubmitting(false);
};
const deleteManagedDomain = async (id) => {
const res = await API.delete(`/api/managed-domains/${id}`);
const { success, message } = res.data;
if (success) {
showSuccess('域名规则已删除');
await loadDomains();
} else {
showError(message);
}
};
const beginEdit = (domain) => {
setEditingId(domain.id);
setForm({
domain: domain.domain,
cert_id: domain.cert_id || '',
enabled: domain.enabled,
remark: domain.remark || '',
});
};
return (
<Segment loading={loading}>
<Header as='h3'>域名管理</Header>
<p className='page-subtitle'>维护精确域名与通配符域名,并为其绑定默认 TLS 证书。</p>
<Form onSubmit={submitManagedDomain}>
<Form.Group widths='equal'>
<Form.Input
label='域名'
placeholder='example.com 或 *.example.com'
value={form.domain}
onChange={(e, { value }) => setForm({ ...form, domain: value })}
/>
<Form.Field
control={Dropdown}
selection
clearable
label='默认证书'
placeholder='可选,绑定默认 TLS 证书'
options={certificateOptions}
value={form.cert_id}
onChange={(e, { value }) => setForm({ ...form, cert_id: value || '' })}
/>
</Form.Group>
<Form.Group widths='equal'>
<Form.Input
label='备注'
placeholder='可选备注'
value={form.remark}
onChange={(e, { value }) => setForm({ ...form, remark: value })}
/>
<Form.Checkbox
toggle
label='启用规则'
checked={form.enabled}
onChange={(e, { checked }) => setForm({ ...form, enabled: checked })}
style={{ alignSelf: 'flex-end', marginBottom: '1rem' }}
/>
</Form.Group>
<Button primary type='submit' loading={submitting}>
{editingId ? '保存修改' : '新增域名'}
</Button>
{editingId ? (
<Button type='button' onClick={resetForm}>
取消编辑
</Button>
) : null}
</Form>
<Table celled stackable className='atsf-table'>
<Table.Header>
<Table.Row>
<Table.HeaderCell>域名</Table.HeaderCell>
<Table.HeaderCell>绑定证书</Table.HeaderCell>
<Table.HeaderCell>状态</Table.HeaderCell>
<Table.HeaderCell>备注</Table.HeaderCell>
<Table.HeaderCell>更新时间</Table.HeaderCell>
<Table.HeaderCell>操作</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{domains.map((domain) => {
const certificate = domain.cert_id ? certificateMap.get(domain.cert_id) : null;
return (
<Table.Row key={domain.id}>
<Table.Cell>{domain.domain}</Table.Cell>
<Table.Cell>{certificate ? certificate.name : '未绑定'}</Table.Cell>
<Table.Cell>
{domain.enabled ? <Label color='green'>启用</Label> : <Label>停用</Label>}
</Table.Cell>
<Table.Cell>{domain.remark || '无'}</Table.Cell>
<Table.Cell>{formatDateTime(domain.updated_at)}</Table.Cell>
<Table.Cell>
<Button size='small' onClick={() => beginEdit(domain)}>
编辑
</Button>
<Button size='small' negative onClick={() => deleteManagedDomain(domain.id)}>
删除
</Button>
</Table.Cell>
</Table.Row>
);
})}
</Table.Body>
</Table>
</Segment>
);
};
export default ManagedDomain;
@@ -27,6 +27,8 @@ const ProxyRoute = () => {
const [certificates, setCertificates] = useState([]);
const [loading, setLoading] = useState(false);
const [publishing, setPublishing] = useState(false);
const [matching, setMatching] = useState(false);
const [matchResult, setMatchResult] = useState(null);
const [form, setForm] = useState(initialForm);
const [editingId, setEditingId] = useState(null);
@@ -57,9 +59,45 @@ const ProxyRoute = () => {
loadCertificates().then();
}, []);
useEffect(() => {
if (!form.enable_https) {
setMatchResult(null);
return undefined;
}
const domain = form.domain.trim().toLowerCase();
if (!domain) {
setMatchResult(null);
return undefined;
}
const timer = setTimeout(async () => {
setMatching(true);
const res = await API.get('/api/managed-domains/match', {
params: { domain },
});
const { success, message, data } = res.data;
if (success) {
setMatchResult(data || null);
if (data?.matched && data?.candidate?.certificate_id) {
setForm((current) => {
if (!current.enable_https || current.domain.trim().toLowerCase() !== domain || current.cert_id) {
return current;
}
return { ...current, cert_id: data.candidate.certificate_id };
});
}
} else {
setMatchResult(null);
showError(message);
}
setMatching(false);
}, 400);
return () => clearTimeout(timer);
}, [form.domain, form.enable_https]);
const resetForm = () => {
setForm(initialForm);
setEditingId(null);
setMatchResult(null);
};
const submitRoute = async () => {
@@ -117,6 +155,7 @@ const ProxyRoute = () => {
redirect_http: route.redirect_http || false,
remark: route.remark || '',
});
setMatchResult(null);
};
const certificateOptions = certificates.map((certificate) => ({
@@ -180,6 +219,17 @@ const ProxyRoute = () => {
onChange={(e, { value }) => setForm({ ...form, cert_id: value || '' })}
/>
</Form.Group>
{form.enable_https ? (
<p className='page-subtitle' style={{ marginTop: '-0.5rem' }}>
{matching
? '正在按域名自动匹配证书...'
: matchResult?.matched
? `已匹配${matchResult.candidate?.match_type === 'exact' ? '精确' : '通配符'}规则 ${matchResult.candidate?.domain},推荐证书:${matchResult.candidate?.certificate_name}`
: form.domain.trim()
? '未找到匹配证书,可继续手动选择。'
: '输入域名后会自动匹配托管证书。'}
</p>
) : null}
<Form.Group widths='equal'>
<Form.Field
control={TextArea}