From a0f920cf059daa8937d5bef84687bb29cc0fea84 Mon Sep 17 00:00:00 2001 From: ryan Date: Fri, 13 Mar 2026 13:45:29 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0TLS=E8=AF=81=E4=B9=A6?= =?UTF-8?q?=E8=AF=A6=E6=83=85=E5=92=8C=E7=BC=96=E8=BE=91=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=EF=BC=8C=E6=9B=B4=E6=96=B0=E7=9B=B8=E5=85=B3API=E5=92=8C?= =?UTF-8?q?=E5=89=8D=E7=AB=AF=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- atsf_server/controller/tls_certificate.go | 79 ++++++++ atsf_server/model/tls_certificate.go | 4 + atsf_server/router/api-router.go | 2 + atsf_server/router/api_phase1_test.go | 19 ++ atsf_server/service/tls_certificate.go | 23 +++ .../tls-certificates/api/tls-certificates.ts | 15 ++ .../web/features/tls-certificates/types.ts | 7 + .../components/certificate-detail-modal.tsx | 157 +++++++++++++++ .../components/certificate-editor-modal.tsx | 182 ++++++++++++++++++ .../components/website-detail-page.tsx | 45 +++++ .../websites/components/websites-page.tsx | 81 +++++++- 11 files changed, 606 insertions(+), 8 deletions(-) create mode 100644 atsf_server/web/features/websites/components/certificate-detail-modal.tsx create mode 100644 atsf_server/web/features/websites/components/certificate-editor-modal.tsx diff --git a/atsf_server/controller/tls_certificate.go b/atsf_server/controller/tls_certificate.go index c5861058..0888ffaa 100644 --- a/atsf_server/controller/tls_certificate.go +++ b/atsf_server/controller/tls_certificate.go @@ -31,6 +31,40 @@ func GetTLSCertificates(c *gin.Context) { }) } +// GetTLSCertificate godoc +// @Summary Get TLS certificate detail +// @Tags TLSCertificates +// @Produce json +// @Security BearerAuth +// @Param id path int true "Certificate ID" +// @Success 200 {object} map[string]interface{} +// @Failure 400 {object} map[string]interface{} +// @Router /api/tls-certificates/{id} [get] +func GetTLSCertificate(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": "invalid request", + }) + return + } + + certificate, err := service.GetTLSCertificate(uint(id)) + 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": certificate, + }) +} + // CreateTLSCertificate godoc // @Summary Create TLS certificate from PEM // @Tags TLSCertificates @@ -65,6 +99,51 @@ func CreateTLSCertificate(c *gin.Context) { }) } +// UpdateTLSCertificate godoc +// @Summary Update TLS certificate from PEM +// @Tags TLSCertificates +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path int true "Certificate ID" +// @Param payload body service.TLSCertificateInput true "TLS certificate payload" +// @Success 200 {object} map[string]interface{} +// @Failure 400 {object} map[string]interface{} +// @Router /api/tls-certificates/{id} [put] +func UpdateTLSCertificate(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": "invalid request", + }) + return + } + + var input service.TLSCertificateInput + if err = json.NewDecoder(c.Request.Body).Decode(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "invalid request", + }) + return + } + + certificate, err := service.UpdateTLSCertificate(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": certificate, + }) +} + // ImportTLSCertificateFile godoc // @Summary Import TLS certificate from files // @Tags TLSCertificates diff --git a/atsf_server/model/tls_certificate.go b/atsf_server/model/tls_certificate.go index a808cc0e..cfd5f972 100644 --- a/atsf_server/model/tls_certificate.go +++ b/atsf_server/model/tls_certificate.go @@ -29,6 +29,10 @@ func (certificate *TLSCertificate) Insert() error { return DB.Create(certificate).Error } +func (certificate *TLSCertificate) Update() error { + return DB.Save(certificate).Error +} + func (certificate *TLSCertificate) Delete() error { return DB.Delete(certificate).Error } diff --git a/atsf_server/router/api-router.go b/atsf_server/router/api-router.go index 1df311ab..b40eac9b 100644 --- a/atsf_server/router/api-router.go +++ b/atsf_server/router/api-router.go @@ -92,7 +92,9 @@ func SetApiRouter(router *gin.Engine) { tlsCertificateRoute.Use(middleware.AdminAuth()) { tlsCertificateRoute.GET("/", controller.GetTLSCertificates) + tlsCertificateRoute.GET("/:id", controller.GetTLSCertificate) tlsCertificateRoute.POST("/", controller.CreateTLSCertificate) + tlsCertificateRoute.PUT("/:id", controller.UpdateTLSCertificate) tlsCertificateRoute.POST("/import-file", controller.ImportTLSCertificateFile) tlsCertificateRoute.DELETE("/:id", controller.DeleteTLSCertificate) } diff --git a/atsf_server/router/api_phase1_test.go b/atsf_server/router/api_phase1_test.go index a060bfeb..b65796a9 100644 --- a/atsf_server/router/api_phase1_test.go +++ b/atsf_server/router/api_phase1_test.go @@ -184,6 +184,25 @@ func TestPhase1HTTPSAndCertificateImportLifecycle(t *testing.T) { t.Fatal("expected manual certificate import to persist certificate") } + detailResp := performJSONRequest(t, engine, token, http.MethodGet, "/api/tls-certificates/"+toString(manualCertificate.ID), nil) + var certificateDetail model.TLSCertificate + decodeResponseData(t, detailResp, &certificateDetail) + if certificateDetail.ID != manualCertificate.ID || certificateDetail.CertPEM == "" || certificateDetail.KeyPEM == "" { + t.Fatal("expected certificate detail endpoint to return pem payloads") + } + + updatedCertPEM, updatedKeyPEM := generateCertificatePairForRouterTest(t, []string{"secure.example.com", "www.secure.example.com"}) + updateCertificateResp := performJSONRequest(t, engine, token, http.MethodPut, "/api/tls-certificates/"+toString(manualCertificate.ID), map[string]any{ + "name": "secure-example-updated", + "cert_pem": updatedCertPEM, + "key_pem": updatedKeyPEM, + "remark": "updated manual import", + }) + decodeResponseData(t, updateCertificateResp, &manualCertificate) + if manualCertificate.Name != "secure-example-updated" || manualCertificate.Remark != "updated manual import" { + t.Fatalf("expected certificate update to persist metadata, got %+v", manualCertificate) + } + fileCertPEM, fileKeyPEM := generateCertificatePairForRouterTest(t, []string{"upload.example.com"}) multipartResp := performMultipartRequest(t, engine, token, "/api/tls-certificates/import-file", map[string]string{ "name": "upload-example", diff --git a/atsf_server/service/tls_certificate.go b/atsf_server/service/tls_certificate.go index 2400c157..c60c999d 100644 --- a/atsf_server/service/tls_certificate.go +++ b/atsf_server/service/tls_certificate.go @@ -20,6 +20,10 @@ func ListTLSCertificates() ([]*model.TLSCertificate, error) { return model.ListTLSCertificates() } +func GetTLSCertificate(id uint) (*model.TLSCertificate, error) { + return model.GetTLSCertificateByID(id) +} + func CreateTLSCertificate(input TLSCertificateInput) (*model.TLSCertificate, error) { certificate, err := buildTLSCertificate(nil, input) if err != nil { @@ -54,6 +58,25 @@ func CreateTLSCertificateFromFiles(name string, certFile *multipart.FileHeader, }) } +func UpdateTLSCertificate(id uint, input TLSCertificateInput) (*model.TLSCertificate, error) { + existing, err := model.GetTLSCertificateByID(id) + if err != nil { + return nil, err + } + + certificate, err := buildTLSCertificate(existing, input) + if err != nil { + return nil, err + } + if err = certificate.Update(); err != nil { + if isUniqueConstraintError(err) { + return nil, errors.New("certificate name already exists") + } + return nil, err + } + return certificate, nil +} + func DeleteTLSCertificate(id uint) error { var routeCount int64 if err := model.DB.Model(&model.ProxyRoute{}).Where("cert_id = ?", id).Count(&routeCount).Error; err != nil { diff --git a/atsf_server/web/features/tls-certificates/api/tls-certificates.ts b/atsf_server/web/features/tls-certificates/api/tls-certificates.ts index f76c488f..7f074d5b 100644 --- a/atsf_server/web/features/tls-certificates/api/tls-certificates.ts +++ b/atsf_server/web/features/tls-certificates/api/tls-certificates.ts @@ -1,6 +1,7 @@ import { apiRequest } from '@/lib/api/client'; import type { + TlsCertificateDetailItem, TlsCertificateFileImportPayload, TlsCertificateItem, TlsCertificateMutationPayload, @@ -17,6 +18,20 @@ export function createTlsCertificate(payload: TlsCertificateMutationPayload) { }); } +export function getTlsCertificate(id: number) { + return apiRequest(`/tls-certificates/${id}`); +} + +export function updateTlsCertificate( + id: number, + payload: TlsCertificateMutationPayload, +) { + return apiRequest(`/tls-certificates/${id}`, { + method: 'PUT', + body: JSON.stringify(payload), + }); +} + export function importTlsCertificateFiles(payload: TlsCertificateFileImportPayload) { const formData = new FormData(); formData.append('name', payload.name); diff --git a/atsf_server/web/features/tls-certificates/types.ts b/atsf_server/web/features/tls-certificates/types.ts index 3e0b5853..27318dfc 100644 --- a/atsf_server/web/features/tls-certificates/types.ts +++ b/atsf_server/web/features/tls-certificates/types.ts @@ -1,6 +1,8 @@ export interface TlsCertificateItem { id: number; name: string; + cert_pem?: string; + key_pem?: string; not_before: string; not_after: string; remark: string; @@ -8,6 +10,11 @@ export interface TlsCertificateItem { updated_at: string; } +export interface TlsCertificateDetailItem extends TlsCertificateItem { + cert_pem: string; + key_pem: string; +} + export interface TlsCertificateMutationPayload { name: string; cert_pem: string; diff --git a/atsf_server/web/features/websites/components/certificate-detail-modal.tsx b/atsf_server/web/features/websites/components/certificate-detail-modal.tsx new file mode 100644 index 00000000..9bb519d0 --- /dev/null +++ b/atsf_server/web/features/websites/components/certificate-detail-modal.tsx @@ -0,0 +1,157 @@ +'use client'; + +import { useQuery } from '@tanstack/react-query'; + +import { EmptyState } from '@/components/feedback/empty-state'; +import { ErrorState } from '@/components/feedback/error-state'; +import { LoadingState } from '@/components/feedback/loading-state'; +import { AppModal } from '@/components/ui/app-modal'; +import { StatusBadge } from '@/components/ui/status-badge'; +import { getTlsCertificate } from '@/features/tls-certificates/api/tls-certificates'; +import { getCertificateStatus, getErrorMessage } from '@/features/websites/utils'; +import { + CodeBlock, + DangerButton, + PrimaryButton, + SecondaryButton, +} from '@/features/shared/components/resource-primitives'; +import { formatDateTime } from '@/lib/utils/date'; + +interface CertificateDetailModalProps { + certificateId: number | null; + isOpen: boolean; + onClose: () => void; + onEdit: () => void; + onDelete: () => void; + deleting?: boolean; +} + +export function CertificateDetailModal({ + certificateId, + isOpen, + onClose, + onEdit, + onDelete, + deleting = false, +}: CertificateDetailModalProps) { + const certificateQuery = useQuery({ + queryKey: ['tls-certificates', 'detail', certificateId], + queryFn: () => getTlsCertificate(certificateId as number), + enabled: isOpen && certificateId !== null, + }); + + const certificate = certificateQuery.data; + const status = certificate ? getCertificateStatus(certificate) : null; + + return ( + + + 关闭 + + + 编辑证书 + + + {deleting ? '删除中...' : '删除证书'} + + + } + > + {certificateQuery.isLoading ? ( + + ) : certificateQuery.isError ? ( + + ) : !certificate ? ( + + ) : ( +
+
+
+

