Compare commits

..
Author SHA1 Message Date
guoyuqi 519a19d507 feat: ssl push multiple directories 2026-10-20 16:27:24 +08:00
19 changed files with 158 additions and 69 deletions
+12 -3
View File
@@ -212,10 +212,17 @@ func (w WebsiteCAService) ObtainSSL(req request.WebsiteCAObtain) (*model.Website
websiteSSL.Shell = req.Shell
}
if req.PushDir {
if !files.NewFileOp().Stat(req.Dir) {
dirs := certificateDirectories(req.Dir)
if len(dirs) == 0 {
return nil, buserr.New("ErrLinkPathNotFound")
}
websiteSSL.Dir = req.Dir
for _, dir := range dirs {
info, err := os.Stat(dir)
if err != nil || !info.IsDir() {
return nil, buserr.New("ErrLinkPathNotFound")
}
}
websiteSSL.Dir = strings.Join(dirs, "\n")
}
if req.Domains != "" {
domainArray := strings.Split(req.Domains, "\n")
@@ -348,7 +355,9 @@ func (w WebsiteCAService) ObtainSSL(req request.WebsiteCAObtain) (*model.Website
if websiteSSL.ExecShell {
workDir := global.Dir.DataDir
if websiteSSL.PushDir {
workDir = websiteSSL.Dir
if dirs := certificateDirectories(websiteSSL.Dir); len(dirs) > 0 {
workDir = dirs[0]
}
}
logger.Println(i18n.GetMsgByKey("ExecShellStart"))
if err = runShellScriptFile(workDir, websiteSSL.Shell, logger); err != nil {
+21 -8
View File
@@ -209,11 +209,17 @@ func (w WebsiteSSLService) Create(create request.WebsiteSSLCreate) (request.Webs
websiteSSL.Shell = create.Shell
}
if create.PushDir {
fileOP := files.NewFileOp()
if !fileOP.Stat(create.Dir) {
dirs := certificateDirectories(create.Dir)
if len(dirs) == 0 {
return res, buserr.New("ErrLinkPathNotFound")
}
websiteSSL.Dir = create.Dir
for _, dir := range dirs {
info, err := os.Stat(dir)
if err != nil || !info.IsDir() {
return res, buserr.New("ErrLinkPathNotFound")
}
}
websiteSSL.Dir = strings.Join(dirs, "\n")
}
setSSLPushConfig(&websiteSSL, create.PushNode, create.Nodes)
@@ -573,7 +579,9 @@ func (w WebsiteSSLService) obtainSSL(id uint, autoRenew bool) error {
if websiteSSL.ExecShell {
workDir := global.Dir.DataDir
if websiteSSL.PushDir {
workDir = websiteSSL.Dir
if dirs := certificateDirectories(websiteSSL.Dir); len(dirs) > 0 {
workDir = dirs[0]
}
}
printSSLLog(logger, "ExecShellStart", nil)
if err = runShellScriptFile(workDir, websiteSSL.Shell, logger); err != nil {
@@ -793,11 +801,16 @@ func (w WebsiteSSLService) Update(update request.WebsiteSSLUpdate) error {
}
if update.PushDir {
fileOP := files.NewFileOp()
if !fileOP.Stat(update.Dir) {
_ = fileOP.CreateDir(update.Dir, constant.DirPerm)
dirs := certificateDirectories(update.Dir)
if len(dirs) == 0 {
return buserr.New("ErrLinkPathNotFound")
}
updateParams["dir"] = update.Dir
for _, dir := range dirs {
if err := os.MkdirAll(dir, constant.DirPerm); err != nil {
return err
}
}
updateParams["dir"] = strings.Join(dirs, "\n")
}
var domains []string
if update.OtherDomains != "" {
+33 -18
View File
@@ -1529,26 +1529,41 @@ func checkWebsitePort(defaultHTTPsPort, port int, websiteType string) error {
return nil
}
// certificateDirectories accepts both legacy single paths and newline-separated paths.
func certificateDirectories(value string) []string {
var dirs []string
seen := make(map[string]bool)
for _, dir := range strings.Split(value, "\n") {
dir = strings.TrimSpace(dir)
if dir == "" {
continue
}
dir = filepath.Clean(dir)
if !seen[dir] {
seen[dir] = true
dirs = append(dirs, dir)
}
}
return dirs
}
func saveCertificateFile(websiteSSL *model.WebsiteSSL, logger *log.Logger) {
if websiteSSL.PushDir {
fileOp := files.NewFileOp()
var (
pushErr error
MsgMap = map[string]interface{}{"path": websiteSSL.Dir, "status": i18n.GetMsgByKey("Success")}
)
if pushErr = fileOp.SaveFile(path.Join(websiteSSL.Dir, "privkey.pem"), websiteSSL.PrivateKey, constant.FilePerm); pushErr != nil {
MsgMap["status"] = i18n.GetMsgByKey("Failed")
logger.Println(i18n.GetMsgWithMap("PushDirLog", MsgMap))
logger.Println("Push dir failed:" + pushErr.Error())
}
if pushErr = fileOp.SaveFile(path.Join(websiteSSL.Dir, "fullchain.pem"), websiteSSL.Pem, constant.FilePerm); pushErr != nil {
MsgMap["status"] = i18n.GetMsgByKey("Failed")
logger.Println(i18n.GetMsgWithMap("PushDirLog", MsgMap))
logger.Println("Push dir failed:" + pushErr.Error())
}
if pushErr == nil {
logger.Println(i18n.GetMsgWithMap("PushDirLog", MsgMap))
if !websiteSSL.PushDir {
return
}
fileOp := files.NewFileOp()
for _, dir := range certificateDirectories(websiteSSL.Dir) {
status := i18n.GetMsgByKey("Success")
for _, file := range []struct{ name, content string }{
{"privkey.pem", websiteSSL.PrivateKey},
{"fullchain.pem", websiteSSL.Pem},
} {
if err := fileOp.SaveFile(path.Join(dir, file.name), file.content, constant.FilePerm); err != nil {
status = i18n.GetMsgByKey("Failed")
logger.Printf("Push dir %s failed: %s", dir, err)
}
}
logger.Println(i18n.GetMsgWithMap("PushDirLog", map[string]interface{}{"path": dir, "status": status}))
}
}
+37 -10
View File
@@ -79,6 +79,16 @@
@row-click="handleRowClick"
class="cursor-pointer dialog-file-list"
>
<el-table-column v-if="isMultiple && form.dir" width="48">
<template #default="{ row }">
<el-checkbox
v-if="row.isDir && !row.isCreate"
:model-value="selectedPathSet.has(row.path)"
@click.stop
@change="toggleDirectory(row.path)"
/>
</template>
</el-table-column>
<el-table-column prop="name" show-overflow-tooltip fix>
<template #default="{ row }">
<svg-icon
@@ -131,6 +141,13 @@
</el-table>
</div>
<div class="file-list-bottom">
<el-checkbox
v-if="isMultiple && form.dir"
:model-value="selectedPathSet.has(req.path)"
@change="toggleDirectory(req.path)"
>
{{ req.path }}
</el-checkbox>
<div v-if="!isMultiple && selectRow?.path">
{{ $t('file.currentSelect') }}
<el-tooltip :content="selectRow.path" placement="top">
@@ -163,7 +180,11 @@
<template #footer>
<span class="dialog-footer">
<el-button @click="handleClose">{{ $t('commons.button.cancel') }}</el-button>
<el-button type="primary" @click="selectFile" :disabled="disBtn">
<el-button
type="primary"
@click="selectFile"
:disabled="disBtn || (isMultiple && selectRows.length === 0)"
>
{{ $t('commons.button.confirm') }}
</el-button>
</span>
@@ -174,7 +195,7 @@
<script lang="ts" setup>
import { File } from '@/api/interface/file';
import { computeDirSize, createFile, getFileContent, getFilesList, searchFavorite } from '@/api/modules/files';
import { nextTick, onUpdated, reactive, ref } from 'vue';
import { computed, nextTick, reactive, ref } from 'vue';
import i18n from '@/lang';
import { MsgSuccess, MsgWarning } from '@/utils/message';
import { useSearchableForSelect } from '@/views/host/file-management/hooks/searchable';
@@ -186,6 +207,7 @@ const paths = ref<string[]>([]);
const req = reactive({ path: '/', expand: true, page: 1, pageSize: 300, showHidden: true });
const selectRow = ref({ path: '', name: '' });
const selectRows = ref<Array<{ path: string; name: string }>>([]);
const selectedPathSet = computed(() => new Set(selectRows.value.map((item) => item.path)));
const isMultiple = ref(false);
const rowRefs = ref();
const open = ref(false);
@@ -211,6 +233,7 @@ interface DialogProps {
isAll?: boolean;
disabled?: boolean;
multiple?: boolean;
selectedPaths?: string[];
}
const acceptParams = (props: DialogProps): void => {
form.path = props.path || '/';
@@ -218,7 +241,8 @@ const acceptParams = (props: DialogProps): void => {
form.isAll = props.isAll || false;
form.disabled = props.disabled || false;
isMultiple.value = props.multiple || false;
selectRows.value = [];
disBtn.value = false;
selectRows.value = [...new Set(props.selectedPaths || [])].map((path) => ({ path, name: path }));
selectRow.value = { path: '', name: '' };
openPage();
req.path = form.path;
@@ -281,7 +305,7 @@ const openDir = async (row: File.File, column: any, event: any) => {
}
// Handle file click
const fullPath = (req.path === '/' ? req.path : req.path + '/') + row.name;
if (isMultiple.value) {
if (isMultiple.value && (form.isAll || !form.dir)) {
// Multiple mode: toggle selection
const index = selectRows.value.findIndex((r) => r.path === fullPath);
if (index > -1) {
@@ -473,22 +497,25 @@ const createFolder = async (row: any) => {
Object.assign(addItem, addForm);
loading.value = true;
createFile(addItem as File.FileCreate)
.then(() => {
.then(async () => {
row.isCreate = false;
disBtn.value = false;
MsgSuccess(i18n.global.t('commons.msg.createSuccess'));
await search(req);
})
.finally(() => {
loading.value = false;
});
};
onUpdated(() => {
if (form.path != '') {
req.path = form.path;
const toggleDirectory = (path: string) => {
const index = selectRows.value.findIndex((item) => item.path === path);
if (index >= 0) {
selectRows.value.splice(index, 1);
} else {
selectRows.value.push({ path, name: path });
}
search(req);
});
};
const removeSelected = (index: number) => {
selectRows.value.splice(index, 1);
+1 -1
View File
@@ -4252,7 +4252,7 @@ const message = {
pushDir: 'Push the certificate to the local directory',
dir: 'Directory',
pushDirHelper:
'Certificate file "fullchain.pem" and key file "privkey.pem" will be generated in this directory.',
'Select multiple directories or enter one directory per line. Each directory will receive "fullchain.pem" and "privkey.pem".',
organizationDetail: 'Organization details',
fromWebsite: 'From website',
dnsMauanlHelper:
+2 -1
View File
@@ -4301,7 +4301,8 @@ const message = {
domainHelper: 'Un dominio por línea, admite * y dirección IP',
pushDir: 'Guardar certificado en el directorio local',
dir: 'Directorio',
pushDirHelper: 'Se generarán los archivos "fullchain.pem" y "privkey.pem" en este directorio.',
pushDirHelper:
'Seleccione varios directorios o introduzca un directorio por línea. En cada directorio se generarán los archivos "fullchain.pem" y "privkey.pem".',
organizationDetail: 'Detalles de la organización',
fromWebsite: 'Desde el sitio web',
dnsMauanlHelper: 'En modo manual, haz clic en solicitar tras la creación para obtener el valor DNS',
+2 -1
View File
@@ -4214,7 +4214,8 @@ const message = {
domainHelper: 'هر خط یک نام دامنه، پشتیبانی از * و آدرس IP',
pushDir: 'ارسال گواهی به دایرکتوری محلی',
dir: 'دایرکتوری',
pushDirHelper: 'فایل گواهی "fullchain.pem" و فایل کلید "privkey.pem" در این دایرکتوری تولید می‌شوند.',
pushDirHelper:
'چند دایرکتوری انتخاب کنید یا در هر خط یک دایرکتوری وارد کنید. فایل‌های "fullchain.pem" و "privkey.pem" در هر دایرکتوری ایجاد می‌شوند.',
organizationDetail: 'جزئیات سازمان',
fromWebsite: 'از وب‌سایت',
dnsMauanlHelper:
+2 -1
View File
@@ -4237,7 +4237,8 @@ const message = {
domainHelper: '1行ごとに1つのドメイン名、 *およびIPアドレスをサポートします',
pushDir: '証明書をローカルディレクトリにプッシュします',
dir: 'ディレクトリ',
pushDirHelper: '証明書ファイル「FullChain.PEM」とキーファイル「Privkey.Pem」がこのディレクトリで生成されます。',
pushDirHelper:
'複数のディレクトリを選択するか、1行に1つずつディレクトリを入力してください。各ディレクトリに「fullchain.pem」と「privkey.pem」が生成されます。',
organizationDetail: '組織の詳細',
fromWebsite: 'ウェブサイトから',
dnsMauanlHelper: '手動解像度モードでは、作成後に適用ボタンをクリックしてDNS解像度値を取得する必要があります',
+2 -1
View File
@@ -4159,7 +4159,8 @@ const message = {
domainHelper: '줄당 하나의 도메인 이름, * 및 IP 주소 지원',
pushDir: '로컬 디렉토리로 인증서 푸시',
dir: '디렉토리',
pushDirHelper: '인증서 파일 "fullchain.pem" 및 키 파일 "privkey.pem"이 이 디렉토리에 생성됩니다.',
pushDirHelper:
'여러 디렉터리를 선택하거나 한 줄에 하나씩 입력하세요. 각 디렉터리에 "fullchain.pem"과 "privkey.pem" 파일이 생성됩니다.',
organizationDetail: '조직 세부 정보',
fromWebsite: '웹사이트에서 가져오기',
dnsMauanlHelper: '수동 해석 모드에서는 생성 후 신청 버튼을 클릭하여 DNS 해석 값을 얻어야 합니다.',
+2 -1
View File
@@ -4131,7 +4131,8 @@ const message = {
domainHelper: 'ໜຶ່ງໂດເມນຕໍ່ແຖວ, ຮອງຮັບ * ແລະ ທີ່ຢູ່ IP',
pushDir: 'ສົ່ງໃບຢັ້ງຢືນໄປຍັງໂຟນເດີໃນເຄື່ອງ',
dir: 'ໂຟນເດີ',
pushDirHelper: 'ໄຟລ໌ "fullchain.pem" ແລະ "privkey.pem" ຈະຖືກສ້າງໃນໂຟນເດີນີ້.',
pushDirHelper:
'ເລືອກຫຼາຍໂຟນເດີ ຫຼື ປ້ອນໜຶ່ງໂຟນເດີຕໍ່ແຖວ. ໄຟລ໌ "fullchain.pem" ແລະ "privkey.pem" ຈະຖືກສ້າງໃນແຕ່ລະໂຟນເດີ.',
organizationDetail: 'ລາຍລະອຽດອົງກອນ',
fromWebsite: 'ຈາກເວັບໄຊ',
dnsMauanlHelper: 'ໃນໂໝດຈັດການ DNS ດ້ວຍຕົນເອງ, ທ່ານຕ້ອງຄລິກປຸ່ມ "ຂໍໃບຢັ້ງຢືນ" ຫຼັງຈາກສ້າງເພື່ອຮັບຄ່າ DNS',
+2 -1
View File
@@ -4315,7 +4315,8 @@ const message = {
domainHelper: 'Satu nama domain setiap baris, menyokong * dan alamat IP',
pushDir: 'Tolakkan sijil ke direktori tempatan',
dir: 'Direktori',
pushDirHelper: 'Fail sijil "fullchain.pem" dan fail kunci "privkey.pem" akan dihasilkan dalam direktori ini.',
pushDirHelper:
'Pilih beberapa direktori atau masukkan satu direktori pada setiap baris. Fail "fullchain.pem" dan "privkey.pem" akan dihasilkan dalam setiap direktori.',
organizationDetail: 'Butiran organisasi',
fromWebsite: 'Daripada laman web',
dnsMauanlHelper:
+1 -1
View File
@@ -4335,7 +4335,7 @@ const message = {
pushDir: 'Enviar o certificado para o diretório local',
dir: 'Diretório',
pushDirHelper:
'O arquivo do certificado "fullchain.pem" e o arquivo de chave "privkey.pem" serão gerados neste diretório.',
'Selecione vários diretórios ou informe um diretório por linha. Os arquivos "fullchain.pem" e "privkey.pem" serão gerados em cada diretório.',
organizationDetail: 'Detalhes da organização',
fromWebsite: 'Do site',
dnsMauanlHelper:
+2 -1
View File
@@ -4302,7 +4302,8 @@ const message = {
domainHelper: 'Одно доменное имя в строке, поддерживает * и IP-адрес',
pushDir: 'Отправить сертификат в локальную директорию',
dir: 'Директория',
pushDirHelper: 'В этой директории будут созданы файл сертификата "fullchain.pem" и файл ключа "privkey.pem".',
pushDirHelper:
'Выберите несколько директорий или укажите по одной директории в строке. В каждой директории будут созданы файлы "fullchain.pem" и "privkey.pem".',
organizationDetail: 'Детали организации',
fromWebsite: 'С веб-сайта',
dnsMauanlHelper:
+1 -1
View File
@@ -4321,7 +4321,7 @@ const message = {
pushDir: 'Sertifikayı yerel dizine aktar',
dir: 'Dizin',
pushDirHelper:
'Sertifika dosyası "fullchain.pem" ve anahtar dosyası "privkey.pem" bu dizinde oluşturulacaktır.',
'Birden fazla dizin seçin veya her satıra bir dizin girin. Her dizinde "fullchain.pem" ve "privkey.pem" dosyaları oluşturulacaktır.',
organizationDetail: 'Organizasyon detayları',
fromWebsite: 'Web sitesinden',
dnsMauanlHelper:
+1 -1
View File
@@ -3970,7 +3970,7 @@ const message = {
domainHelper: '一行一個網域名稱,支援*和IP位址',
pushDir: '推送憑證到本機目錄',
dir: '目錄',
pushDirHelper: '會在此目錄下產生兩個檔案,憑證檔案:fullchain.pem 金鑰檔案:privkey.pem',
pushDirHelper: '支援選擇多個目錄,也可每行填寫一個目錄。每個目錄下都會產生 fullchain.pem 和 privkey.pem。',
organizationDetail: '機構詳情',
fromWebsite: '從網站取得',
dnsMauanlHelper: '手動解析模式需要在建立完之後點選申請按鈕取得 DNS 解析值',
+2 -1
View File
@@ -4015,7 +4015,8 @@ const message = {
domainHelper: '一行一个域名,支持*和IP地址',
pushDir: '推送证书到本地目录',
dir: '目录',
pushDirHelper: '会在此目录下生成两个文件,证书文件:fullchain.pem 密钥文件:privkey.pem',
pushDirHelper:
'支持选择多个目录,也可每行填写一个目录。每个目录下都会生成证书文件 fullchain.pem 和密钥文件 privkey.pem',
organizationDetail: '机构详情',
fromWebsite: '从网站中获取',
dnsMauanlHelper: '手动解析模式需要在创建完之后点击申请按钮获取 DNS 解析值',
@@ -41,14 +41,21 @@
<el-checkbox v-model="obtain.pushDir" :label="$t('ssl.pushDir')" />
</el-form-item>
<el-form-item :label="$t('ssl.dir')" prop="dir" v-if="obtain.pushDir">
<el-input v-model.trim="obtain.dir">
<template #prepend>
<el-button
icon="Folder"
@click="fileRef.acceptParams({ path: obtain.dir, dir: true })"
/>
</template>
</el-input>
<el-button
icon="Folder"
class="mb-2"
@click="
fileRef.acceptParams({
path: obtain.dir.split('\n')[0],
dir: true,
multiple: true,
selectedPaths: obtain.dir.split('\n').filter(Boolean),
})
"
>
{{ $t('ssl.dir') }}
</el-button>
<el-input v-model.trim="obtain.dir" type="textarea" :rows="3" />
<span class="input-help">
{{ $t('ssl.pushDirHelper') }}
</span>
@@ -157,8 +164,8 @@ const resetForm = () => {
obtain.value = initData();
};
const getPath = (dir: string) => {
obtain.value.dir = dir;
const getPath = (dirs: string[]) => {
obtain.value.dir = dirs.join('\n');
};
const submit = async (formEl: FormInstance | undefined) => {
@@ -127,11 +127,21 @@
<el-checkbox v-model="ssl.pushDir" :label="$t('ssl.pushDir')" />
</el-form-item>
<el-form-item :label="$t('ssl.dir')" prop="dir" v-if="ssl.pushDir">
<el-input v-model.trim="ssl.dir">
<template #prepend>
<el-button icon="Folder" @click="fileRef.acceptParams({ path: ssl.dir, dir: true })" />
</template>
</el-input>
<el-button
icon="Folder"
class="mb-2"
@click="
fileRef.acceptParams({
path: ssl.dir.split('\n')[0],
dir: true,
multiple: true,
selectedPaths: ssl.dir.split('\n').filter(Boolean),
})
"
>
{{ $t('ssl.dir') }}
</el-button>
<el-input v-model.trim="ssl.dir" type="textarea" :rows="3" />
<span class="input-help">
{{ $t('ssl.pushDirHelper') }}
</span>
@@ -347,8 +357,8 @@ const acceptParams = (op: string, websiteSSL?: Website.SSLDTO) => {
open.value = true;
};
const getPath = (dir: string) => {
ssl.value.dir = dir;
const getPath = (dirs: string[]) => {
ssl.value.dir = dirs.join('\n');
};
const getAcmeAccounts = async () => {
@@ -44,7 +44,7 @@
{{ ssl.acmeAccount.email }}
</el-descriptions-item>
<el-descriptions-item :label="$t('ssl.pushDir')" v-if="ssl.pushDir">
{{ ssl.dir }}
<div v-for="dir in ssl.dir.split('\n')" :key="dir">{{ dir }}</div>
</el-descriptions-item>
</el-descriptions>
</div>