mirror of
				https://github.com/TeaOSLab/EdgeAdmin.git
				synced 2025-11-04 13:10:26 +08:00 
			
		
		
		
	增加批量上传证书功能
This commit is contained in:
		@@ -21,6 +21,7 @@ func init() {
 | 
			
		||||
			Data("leftMenuItem", "cert").
 | 
			
		||||
			Get("", new(IndexAction)).
 | 
			
		||||
			GetPost("/uploadPopup", new(UploadPopupAction)).
 | 
			
		||||
			GetPost("/uploadBatchPopup", new(UploadBatchPopupAction)).
 | 
			
		||||
			Post("/delete", new(DeleteAction)).
 | 
			
		||||
			GetPost("/updatePopup", new(UpdatePopupAction)).
 | 
			
		||||
			Get("/certPopup", new(CertPopupAction)).
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										171
									
								
								internal/web/actions/default/servers/certs/uploadBatchPopup.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										171
									
								
								internal/web/actions/default/servers/certs/uploadBatchPopup.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,171 @@
 | 
			
		||||
// Copyright 2023 Liuxiangchao iwind.liu@gmail.com. All rights reserved. Official site: https://goedge.cn .
 | 
			
		||||
 | 
			
		||||
package certs
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"bytes"
 | 
			
		||||
	"crypto/tls"
 | 
			
		||||
	"github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils"
 | 
			
		||||
	"github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb"
 | 
			
		||||
	"github.com/TeaOSLab/EdgeCommon/pkg/serverconfigs/sslconfigs"
 | 
			
		||||
	"github.com/iwind/TeaGo/actions"
 | 
			
		||||
	"github.com/iwind/TeaGo/types"
 | 
			
		||||
	"io"
 | 
			
		||||
	"mime/multipart"
 | 
			
		||||
	"strings"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
type UploadBatchPopupAction struct {
 | 
			
		||||
	actionutils.ParentAction
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (this *UploadBatchPopupAction) Init() {
 | 
			
		||||
	this.Nav("", "", "")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (this *UploadBatchPopupAction) RunGet(params struct{}) {
 | 
			
		||||
	this.Show()
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (this *UploadBatchPopupAction) RunPost(params struct {
 | 
			
		||||
	UserId int64
 | 
			
		||||
 | 
			
		||||
	Must *actions.Must
 | 
			
		||||
	CSRF *actionutils.CSRF
 | 
			
		||||
}) {
 | 
			
		||||
	defer this.CreateLogInfo("批量上传证书")
 | 
			
		||||
 | 
			
		||||
	var files = this.Request.MultipartForm.File["certFiles"]
 | 
			
		||||
	if len(files) == 0 {
 | 
			
		||||
		this.Fail("请选择要上传的证书和私钥文件")
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// 限制每次上传的文件数量
 | 
			
		||||
	const maxFiles = 10_000
 | 
			
		||||
	if len(files) > maxFiles {
 | 
			
		||||
		this.Fail("每次上传最多不能超过" + types.String(maxFiles) + "个文件")
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	type certInfo struct {
 | 
			
		||||
		filename string
 | 
			
		||||
		data     []byte
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	var certDataList = []*certInfo{}
 | 
			
		||||
	var keyDataList = [][]byte{}
 | 
			
		||||
 | 
			
		||||
	var failMessages = []string{}
 | 
			
		||||
	for _, file := range files {
 | 
			
		||||
		func(file *multipart.FileHeader) {
 | 
			
		||||
			fp, err := file.Open()
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				failMessages = append(failMessages, "文件"+file.Filename+"读取失败:"+err.Error())
 | 
			
		||||
				return
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			defer func() {
 | 
			
		||||
				_ = fp.Close()
 | 
			
		||||
			}()
 | 
			
		||||
 | 
			
		||||
			data, err := io.ReadAll(fp)
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				failMessages = append(failMessages, "文件"+file.Filename+"读取失败:"+err.Error())
 | 
			
		||||
				return
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			if bytes.Contains(data, []byte("CERTIFICATE-")) {
 | 
			
		||||
				certDataList = append(certDataList, &certInfo{
 | 
			
		||||
					filename: file.Filename,
 | 
			
		||||
					data:     data,
 | 
			
		||||
				})
 | 
			
		||||
			} else if bytes.Contains(data, []byte("PRIVATE KEY-")) {
 | 
			
		||||
				keyDataList = append(keyDataList, data)
 | 
			
		||||
			} else {
 | 
			
		||||
				failMessages = append(failMessages, "文件"+file.Filename+"读取失败:文件格式错误,无法识别是证书还是私钥")
 | 
			
		||||
				return
 | 
			
		||||
			}
 | 
			
		||||
		}(file)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if len(failMessages) > 0 {
 | 
			
		||||
		this.Fail("发生了错误:" + strings.Join(failMessages, ";"))
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// 自动匹配
 | 
			
		||||
	var pairs = [][2][]byte{}        // [] { cert, key }
 | 
			
		||||
	var keyIndexMap = map[int]bool{} // 方便下面跳过已匹配的Key
 | 
			
		||||
	for _, cert := range certDataList {
 | 
			
		||||
		var found = false
 | 
			
		||||
		for keyIndex, keyData := range keyDataList {
 | 
			
		||||
			if keyIndexMap[keyIndex] {
 | 
			
		||||
				continue
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			_, err := tls.X509KeyPair(cert.data, keyData)
 | 
			
		||||
			if err == nil {
 | 
			
		||||
				found = true
 | 
			
		||||
				pairs = append(pairs, [2][]byte{cert.data, keyData})
 | 
			
		||||
				keyIndexMap[keyIndex] = true
 | 
			
		||||
				break
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
		if !found {
 | 
			
		||||
			this.Fail("找不到" + cert.filename + "对应的私钥")
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// 组织 CertConfig
 | 
			
		||||
	var pbCerts = []*pb.CreateSSLCertsRequestCert{}
 | 
			
		||||
	for _, pair := range pairs {
 | 
			
		||||
		certData, keyData := pair[0], pair[1]
 | 
			
		||||
 | 
			
		||||
		var certConfig = &sslconfigs.SSLCertConfig{
 | 
			
		||||
			IsCA:     false,
 | 
			
		||||
			CertData: certData,
 | 
			
		||||
			KeyData:  keyData,
 | 
			
		||||
		}
 | 
			
		||||
		err := certConfig.Init(nil)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			this.Fail("证书验证失败:" + err.Error())
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		var certName = ""
 | 
			
		||||
		if len(certConfig.DNSNames) > 0 {
 | 
			
		||||
			certName = certConfig.DNSNames[0]
 | 
			
		||||
			if len(certConfig.DNSNames) > 1 {
 | 
			
		||||
				certName += "等" + types.String(len(certConfig.DNSNames)) + "个域名"
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		pbCerts = append(pbCerts, &pb.CreateSSLCertsRequestCert{
 | 
			
		||||
			IsOn:        true,
 | 
			
		||||
			Name:        certName,
 | 
			
		||||
			Description: "",
 | 
			
		||||
			ServerName:  "",
 | 
			
		||||
			IsCA:        false,
 | 
			
		||||
			CertData:    certData,
 | 
			
		||||
			KeyData:     keyData,
 | 
			
		||||
			TimeBeginAt: certConfig.TimeBeginAt,
 | 
			
		||||
			TimeEndAt:   certConfig.TimeEndAt,
 | 
			
		||||
			DnsNames:    certConfig.DNSNames,
 | 
			
		||||
			CommonNames: certConfig.CommonNames,
 | 
			
		||||
		})
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	_, err := this.RPC().SSLCertRPC().CreateSSLCerts(this.AdminContext(), &pb.CreateSSLCertsRequest{
 | 
			
		||||
		UserId:   params.UserId,
 | 
			
		||||
		SSLCerts: pbCerts,
 | 
			
		||||
	})
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		this.ErrorPage(err)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	this.Data["count"] = len(pbCerts)
 | 
			
		||||
	this.Success()
 | 
			
		||||
}
 | 
			
		||||
@@ -11,6 +11,7 @@
 | 
			
		||||
		<menu-item :href="'/servers/certs?type=30days&keyword=' + keyword" :active="type == '30days'">30天内过期({{count30Days}})</menu-item>
 | 
			
		||||
		<span class="item disabled">|</span>
 | 
			
		||||
		<a href="" class="item" @click.prevent="uploadCert">[上传证书]</a>
 | 
			
		||||
        <a href="" class="item" @click.prevent="uploadBatch">[批量上传]</a>
 | 
			
		||||
	</second-menu>
 | 
			
		||||
 | 
			
		||||
    <form class="ui form">
 | 
			
		||||
 
 | 
			
		||||
@@ -11,6 +11,16 @@ Tea.context(function () {
 | 
			
		||||
		})
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// 批量上传证书
 | 
			
		||||
	this.uploadBatch = function () {
 | 
			
		||||
		teaweb.popup("/servers/certs/uploadBatchPopup", {
 | 
			
		||||
			height: "30em",
 | 
			
		||||
			callback: function () {
 | 
			
		||||
				window.location.reload()
 | 
			
		||||
			}
 | 
			
		||||
		})
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// 删除证书
 | 
			
		||||
	this.deleteCert = function (certId) {
 | 
			
		||||
		let that = this
 | 
			
		||||
 
 | 
			
		||||
@@ -25,7 +25,7 @@
 | 
			
		||||
		<tr>
 | 
			
		||||
			<td><span v-if="textMode">输入证书内容</span><span v-else>选择证书文件</span></td>
 | 
			
		||||
			<td>
 | 
			
		||||
                <input type="file" name="certFile" accept="application/x-pem-file, application/pkcs10, application/x-pkcs12, application/x-x509-user-cert, application/x-x509-ca-cert, application/pkix-cert" v-if="!textMode"/>
 | 
			
		||||
                <input type="file" name="certFile" accept="application/x-pem-file, application/pkcs10, application/x-pkcs12, application/x-x509-user-cert, application/x-x509-ca-cert, application/pkix-cert, .pem" v-if="!textMode"/>
 | 
			
		||||
                <file-textarea class="wide-code" ref="certTextField" spellcheck="false" name="certText" placeholder="-----BEGIN CERTIFICATE-----" v-if="textMode" style="font-size: 0.7em"></file-textarea>
 | 
			
		||||
                <p class="comment"><a href="" @click.prevent="switchTextMode">[<span v-if="!textMode">输入内容</span><span v-else>上传文件</span>]</a>。文件内容中通常含有"-----BEGIN CERTIFICATE-----"类似的信息<span v-if="textMode">,可以直接拖动证书文件到输入框,留空表示不修改</span>。</p>
 | 
			
		||||
			</td>
 | 
			
		||||
@@ -33,7 +33,7 @@
 | 
			
		||||
		<tr v-show="isCA == 0">
 | 
			
		||||
			<td><span v-if="textMode">输入私钥内容</span><span v-else>选择私钥文件</span></td>
 | 
			
		||||
			<td>
 | 
			
		||||
                <input type="file" name="keyFile" accept="application/pkcs8" v-if="!textMode"/>
 | 
			
		||||
                <input type="file" name="keyFile" accept="application/pkcs8, .key" v-if="!textMode"/>
 | 
			
		||||
                <file-textarea class="wide-code" spellcheck="false" name="keyText" placeholder="-----BEGIN RSA PRIVATE KEY-----" v-if="textMode" style="font-size: 0.7em"></file-textarea>
 | 
			
		||||
                <p class="comment"><a href="" @click.prevent="switchTextMode">[<span v-if="!textMode">输入内容</span><span v-else>上传文件</span>]</a>。文件内容中通常含有"-----BEGIN RSA PRIVATE KEY-----"类似的信息<span v-if="textMode">,可以直接拖动证书文件到输入框,留空表示不修改</span>。</p>
 | 
			
		||||
			</td>
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										25
									
								
								web/views/@default/servers/certs/uploadBatchPopup.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								web/views/@default/servers/certs/uploadBatchPopup.html
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,25 @@
 | 
			
		||||
{$layout "layout_popup"}
 | 
			
		||||
 | 
			
		||||
<h3>批量上传证书</h3>
 | 
			
		||||
 | 
			
		||||
<form class="ui form" data-tea-success="successUpload" data-tea-action="$" data-tea-before="before" data-tea-done="done" data-tea-timeout="600">
 | 
			
		||||
    <csrf-token></csrf-token>
 | 
			
		||||
    <table class="ui table definition selectable">
 | 
			
		||||
        <tr>
 | 
			
		||||
            <td class="title">选择要上传的证书和私钥文件 *</td>
 | 
			
		||||
            <td>
 | 
			
		||||
                <input type="file" name="certFiles" accept="application/x-pem-file, application/pkcs10, application/x-pkcs12, application/x-x509-user-cert, application/x-x509-ca-cert, application/pkix-cert, application/pkcs8, .key, .pem" multiple="multiple"/>
 | 
			
		||||
                <p class="comment">点击后在弹出的文件选择框中支持多选。</p>
 | 
			
		||||
            </td>
 | 
			
		||||
        </tr>
 | 
			
		||||
        <tr>
 | 
			
		||||
            <td>所属用户</td>
 | 
			
		||||
            <td>
 | 
			
		||||
                <user-selector @change="changeUserId"></user-selector>
 | 
			
		||||
                <p class="comment">可选项,指定证书所属的用户;指定用户后,上传的证书管理员无法在管理系统查看,只能在用户系统查看。</p>
 | 
			
		||||
            </td>
 | 
			
		||||
        </tr>
 | 
			
		||||
    </table>
 | 
			
		||||
    <submit-btn v-show="!isRequesting">上传</submit-btn>
 | 
			
		||||
    <button class="ui button disabled " type="button" v-if="isRequesting">上传中...</button>
 | 
			
		||||
</form>
 | 
			
		||||
							
								
								
									
										30
									
								
								web/views/@default/servers/certs/uploadBatchPopup.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								web/views/@default/servers/certs/uploadBatchPopup.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,30 @@
 | 
			
		||||
Tea.context(function () {
 | 
			
		||||
	this.isRequesting = false
 | 
			
		||||
	this.userId = 0
 | 
			
		||||
 | 
			
		||||
	this.before = function () {
 | 
			
		||||
		this.isRequesting = true
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	this.done = function () {
 | 
			
		||||
		this.isRequesting = false
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	this.successUpload = function (resp) {
 | 
			
		||||
		if (resp.data.count > 0) {
 | 
			
		||||
			let msg = "html:成功上传" + resp.data.count + "个证书"
 | 
			
		||||
			if (this.userId > 0) {
 | 
			
		||||
				msg += "<br/>由于你选择了证书用户,所以只有此用户才能在用户系统中查看到这些证书。"
 | 
			
		||||
			}
 | 
			
		||||
			teaweb.success(msg, function () {
 | 
			
		||||
				NotifyPopup(resp)
 | 
			
		||||
			})
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
		NotifyPopup(resp)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	this.changeUserId = function (userId) {
 | 
			
		||||
		this.userId = userId
 | 
			
		||||
	}
 | 
			
		||||
})
 | 
			
		||||
@@ -24,7 +24,7 @@
 | 
			
		||||
		<tr>
 | 
			
		||||
			<td><span v-if="textMode">输入证书内容</span><span v-else>选择证书文件</span> *</td>
 | 
			
		||||
			<td>
 | 
			
		||||
				<input type="file" name="certFile" accept="application/x-pem-file, application/pkcs10, application/x-pkcs12, application/x-x509-user-cert, application/x-x509-ca-cert, application/pkix-cert" v-if="!textMode"/>
 | 
			
		||||
				<input type="file" name="certFile" accept="application/x-pem-file, application/pkcs10, application/x-pkcs12, application/x-x509-user-cert, application/x-x509-ca-cert, application/pkix-cert, .pem" v-if="!textMode"/>
 | 
			
		||||
                <file-textarea class="wide-code" ref="certTextField" spellcheck="false" name="certText" placeholder="-----BEGIN CERTIFICATE-----" v-if="textMode" style="font-size: 0.7em"></file-textarea>
 | 
			
		||||
                <p class="comment"><a href="" @click.prevent="switchTextMode">[<span v-if="!textMode">输入内容</span><span v-else>上传文件</span>]</a>。文件内容中通常含有"-----BEGIN CERTIFICATE-----"类似的信息<span v-if="textMode">,可以直接拖动证书文件到输入框</span>。</p>
 | 
			
		||||
			</td>
 | 
			
		||||
@@ -32,7 +32,7 @@
 | 
			
		||||
		<tr v-show="isCA == 0">
 | 
			
		||||
			<td><span v-if="textMode">输入私钥内容</span><span v-else>选择私钥文件</span> *</td>
 | 
			
		||||
			<td>
 | 
			
		||||
				<input type="file" name="keyFile" accept="application/pkcs8" v-if="!textMode"/>
 | 
			
		||||
				<input type="file" name="keyFile" accept="application/pkcs8, .key" v-if="!textMode"/>
 | 
			
		||||
                <file-textarea class="wide-code" spellcheck="false" name="keyText" placeholder="-----BEGIN RSA PRIVATE KEY-----" v-if="textMode" style="font-size: 0.7em"></file-textarea>
 | 
			
		||||
				<p class="comment"><a href="" @click.prevent="switchTextMode">[<span v-if="!textMode">输入内容</span><span v-else>上传文件</span>]</a>。文件内容中通常含有"-----BEGIN RSA PRIVATE KEY-----"类似的信息<span v-if="textMode">,可以直接拖动私钥文件到输入框</span>。</p>
 | 
			
		||||
			</td>
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user