+ 证书名称 +

+

+ {certificate.name} +

+
+
+

+ 状态 +

+
+ {status ? ( + + ) : null} +
+
+
+

+ 生效时间 +

+

+ {formatDateTime(certificate.not_before)} +

+
+
+

+ 到期时间 +

+

+ {formatDateTime(certificate.not_after)} +

+
+
+ +
+

+ 备注 +

+

+ {certificate.remark || '暂无备注'} +

+
+ +
+
+

+ 证书 PEM +

+ + {certificate.cert_pem} + +
+
+

+ 私钥 PEM +

+ + {certificate.key_pem} + +
+
+
+ )} +
+ ); +} diff --git a/atsf_server/web/features/websites/components/certificate-editor-modal.tsx b/atsf_server/web/features/websites/components/certificate-editor-modal.tsx new file mode 100644 index 00000000..a9d33036 --- /dev/null +++ b/atsf_server/web/features/websites/components/certificate-editor-modal.tsx @@ -0,0 +1,182 @@ +'use client'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useEffect } from 'react'; +import { useForm } from 'react-hook-form'; + +import { EmptyState } from '@/components/feedback/empty-state'; +import { ErrorState } from '@/components/feedback/error-state'; +import { InlineMessage } from '@/components/feedback/inline-message'; +import { LoadingState } from '@/components/feedback/loading-state'; +import { AppModal } from '@/components/ui/app-modal'; +import { + getTlsCertificate, + updateTlsCertificate, +} from '@/features/tls-certificates/api/tls-certificates'; +import type { TlsCertificateItem } from '@/features/tls-certificates/types'; +import { + defaultManualImportValues, + manualImportSchema, + type ManualImportFormValues, +} from '@/features/websites/schemas'; +import { getErrorMessage, toManualPayload } from '@/features/websites/utils'; +import { + PrimaryButton, + ResourceField, + ResourceInput, + ResourceTextarea, + SecondaryButton, +} from '@/features/shared/components/resource-primitives'; + +interface CertificateEditorModalProps { + certificateId: number | null; + isOpen: boolean; + onClose: () => void; + onSaved?: (certificate: TlsCertificateItem) => void; +} + +export function CertificateEditorModal({ + certificateId, + isOpen, + onClose, + onSaved, +}: CertificateEditorModalProps) { + const queryClient = useQueryClient(); + const form = useForm({ + resolver: zodResolver(manualImportSchema), + defaultValues: defaultManualImportValues, + }); + + const certificateQuery = useQuery({ + queryKey: ['tls-certificates', 'detail', certificateId], + queryFn: () => getTlsCertificate(certificateId as number), + enabled: isOpen && certificateId !== null, + }); + + const updateMutation = useMutation({ + mutationFn: async (values: ManualImportFormValues) => + updateTlsCertificate(certificateId as number, toManualPayload(values)), + onSuccess: async (certificate) => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ['tls-certificates'] }), + queryClient.invalidateQueries({ queryKey: ['managed-domains'] }), + ]); + onSaved?.(certificate); + handleClose(); + }, + }); + + useEffect(() => { + if (!isOpen || !certificateQuery.data) { + return; + } + + form.reset({ + name: certificateQuery.data.name, + cert_pem: certificateQuery.data.cert_pem, + key_pem: certificateQuery.data.key_pem, + remark: certificateQuery.data.remark || '', + }); + }, [certificateQuery.data, form, isOpen]); + + const handleSubmit = form.handleSubmit((values) => { + updateMutation.mutate(values); + }); + + const handleClose = () => { + updateMutation.reset(); + form.reset(defaultManualImportValues); + onClose(); + }; + + return ( + + + 取消 + + + {updateMutation.isPending ? '保存中...' : '保存证书'} + + + } + > + {certificateQuery.isLoading ? ( + + ) : certificateQuery.isError ? ( + + ) : !certificateQuery.data ? ( + + ) : ( +
+ {updateMutation.isError ? ( + + ) : null} + +
+ + + + + + +
+ + + + + + + + + + )} +
+ ); +} diff --git a/atsf_server/web/features/websites/components/website-detail-page.tsx b/atsf_server/web/features/websites/components/website-detail-page.tsx index bf4b2e15..812106da 100644 --- a/atsf_server/web/features/websites/components/website-detail-page.tsx +++ b/atsf_server/web/features/websites/components/website-detail-page.tsx @@ -23,6 +23,8 @@ import { deleteTlsCertificate, getTlsCertificates, } from '@/features/tls-certificates/api/tls-certificates'; +import { CertificateDetailModal } from '@/features/websites/components/certificate-detail-modal'; +import { CertificateEditorModal } from '@/features/websites/components/certificate-editor-modal'; import { CertificateImportModal } from '@/features/websites/components/certificate-import-modal'; import { WebsiteEditorModal } from '@/features/websites/components/website-editor-modal'; import { @@ -50,6 +52,8 @@ export function WebsiteDetailPage({ websiteId }: { websiteId: string }) { const [feedback, setFeedback] = useState(null); const [isEditorOpen, setIsEditorOpen] = useState(false); const [isCertificateImportOpen, setIsCertificateImportOpen] = useState(false); + const [isCertificateDetailOpen, setIsCertificateDetailOpen] = useState(false); + const [isCertificateEditorOpen, setIsCertificateEditorOpen] = useState(false); const [preferredCertificateId, setPreferredCertificateId] = useState< number | null >(null); @@ -339,6 +343,20 @@ export function WebsiteDetailPage({ websiteId }: { websiteId: string }) {

到期时间:{formatDateTime(certificate.not_after)}

备注:{certificate.remark || '暂无备注'}

+
+ setIsCertificateDetailOpen(true)} + > + 查看证书 + + setIsCertificateEditorOpen(true)} + > + 编辑证书 + +
+ + setIsCertificateDetailOpen(false)} + onEdit={() => { + setIsCertificateDetailOpen(false); + setIsCertificateEditorOpen(true); + }} + onDelete={() => { + setIsCertificateDetailOpen(false); + handleDeleteCertificate(); + }} + deleting={deleteCertificateMutation.isPending} + /> + + setIsCertificateEditorOpen(false)} + onSaved={(updatedCertificate) => { + setFeedback({ + tone: 'success', + message: `证书 ${updatedCertificate.name} 已更新。`, + }); + }} + /> ); } diff --git a/atsf_server/web/features/websites/components/websites-page.tsx b/atsf_server/web/features/websites/components/websites-page.tsx index 70c80367..693062b4 100644 --- a/atsf_server/web/features/websites/components/websites-page.tsx +++ b/atsf_server/web/features/websites/components/websites-page.tsx @@ -22,6 +22,8 @@ import { getTlsCertificates, } from '@/features/tls-certificates/api/tls-certificates'; import type { TlsCertificateItem } from '@/features/tls-certificates/types'; +import { CertificateDetailModal } from '@/features/websites/components/certificate-detail-modal'; +import { CertificateEditorModal } from '@/features/websites/components/certificate-editor-modal'; import { CertificateImportModal } from '@/features/websites/components/certificate-import-modal'; import { WebsiteEditorModal } from '@/features/websites/components/website-editor-modal'; import { @@ -48,6 +50,11 @@ export function WebsitesPage() { const [isWebsiteModalOpen, setIsWebsiteModalOpen] = useState(false); const [isCertificateImportOpen, setIsCertificateImportOpen] = useState(false); const [isCertificateListOpen, setIsCertificateListOpen] = useState(false); + const [selectedCertificateId, setSelectedCertificateId] = useState< + number | null + >(null); + const [isCertificateDetailOpen, setIsCertificateDetailOpen] = useState(false); + const [isCertificateEditorOpen, setIsCertificateEditorOpen] = useState(false); const [preferredCertificateId, setPreferredCertificateId] = useState< number | null >(null); @@ -123,6 +130,16 @@ export function WebsitesPage() { deleteCertificateMutation.mutate(certificate.id); }; + const handleOpenCertificateDetail = (certificate: TlsCertificateItem) => { + setSelectedCertificateId(certificate.id); + setIsCertificateDetailOpen(true); + }; + + const handleOpenCertificateEditor = (certificate: TlsCertificateItem) => { + setSelectedCertificateId(certificate.id); + setIsCertificateEditorOpen(true); + }; + return ( <>
@@ -342,14 +359,30 @@ export function WebsitesPage() {
- handleDeleteCertificate(certificate)} - disabled={deleteCertificateMutation.isPending} - className="px-3 py-2 text-xs" - > - 删除 - +
+ handleOpenCertificateDetail(certificate)} + className="px-3 py-2 text-xs" + > + 查看 + + handleOpenCertificateEditor(certificate)} + className="px-3 py-2 text-xs" + > + 编辑 + + handleDeleteCertificate(certificate)} + disabled={deleteCertificateMutation.isPending} + className="px-3 py-2 text-xs" + > + 删除 + +
); @@ -358,6 +391,38 @@ export function WebsitesPage() { )} + + setIsCertificateDetailOpen(false)} + onEdit={() => { + setIsCertificateDetailOpen(false); + setIsCertificateEditorOpen(true); + }} + onDelete={() => { + const certificate = certificates.find( + (item) => item.id === selectedCertificateId, + ); + if (certificate) { + setIsCertificateDetailOpen(false); + handleDeleteCertificate(certificate); + } + }} + deleting={deleteCertificateMutation.isPending} + /> + + setIsCertificateEditorOpen(false)} + onSaved={(certificate) => { + setFeedback({ + tone: 'success', + message: `证书 ${certificate.name} 已更新。`, + }); + }} + /> ); }