Compare commits

...

9 Commits

Author SHA1 Message Date
yoan
ac14924a73 update ui 2024-09-13 07:55:25 +08:00
yoan
7550aec904 When adding a domain, you can also add a custom email address. 2024-09-13 07:36:26 +08:00
yoan
139a6980ac Add godaddy provider 2024-09-12 21:59:50 +08:00
yoan
d5a6411e26 Merge branch 'main' of github.com:usual2970/certimate 2024-09-12 06:11:34 +08:00
yoan
1fcd9897be update README 2024-09-12 06:11:15 +08:00
usual2970
99d4bf2624 Update README.md 2024-09-11 08:03:18 +08:00
yoan
844347acf9 fix file select 2024-09-11 07:35:19 +08:00
usual2970
eeae9b4405 Merge pull request #16 from gexin1/main
fix: 首页展示问题和授权管理弹窗无法滚动
2024-09-11 06:37:38 +08:00
river
feef94f873 fix: 1. 修复首页控制面板在md和2xl的时候会超出屏幕
2.修复授权管理-添加授权选择ssh 弹窗过长超出屏幕无法滚动
2024-09-10 22:56:54 +08:00
34 changed files with 1236 additions and 503 deletions

View File

@@ -4,12 +4,12 @@
做个人产品或在小企业负责运维的同学,需要管理多个域名,要给域名申请证书。但手动申请证书有以下缺点:
1. 😱麻烦:申请、部署证书虽不复杂,但也挺麻烦的,尤其是维护多个域名的时候。
1. 😱麻烦:申请、部署证书虽不困难,但也挺麻烦的,尤其是维护多个域名的时候。
2. 😭易忘当前免费证书有效期仅90天这就要求定期操作增加工作量的同时也很容易忘掉导致网站无法访问。
Certimate 就是为了解决上述问题而产生的,它具有以下特点:
1. 操作简单:自动申请、部署 SSL 证书,并在证书即将过期时自动续期,无需人工干预。
1. 操作简单:自动申请、部署、续期 SSL 证书,全程无需人工干预。
2. 支持私有部署部署方法简单只需下载二进制文件执行即可。二进制文件、docker 镜像全部用 github actions 生成,过程透明,可自行审计。
3. 数据安全:由于是私有部署,所有数据均存储在本地,不会保存在服务商的服务器,确保数据的安全性。
@@ -29,7 +29,7 @@ Certimate 旨在为用户提供一个安全、简便的 SSL 证书管理解决
- [2. dns 服务商授权信息](#2-dns-服务商授权信息)
- [3. 部署服务商授权信息](#3-部署服务商授权信息)
- [六、常见问题](#六常见问题)
- [七、许可证](#七许可证)
- [七、贡献](#七贡献)
@@ -162,6 +162,15 @@ Q: 自动续期证书?
## 七、许可证
## 七、贡献
Certimate 是一个免费且开源的项目,采用 [MIT 开源协议](LICENSE.md)。你可以使用它做任何你想做的事,甚至把它当作一个付费服务提供给用户。
你可以通过以下方式来支持 Certimate 的开发:
* 提交代码:如果你发现了 bug 或有新的功能需求,而你又有相关经验,可以提交代码给我们。
* 提交 issue功能建议或者 bug 可以[提交 issue](https://github.com/usual2970/certimate/issues) 给我们。
支持更多服务商、UI 的优化改进、BUG 修复、文档完善等,欢迎大家提交 PR。
Certimate 采用 MIT 许可证,详情请查看 [LICENSE](LICENSE.md) 文件。

View File

@@ -20,8 +20,11 @@ const (
configTypeAliyun = "aliyun"
configTypeCloudflare = "cloudflare"
configTypeNamesilo = "namesilo"
configTypeGodaddy = "godaddy"
)
const defaultEmail = "536464346@qq.com"
type Certificate struct {
CertUrl string `json:"certUrl"`
CertStableUrl string `json:"certStableUrl"`
@@ -59,8 +62,12 @@ type Applicant interface {
func Get(record *models.Record) (Applicant, error) {
access := record.ExpandedOne("access")
email := record.GetString("email")
if email == "" {
email = defaultEmail
}
option := &ApplyOption{
Email: "536464346@qq.com",
Email: email,
Domain: record.GetString("domain"),
Access: access.GetString("config"),
}
@@ -73,6 +80,8 @@ func Get(record *models.Record) (Applicant, error) {
return NewCloudflare(option), nil
case configTypeNamesilo:
return NewNamesilo(option), nil
case configTypeGodaddy:
return NewGodaddy(option), nil
default:
return nil, errors.New("unknown config type")
}

View File

@@ -0,0 +1,35 @@
package applicant
import (
"certimate/internal/domain"
"encoding/json"
"os"
godaddyProvider "github.com/go-acme/lego/v4/providers/dns/godaddy"
)
type godaddy struct {
option *ApplyOption
}
func NewGodaddy(option *ApplyOption) Applicant {
return &godaddy{
option: option,
}
}
func (a *godaddy) Apply() (*Certificate, error) {
access := &domain.GodaddyAccess{}
json.Unmarshal([]byte(a.option.Access), access)
os.Setenv("GODADDY_API_KEY", access.ApiKey)
os.Setenv("GODADDY_API_SECRET", access.ApiKey)
dnsProvider, err := godaddyProvider.NewDNSProvider()
if err != nil {
return nil, err
}
return apply(a.option, dnsProvider)
}

View File

@@ -22,3 +22,8 @@ type QiniuAccess struct {
type NameSiloAccess struct {
ApiKey string `json:"apiKey"`
}
type GodaddyAccess struct {
ApiKey string `json:"apiKey"`
ApiSecret string `json:"apiSecret"`
}

View File

@@ -12,73 +12,10 @@ import (
func init() {
m.Register(func(db dbx.Builder) error {
jsonData := `[
{
"id": "_pb_users_auth_",
"created": "2024-07-29 09:44:56.398Z",
"updated": "2024-09-07 07:42:28.389Z",
"name": "users",
"type": "auth",
"system": false,
"schema": [
{
"system": false,
"id": "users_name",
"name": "name",
"type": "text",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
},
{
"system": false,
"id": "users_avatar",
"name": "avatar",
"type": "file",
"required": false,
"presentable": false,
"unique": false,
"options": {
"mimeTypes": [
"image/jpeg",
"image/png",
"image/svg+xml",
"image/gif",
"image/webp"
],
"thumbs": null,
"maxSelect": 1,
"maxSize": 5242880,
"protected": false
}
}
],
"indexes": [],
"listRule": "id = @request.auth.id",
"viewRule": "id = @request.auth.id",
"createRule": "",
"updateRule": "id = @request.auth.id",
"deleteRule": "id = @request.auth.id",
"options": {
"allowEmailAuth": true,
"allowOAuth2Auth": true,
"allowUsernameAuth": true,
"exceptEmailDomains": null,
"manageRule": null,
"minPasswordLength": 8,
"onlyEmailDomains": null,
"onlyVerified": false,
"requireEmail": false
}
},
{
"id": "z3p974ainxjqlvs",
"created": "2024-07-29 10:02:48.334Z",
"updated": "2024-09-07 07:42:28.389Z",
"updated": "2024-09-12 13:09:54.500Z",
"name": "domains",
"type": "base",
"system": false,
@@ -333,7 +270,7 @@ func init() {
{
"id": "4yzbv8urny5ja1e",
"created": "2024-07-29 10:04:39.685Z",
"updated": "2024-09-10 12:51:41.405Z",
"updated": "2024-09-12 13:18:00.093Z",
"name": "access",
"type": "base",
"system": false,
@@ -381,7 +318,8 @@ func init() {
"webhook",
"cloudflare",
"qiniu",
"namesilo"
"namesilo",
"godaddy"
]
}
},
@@ -397,6 +335,23 @@ func init() {
"min": "",
"max": ""
}
},
{
"system": false,
"id": "hsxcnlvd",
"name": "usage",
"type": "select",
"required": false,
"presentable": false,
"unique": false,
"options": {
"maxSelect": 1,
"values": [
"apply",
"deploy",
"all"
]
}
}
],
"indexes": [
@@ -412,7 +367,7 @@ func init() {
{
"id": "0a1o4e6sstp694f",
"created": "2024-07-30 06:30:27.801Z",
"updated": "2024-09-07 07:42:28.390Z",
"updated": "2024-09-12 13:09:54.500Z",
"name": "deployments",
"type": "base",
"system": false,
@@ -493,6 +448,69 @@ func init() {
"updateRule": null,
"deleteRule": null,
"options": {}
},
{
"id": "_pb_users_auth_",
"created": "2024-09-12 13:09:54.234Z",
"updated": "2024-09-12 13:09:54.500Z",
"name": "users",
"type": "auth",
"system": false,
"schema": [
{
"system": false,
"id": "users_name",
"name": "name",
"type": "text",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
},
{
"system": false,
"id": "users_avatar",
"name": "avatar",
"type": "file",
"required": false,
"presentable": false,
"unique": false,
"options": {
"mimeTypes": [
"image/jpeg",
"image/png",
"image/svg+xml",
"image/gif",
"image/webp"
],
"thumbs": null,
"maxSelect": 1,
"maxSize": 5242880,
"protected": false
}
}
],
"indexes": [],
"listRule": "id = @request.auth.id",
"viewRule": "id = @request.auth.id",
"createRule": "",
"updateRule": "id = @request.auth.id",
"deleteRule": "id = @request.auth.id",
"options": {
"allowEmailAuth": true,
"allowOAuth2Auth": true,
"allowUsernameAuth": true,
"exceptEmailDomains": null,
"manageRule": null,
"minPasswordLength": 8,
"onlyEmailDomains": null,
"onlyVerified": false,
"requireEmail": false
}
}
]`

View File

@@ -0,0 +1,20 @@
package migrations
import (
"github.com/pocketbase/dbx"
m "github.com/pocketbase/pocketbase/migrations"
)
func init() {
m.Register(func(db dbx.Builder) error {
// add up queries...
db.NewQuery("update access set usage='all' where configType in ('aliyun', 'tencent')").Execute()
db.NewQuery("update access set usage='deploy' where configType in ('ssh', 'webhook','qiniu')").Execute()
db.NewQuery("update access set usage='apply' where configType in ('cloudflare','namesilo','godaddy')").Execute()
return nil
}, func(db dbx.Builder) error {
// add down queries...
return nil
})
}

View File

@@ -12,73 +12,10 @@ import (
func init() {
m.Register(func(db dbx.Builder) error {
jsonData := `[
{
"id": "_pb_users_auth_",
"created": "2024-07-29 09:44:56.398Z",
"updated": "2024-09-02 14:02:40.191Z",
"name": "users",
"type": "auth",
"system": false,
"schema": [
{
"system": false,
"id": "users_name",
"name": "name",
"type": "text",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
},
{
"system": false,
"id": "users_avatar",
"name": "avatar",
"type": "file",
"required": false,
"presentable": false,
"unique": false,
"options": {
"mimeTypes": [
"image/jpeg",
"image/png",
"image/svg+xml",
"image/gif",
"image/webp"
],
"thumbs": null,
"maxSelect": 1,
"maxSize": 5242880,
"protected": false
}
}
],
"indexes": [],
"listRule": "id = @request.auth.id",
"viewRule": "id = @request.auth.id",
"createRule": "",
"updateRule": "id = @request.auth.id",
"deleteRule": "id = @request.auth.id",
"options": {
"allowEmailAuth": true,
"allowOAuth2Auth": true,
"allowUsernameAuth": true,
"exceptEmailDomains": null,
"manageRule": null,
"minPasswordLength": 8,
"onlyEmailDomains": null,
"onlyVerified": false,
"requireEmail": false
}
},
{
"id": "z3p974ainxjqlvs",
"created": "2024-07-29 10:02:48.334Z",
"updated": "2024-09-04 15:10:19.440Z",
"updated": "2024-09-12 23:13:12.119Z",
"name": "domains",
"type": "base",
"system": false,
@@ -97,6 +34,19 @@ func init() {
"pattern": ""
}
},
{
"system": false,
"id": "ukkhuw85",
"name": "email",
"type": "email",
"required": false,
"presentable": false,
"unique": false,
"options": {
"exceptDomains": null,
"onlyDomains": null
}
},
{
"system": false,
"id": "v98eebqq",
@@ -333,7 +283,7 @@ func init() {
{
"id": "4yzbv8urny5ja1e",
"created": "2024-07-29 10:04:39.685Z",
"updated": "2024-09-04 15:09:46.789Z",
"updated": "2024-09-12 23:08:52.810Z",
"name": "access",
"type": "base",
"system": false,
@@ -380,7 +330,9 @@ func init() {
"ssh",
"webhook",
"cloudflare",
"qiniu"
"qiniu",
"namesilo",
"godaddy"
]
}
},
@@ -396,6 +348,23 @@ func init() {
"min": "",
"max": ""
}
},
{
"system": false,
"id": "hsxcnlvd",
"name": "usage",
"type": "select",
"required": false,
"presentable": false,
"unique": false,
"options": {
"maxSelect": 1,
"values": [
"apply",
"deploy",
"all"
]
}
}
],
"indexes": [
@@ -411,7 +380,7 @@ func init() {
{
"id": "0a1o4e6sstp694f",
"created": "2024-07-30 06:30:27.801Z",
"updated": "2024-09-02 14:02:40.191Z",
"updated": "2024-09-12 23:08:52.810Z",
"name": "deployments",
"type": "base",
"system": false,
@@ -492,6 +461,114 @@ func init() {
"updateRule": null,
"deleteRule": null,
"options": {}
},
{
"id": "_pb_users_auth_",
"created": "2024-09-12 13:09:54.234Z",
"updated": "2024-09-12 23:08:52.811Z",
"name": "users",
"type": "auth",
"system": false,
"schema": [
{
"system": false,
"id": "users_name",
"name": "name",
"type": "text",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
},
{
"system": false,
"id": "users_avatar",
"name": "avatar",
"type": "file",
"required": false,
"presentable": false,
"unique": false,
"options": {
"mimeTypes": [
"image/jpeg",
"image/png",
"image/svg+xml",
"image/gif",
"image/webp"
],
"thumbs": null,
"maxSelect": 1,
"maxSize": 5242880,
"protected": false
}
}
],
"indexes": [],
"listRule": "id = @request.auth.id",
"viewRule": "id = @request.auth.id",
"createRule": "",
"updateRule": "id = @request.auth.id",
"deleteRule": "id = @request.auth.id",
"options": {
"allowEmailAuth": true,
"allowOAuth2Auth": true,
"allowUsernameAuth": true,
"exceptEmailDomains": null,
"manageRule": null,
"minPasswordLength": 8,
"onlyEmailDomains": null,
"onlyVerified": false,
"requireEmail": false
}
},
{
"id": "dy6ccjb60spfy6p",
"created": "2024-09-12 23:12:21.677Z",
"updated": "2024-09-12 23:19:09.110Z",
"name": "settings",
"type": "base",
"system": false,
"schema": [
{
"system": false,
"id": "1tcmdsdf",
"name": "name",
"type": "text",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
},
{
"system": false,
"id": "f9wyhypi",
"name": "content",
"type": "json",
"required": false,
"presentable": false,
"unique": false,
"options": {
"maxSize": 2000000
}
}
],
"indexes": [
"CREATE UNIQUE INDEX ` + "`" + `idx_RO7X9Vw` + "`" + ` ON ` + "`" + `settings` + "`" + ` (` + "`" + `name` + "`" + `)"
],
"listRule": null,
"viewRule": null,
"createRule": null,
"updateRule": null,
"deleteRule": null,
"options": {}
}
]`

View File

File diff suppressed because one or more lines are too long

284
ui/dist/assets/index-BRoqOo1T.js vendored Normal file
View File

File diff suppressed because one or more lines are too long

1
ui/dist/assets/index-Cg0yCJnh.css vendored Normal file
View File

File diff suppressed because one or more lines are too long

View File

File diff suppressed because one or more lines are too long

5
ui/dist/imgs/providers/godaddy.svg vendored Normal file
View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
<circle cx="512" cy="512" r="512" style="fill:#1bdbdb"/>
<path d="M697.6 315.9c-53.2-33.2-123.3-25.3-185.6 13.9-62.4-39.3-132.4-47.2-185.6-13.9-84.1 52.5-94.3 187.8-22.8 302.2 52.7 84.3 135.1 133.7 208.4 132.8 73.3.9 155.7-48.5 208.4-132.8 71.5-114.4 61.3-249.7-22.8-302.2M342.2 594c-15-24.1-26.1-49.5-33-75.5-6.5-24.5-8.9-48.5-7.1-71.2 3.2-42.3 20.4-75.2 48.4-92.7s65.2-18.6 104.5-2.9c5.9 2.4 11.8 5.1 17.6 8.1-21 19-40.3 41.9-56.7 68.1-43.4 69.5-56.6 146.7-41.5 208.4-11.8-12.8-22.6-27-32.2-42.3m372.6-75.6c-6.9 26.1-17.9 51.5-33 75.5-9.6 15.4-20.4 29.5-32.3 42.3 13.5-55.2 4.4-122.9-28.9-186.3-2.3-4.5-7.7-5.9-12-3.3l-103.5 64.7c-4 2.5-5.2 7.7-2.7 11.7l15.2 24.3c2.5 4 7.7 5.2 11.7 2.7l67.1-41.9c2.2 6.4 4.3 12.9 6 19.5 6.5 24.5 8.9 48.5 7.1 71.2-3.2 42.3-20.4 75.2-48.4 92.7-14 8.8-30.3 13.4-48 13.9h-2.2c-17.7-.5-34-5.1-48-13.9-28-17.5-45.2-50.4-48.4-92.7-1.7-22.7.7-46.7 7.1-71.2 6.8-26.1 17.9-51.5 33-75.5 15-24.1 33-45.2 53.4-62.8 19.2-16.6 39.7-29.2 60.9-37.6 39.4-15.7 76.5-14.6 104.5 2.9s45.2 50.4 48.4 92.7c1.8 22.6-.6 46.6-7 71.1" style="fill:#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

4
ui/dist/index.html vendored
View File

@@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Certimate - Your Trusted SSL Automation Partner</title>
<script type="module" crossorigin src="/assets/index-BKUIxIk5.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Kh_0Jotc.css">
<script type="module" crossorigin src="/assets/index-BRoqOo1T.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Cg0yCJnh.css">
</head>
<body class="bg-background">
<div id="root"></div>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
<circle cx="512" cy="512" r="512" style="fill:#1bdbdb"/>
<path d="M697.6 315.9c-53.2-33.2-123.3-25.3-185.6 13.9-62.4-39.3-132.4-47.2-185.6-13.9-84.1 52.5-94.3 187.8-22.8 302.2 52.7 84.3 135.1 133.7 208.4 132.8 73.3.9 155.7-48.5 208.4-132.8 71.5-114.4 61.3-249.7-22.8-302.2M342.2 594c-15-24.1-26.1-49.5-33-75.5-6.5-24.5-8.9-48.5-7.1-71.2 3.2-42.3 20.4-75.2 48.4-92.7s65.2-18.6 104.5-2.9c5.9 2.4 11.8 5.1 17.6 8.1-21 19-40.3 41.9-56.7 68.1-43.4 69.5-56.6 146.7-41.5 208.4-11.8-12.8-22.6-27-32.2-42.3m372.6-75.6c-6.9 26.1-17.9 51.5-33 75.5-9.6 15.4-20.4 29.5-32.3 42.3 13.5-55.2 4.4-122.9-28.9-186.3-2.3-4.5-7.7-5.9-12-3.3l-103.5 64.7c-4 2.5-5.2 7.7-2.7 11.7l15.2 24.3c2.5 4 7.7 5.2 11.7 2.7l67.1-41.9c2.2 6.4 4.3 12.9 6 19.5 6.5 24.5 8.9 48.5 7.1 71.2-3.2 42.3-20.4 75.2-48.4 92.7-14 8.8-30.3 13.4-48 13.9h-2.2c-17.7-.5-34-5.1-48-13.9-28-17.5-45.2-50.4-48.4-92.7-1.7-22.7.7-46.7 7.1-71.2 6.8-26.1 17.9-51.5 33-75.5 15-24.1 33-45.2 53.4-62.8 19.2-16.6 39.7-29.2 60.9-37.6 39.4-15.7 76.5-14.6 104.5 2.9s45.2 50.4 48.4 92.7c1.8 22.6-.6 46.6-7 71.1" style="fill:#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -14,7 +14,7 @@ import {
} from "@/components/ui/form";
import { Button } from "@/components/ui/button";
import { Access, accessFormType, AliyunConfig } from "@/domain/access";
import { Access, accessFormType, AliyunConfig, getUsageByConfigType } from "@/domain/access";
import { save } from "@/repository/access";
import { useConfig } from "@/providers/config";
@@ -59,6 +59,7 @@ const AccessAliyunForm = ({
id: data.id as string,
name: data.name,
configType: data.configType,
usage: getUsageByConfigType(data.configType),
config: {
accessKeyId: data.accessKeyId,
accessKeySecret: data.accessSecretId,

View File

@@ -14,7 +14,7 @@ import {
} from "@/components/ui/form";
import { Button } from "@/components/ui/button";
import { Access, accessFormType, CloudflareConfig } from "@/domain/access";
import { Access, accessFormType, CloudflareConfig, getUsageByConfigType } from "@/domain/access";
import { save } from "@/repository/access";
import { useConfig } from "@/providers/config";
import { ClientResponseError } from "pocketbase";
@@ -56,6 +56,7 @@ const AccessCloudflareForm = ({
id: data.id as string,
name: data.name,
configType: data.configType,
usage: getUsageByConfigType(data.configType),
config: {
dnsApiToken: data.dnsApiToken,
},

View File

@@ -5,6 +5,7 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useState } from "react";
@@ -30,6 +31,7 @@ import {
import AccessCloudflareForm from "./AccessCloudflareForm";
import AccessQiniuForm from "./AccessQiniuForm";
import AccessNamesiloForm from "./AccessNamesiloForm";
import AccessGodaddyFrom from "./AccessGodaddyForm";
type TargetConfigEditProps = {
op: "add" | "edit";
@@ -121,6 +123,16 @@ export function AccessEdit({
/>
);
break;
case "godaddy":
form = (
<AccessGodaddyFrom
data={data}
onAfterReq={() => {
setOpen(false);
}}
/>
);
break;
}
const getOptionCls = (val: string) => {
@@ -136,44 +148,46 @@ export function AccessEdit({
<DialogHeader>
<DialogTitle>{op == "add" ? "添加" : "编辑"}</DialogTitle>
</DialogHeader>
<div className="container">
<Label></Label>
<ScrollArea className="max-h-[80vh]">
<div className="container py-3">
<Label></Label>
<Select
onValueChange={(val) => {
console.log(val);
setConfigType(val);
}}
defaultValue={configType}
>
<SelectTrigger className="mt-3">
<SelectValue placeholder="请选择服务商" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel></SelectLabel>
{typeKeys.map((key) => (
<SelectItem value={key} key={key}>
<div
className={cn(
"flex items-center space-x-2 rounded cursor-pointer",
getOptionCls(key)
)}
>
<img
src={accessTypeMap.get(key)?.[1]}
className="h-6 w-6"
/>
<div>{accessTypeMap.get(key)?.[0]}</div>
</div>
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Select
onValueChange={(val) => {
console.log(val);
setConfigType(val);
}}
defaultValue={configType}
>
<SelectTrigger className="mt-3">
<SelectValue placeholder="请选择服务商" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel></SelectLabel>
{typeKeys.map((key) => (
<SelectItem value={key} key={key}>
<div
className={cn(
"flex items-center space-x-2 rounded cursor-pointer",
getOptionCls(key)
)}
>
<img
src={accessTypeMap.get(key)?.[1]}
className="h-6 w-6"
/>
<div>{accessTypeMap.get(key)?.[0]}</div>
</div>
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
{form}
</div>
{form}
</div>
</ScrollArea>
</DialogContent>
</Dialog>
);

View File

@@ -0,0 +1,198 @@
import { Input } from "@/components/ui/input";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import z from "zod";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Button } from "@/components/ui/button";
import {
Access,
accessFormType,
getUsageByConfigType,
GodaddyConfig,
} from "@/domain/access";
import { save } from "@/repository/access";
import { useConfig } from "@/providers/config";
import { ClientResponseError } from "pocketbase";
import { PbErrorData } from "@/domain/base";
const AccessGodaddyFrom = ({
data,
onAfterReq,
}: {
data?: Access;
onAfterReq: () => void;
}) => {
const { addAccess, updateAccess } = useConfig();
const formSchema = z.object({
id: z.string().optional(),
name: z.string().min(1).max(64),
configType: accessFormType,
apiKey: z.string().min(1).max(64),
apiSecret: z.string().min(1).max(64),
});
let config: GodaddyConfig = {
apiKey: "",
apiSecret: "",
};
if (data) config = data.config as GodaddyConfig;
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
id: data?.id,
name: data?.name,
configType: "godaddy",
apiKey: config.apiKey,
apiSecret: config.apiSecret,
},
});
const onSubmit = async (data: z.infer<typeof formSchema>) => {
console.log(data);
const req: Access = {
id: data.id as string,
name: data.name,
configType: data.configType,
usage: getUsageByConfigType(data.configType),
config: {
apiKey: data.apiKey,
apiSecret: data.apiSecret,
},
};
try {
const rs = await save(req);
onAfterReq();
req.id = rs.id;
req.created = rs.created;
req.updated = rs.updated;
if (data.id) {
updateAccess(req);
return;
}
addAccess(req);
} catch (e) {
const err = e as ClientResponseError;
Object.entries(err.response.data as PbErrorData).forEach(
([key, value]) => {
form.setError(key as keyof z.infer<typeof formSchema>, {
type: "manual",
message: value.message,
});
}
);
}
};
return (
<>
<div className="max-w-[35em] mx-auto mt-10">
<Form {...form}>
<form
onSubmit={(e) => {
console.log(e);
e.stopPropagation();
form.handleSubmit(onSubmit)(e);
}}
className="space-y-8"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input placeholder="请输入授权名称" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="id"
render={({ field }) => (
<FormItem className="hidden">
<FormLabel></FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="configType"
render={({ field }) => (
<FormItem className="hidden">
<FormLabel></FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="apiKey"
render={({ field }) => (
<FormItem>
<FormLabel>GODADDY_API_KEY</FormLabel>
<FormControl>
<Input placeholder="请输入GODADDY_API_KEY" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="apiSecret"
render={({ field }) => (
<FormItem>
<FormLabel>GODADDY_API_SECRET</FormLabel>
<FormControl>
<Input placeholder="请输入GODADDY_API_SECRET" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end">
<Button type="submit"></Button>
</div>
</form>
</Form>
</div>
</>
);
};
export default AccessGodaddyFrom;

View File

@@ -14,7 +14,7 @@ import {
} from "@/components/ui/form";
import { Button } from "@/components/ui/button";
import { Access, accessFormType, NamesiloConfig } from "@/domain/access";
import { Access, accessFormType, getUsageByConfigType, NamesiloConfig } from "@/domain/access";
import { save } from "@/repository/access";
import { useConfig } from "@/providers/config";
import { ClientResponseError } from "pocketbase";
@@ -56,6 +56,7 @@ const AccessNamesiloForm = ({
id: data.id as string,
name: data.name,
configType: data.configType,
usage: getUsageByConfigType(data.configType),
config: {
apiKey: data.apiKey,
},

View File

@@ -14,7 +14,7 @@ import {
} from "@/components/ui/form";
import { Button } from "@/components/ui/button";
import { Access, accessFormType, QiniuConfig } from "@/domain/access";
import { Access, accessFormType, getUsageByConfigType, QiniuConfig } from "@/domain/access";
import { save } from "@/repository/access";
import { useConfig } from "@/providers/config";
@@ -59,6 +59,7 @@ const AccessQiniuForm = ({
id: data.id as string,
name: data.name,
configType: data.configType,
usage: getUsageByConfigType(data.configType),
config: {
accessKey: data.accessKey,
secretKey: data.secretKey,

View File

@@ -1,4 +1,4 @@
import { Access, accessFormType, SSHConfig } from "@/domain/access";
import { Access, accessFormType, getUsageByConfigType, SSHConfig } from "@/domain/access";
import { useConfig } from "@/providers/config";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
@@ -18,6 +18,7 @@ import { save } from "@/repository/access";
import { ClientResponseError } from "pocketbase";
import { PbErrorData } from "@/domain/base";
import { readFileContent } from "@/lib/file";
import { useRef, useState } from "react";
const AccessSSHForm = ({
data,
@@ -27,6 +28,11 @@ const AccessSSHForm = ({
onAfterReq: () => void;
}) => {
const { addAccess, updateAccess } = useConfig();
const fileInputRef = useRef<HTMLInputElement | null>(null);
const [fileName, setFileName] = useState("");
const formSchema = z.object({
id: z.string().optional(),
name: z.string().min(1).max(64),
@@ -38,7 +44,7 @@ const AccessSSHForm = ({
username: z.string().min(1).max(64),
password: z.string().min(0).max(64),
key: z.string().min(0).max(20480),
keyFile: z.string().optional(),
keyFile: z.any().optional(),
command: z.string().min(1).max(2048),
certPath: z.string().min(0).max(2048),
keyPath: z.string().min(0).max(2048),
@@ -81,6 +87,7 @@ const AccessSSHForm = ({
id: data.id as string,
name: data.name,
configType: data.configType,
usage: getUsageByConfigType(data.configType),
config: {
host: data.host,
port: data.port,
@@ -127,9 +134,16 @@ const AccessSSHForm = ({
) => {
const file = event.target.files?.[0];
if (!file) return;
const content = await readFileContent(file);
const savedFile = file;
setFileName(savedFile.name);
const content = await readFileContent(savedFile);
form.setValue("key", content);
form.setValue("keyFile", "");
};
const handleSelectFileClick = () => {
console.log(fileInputRef.current);
fileInputRef.current?.click();
};
return (
@@ -279,12 +293,26 @@ const AccessSSHForm = ({
<FormItem>
<FormLabel>Key使</FormLabel>
<FormControl>
<Input
placeholder="请输入Key"
{...field}
type="file"
onChange={handleFileChange}
/>
<div>
<Button
type={"button"}
variant={"secondary"}
size={"sm"}
className="w-48"
onClick={handleSelectFileClick}
>
{fileName ? fileName : "请选择文件"}
</Button>
<Input
placeholder="请输入Key"
{...field}
ref={fileInputRef}
className="hidden"
hidden
type="file"
onChange={handleFileChange}
/>
</div>
</FormControl>
<FormMessage />

View File

@@ -14,7 +14,7 @@ import {
} from "@/components/ui/form";
import { Button } from "@/components/ui/button";
import { Access, accessFormType, TencentConfig } from "@/domain/access";
import { Access, accessFormType, getUsageByConfigType, TencentConfig } from "@/domain/access";
import { save } from "@/repository/access";
import { useConfig } from "@/providers/config";
import { ClientResponseError } from "pocketbase";
@@ -58,6 +58,7 @@ const AccessTencentForm = ({
id: data.id as string,
name: data.name,
configType: data.configType,
usage: getUsageByConfigType(data.configType),
config: {
secretId: data.secretId,
secretKey: data.secretKey,

View File

@@ -14,7 +14,7 @@ import {
} from "@/components/ui/form";
import { Button } from "@/components/ui/button";
import { Access, accessFormType, WebhookConfig } from "@/domain/access";
import { Access, accessFormType, getUsageByConfigType, WebhookConfig } from "@/domain/access";
import { save } from "@/repository/access";
import { useConfig } from "@/providers/config";
import { ClientResponseError } from "pocketbase";
@@ -56,6 +56,7 @@ const WebhookForm = ({
id: data.id as string,
name: data.name,
configType: data.configType,
usage: getUsageByConfigType(data.configType),
config: {
url: data.url,
},

View File

@@ -0,0 +1,141 @@
import { cn } from "@/lib/utils";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "../ui/dialog";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "../ui/form";
import { Input } from "../ui/input";
import { Button } from "../ui/button";
import { useConfig } from "@/providers/config";
import { update } from "@/repository/settings";
import { ClientResponseError } from "pocketbase";
import { PbErrorData } from "@/domain/base";
import { useState } from "react";
type EmailsEditProps = {
className?: string;
trigger: React.ReactNode;
};
const EmailsEdit = ({ className, trigger }: EmailsEditProps) => {
const {
config: { emails },
setEmails,
} = useConfig();
const [open, setOpen] = useState(false);
const formSchema = z.object({
email: z.string().email(),
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
email: "",
},
});
const onSubmit = async (data: z.infer<typeof formSchema>) => {
if (emails.content.emails.includes(data.email)) {
form.setError("email", {
message: "邮箱已存在",
});
return;
}
// 保存到 config
const newEmails = [...emails.content.emails, data.email];
try {
const resp = await update({
...emails,
name: "emails",
content: {
emails: newEmails,
},
});
// 更新本地状态
setEmails(resp);
// 关闭弹窗
form.reset();
form.clearErrors();
setOpen(false);
} catch (e) {
const err = e as ClientResponseError;
Object.entries(err.response.data as PbErrorData).forEach(
([key, value]) => {
form.setError(key as keyof z.infer<typeof formSchema>, {
type: "manual",
message: value.message,
});
}
);
}
};
return (
<Dialog onOpenChange={setOpen} open={open}>
<DialogTrigger asChild className={cn(className)}>
{trigger}
</DialogTrigger>
<DialogContent className="sm:max-w-[600px] w-full dark:text-stone-200">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="container py-3">
<Form {...form}>
<form
onSubmit={(e) => {
console.log(e);
e.stopPropagation();
form.handleSubmit(onSubmit)(e);
}}
className="space-y-8"
>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input placeholder="请输入邮箱" {...field} type="email" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end">
<Button type="submit"></Button>
</div>
</form>
</Form>
</div>
</DialogContent>
</Dialog>
);
};
export default EmailsEdit;

View File

@@ -5,6 +5,7 @@ export const accessTypeMap: Map<string, [string, string]> = new Map([
["aliyun", ["阿里云", "/imgs/providers/aliyun.svg"]],
["cloudflare", ["Cloudflare", "/imgs/providers/cloudflare.svg"]],
["namesilo", ["Namesilo", "/imgs/providers/namesilo.svg"]],
["godaddy", ["GoDaddy", "/imgs/providers/godaddy.svg"]],
["qiniu", ["七牛云", "/imgs/providers/qiniu.svg"]],
["ssh", ["SSH部署", "/imgs/providers/ssh.svg"]],
["webhook", ["Webhook", "/imgs/providers/webhook.svg"]],
@@ -19,14 +20,18 @@ export const accessFormType = z.union(
z.literal("cloudflare"),
z.literal("qiniu"),
z.literal("namesilo"),
z.literal("godaddy"),
],
{ message: "请选择云服务商" }
);
type AccessUsage = "apply" | "deploy" | "all";
export type Access = {
id: string;
name: string;
configType: string;
usage: AccessUsage;
config:
| TencentConfig
| AliyunConfig
@@ -34,7 +39,8 @@ export type Access = {
| WebhookConfig
| CloudflareConfig
| QiniuConfig
| NamesiloConfig;
| NamesiloConfig
| GodaddyConfig;
deleted?: string;
created?: string;
@@ -67,6 +73,10 @@ export type AliyunConfig = {
export type NamesiloConfig = {
apiKey: string;
};
export type GodaddyConfig = {
apiKey: string;
apiSecret: string;
};
export type SSHConfig = {
host: string;
@@ -79,3 +89,22 @@ export type SSHConfig = {
certPath: string;
keyPath: string;
};
export const getUsageByConfigType = (configType: string): AccessUsage => {
switch (configType) {
case "aliyun":
case "tencent":
return "all";
case "ssh":
case "webhook":
case "qiniu":
return "deploy";
case "cloudflare":
case "namesilo":
case "godaddy":
return "apply";
default:
return "all";
}
};

View File

@@ -3,6 +3,7 @@ import { Deployment, Pahse } from "./deployment";
export type Domain = {
id: string;
domain: string;
email?: string;
crontab: string;
access: string;
targetAccess: string;

View File

@@ -0,0 +1,9 @@
export type Setting = {
id?: string;
name?: string;
content: EmailsSetting;
};
type EmailsSetting = {
emails: string[];
};

View File

@@ -227,7 +227,7 @@ export default function Dashboard() {
href="https://github.com/usual2970/certimate/releases"
target="_blank"
>
Certimate v0.1.2
Certimate v0.1.4
</a>
</div>
</div>

View File

@@ -58,8 +58,8 @@ const Dashboard = () => {
<div className="flex justify-between items-center">
<div className="text-muted-foreground"></div>
</div>
<div className="flex mt-10 gap-5 flex-col md:flex-row">
<div className="w-full md:w-[300px] flex items-center rounded-md p-3 shadow-lg border">
<div className="flex mt-10 gap-5 flex-col flex-wrap md:flex-row">
<div className="w-full md:w-[250px] 3xl:w-[300px] flex items-center rounded-md p-3 shadow-lg border">
<div className="p-3">
<SquareSigma size={48} strokeWidth={1} className="text-blue-400" />
</div>
@@ -80,7 +80,7 @@ const Dashboard = () => {
</div>
</div>
<div className="w-full md:w-[300px] flex items-center rounded-md p-3 shadow-lg border">
<div className="w-full md:w-[250px] 3xl:w-[300px] flex items-center rounded-md p-3 shadow-lg border">
<div className="p-3">
<CalendarX2 size={48} strokeWidth={1} className="text-red-400" />
</div>
@@ -101,7 +101,7 @@ const Dashboard = () => {
</div>
</div>
<div className="border w-full md:w-[300px] flex items-center rounded-md p-3 shadow-lg">
<div className="border w-full md:w-[250px] 3xl:w-[300px] flex items-center rounded-md p-3 shadow-lg">
<div className="p-3">
<LoaderPinwheel
size={48}
@@ -126,7 +126,7 @@ const Dashboard = () => {
</div>
</div>
<div className="border w-full md:w-[300px] flex items-center rounded-md p-3 shadow-lg">
<div className="border w-full md:w-[250px] 3xl:w-[300px] flex items-center rounded-md p-3 shadow-lg">
<div className="p-3">
<Ban size={48} strokeWidth={1} className="text-gray-400" />
</div>
@@ -171,7 +171,7 @@ const Dashboard = () => {
<div className="mt-2 flex justify-end">
<Button
onClick={() => {
navigate("/");
navigate("/edit");
}}
>

View File

@@ -34,10 +34,11 @@ import { useLocation, useNavigate } from "react-router-dom";
import { Plus } from "lucide-react";
import { AccessEdit } from "@/components/certimate/AccessEdit";
import { accessTypeMap } from "@/domain/access";
import EmailsEdit from "@/components/certimate/EmailsEdit";
const Edit = () => {
const {
config: { accesses },
config: { accesses, emails },
} = useConfig();
const [domain, setDomain] = useState<Domain>();
@@ -62,6 +63,7 @@ const Edit = () => {
domain: z.string().regex(/^(?:\*\.)?([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}$/, {
message: "请输入正确的域名",
}),
email: z.string().email().optional(),
access: z.string().regex(/^[a-zA-Z0-9]+$/, {
message: "请选择DNS服务商授权配置",
}),
@@ -78,6 +80,7 @@ const Edit = () => {
defaultValues: {
id: "",
domain: "",
email: "",
access: "",
targetAccess: "",
targetType: "",
@@ -89,6 +92,7 @@ const Edit = () => {
form.reset({
id: domain.id,
domain: domain.domain,
email: domain.email,
access: domain.access,
targetAccess: domain.targetAccess,
targetType: domain.targetType,
@@ -99,6 +103,10 @@ const Edit = () => {
const [targetType, setTargetType] = useState(domain ? domain.targetType : "");
const targetAccesses = accesses.filter((item) => {
if (item.usage == "apply") {
return false;
}
if (targetType == "") {
return true;
}
@@ -115,6 +123,7 @@ const Edit = () => {
id: data.id as string,
crontab: "0 0 * * *",
domain: data.domain,
email: data.email,
access: data.access,
targetAccess: data.targetAccess,
targetType: data.targetType,
@@ -176,6 +185,51 @@ const Edit = () => {
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel className="flex w-full justify-between">
<div>Email</div>
<EmailsEdit
trigger={
<div className="font-normal text-primary hover:underline cursor-pointer flex items-center">
<Plus size={14} />
</div>
}
/>
</FormLabel>
<FormControl>
<Select
{...field}
value={field.value}
onValueChange={(value) => {
form.setValue("email", value);
}}
>
<SelectTrigger>
<SelectValue placeholder="请选择邮箱" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel></SelectLabel>
{emails.content.emails.map((item) => (
<SelectItem key={item} value={item}>
<div>{item}</div>
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="access"
@@ -207,19 +261,21 @@ const Edit = () => {
<SelectContent>
<SelectGroup>
<SelectLabel></SelectLabel>
{accesses.map((item) => (
<SelectItem key={item.id} value={item.id}>
<div className="flex items-center space-x-2">
<img
className="w-6"
src={
accessTypeMap.get(item.configType)?.[1]
}
/>
<div>{item.name}</div>
</div>
</SelectItem>
))}
{accesses
.filter((item) => item.usage != "deploy")
.map((item) => (
<SelectItem key={item.id} value={item.id}>
<div className="flex items-center space-x-2">
<img
className="w-6"
src={
accessTypeMap.get(item.configType)?.[1]
}
/>
<div>{item.name}</div>
</div>
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>

View File

@@ -10,9 +10,12 @@ import {
useReducer,
} from "react";
import { configReducer } from "./reducer";
import { getEmails } from "@/repository/settings";
import { Setting } from "@/domain/settings";
export type ConfigData = {
accesses: Access[];
emails: Setting;
};
export type ConfigContext = {
@@ -20,6 +23,7 @@ export type ConfigContext = {
deleteAccess: (id: string) => void;
addAccess: (access: Access) => void;
updateAccess: (access: Access) => void;
setEmails: (email: Setting) => void;
};
const Context = createContext({} as ConfigContext);
@@ -31,7 +35,10 @@ interface ContainerProps {
}
export const ConfigProvider = ({ children }: ContainerProps) => {
const [config, dispatchConfig] = useReducer(configReducer, { accesses: [] });
const [config, dispatchConfig] = useReducer(configReducer, {
accesses: [],
emails: { content: { emails: [] } },
});
useEffect(() => {
const featchData = async () => {
@@ -41,6 +48,18 @@ export const ConfigProvider = ({ children }: ContainerProps) => {
featchData();
}, []);
useEffect(() => {
const featchEmails = async () => {
const emails = await getEmails();
dispatchConfig({ type: "SET_EMAILS", payload: emails });
};
featchEmails();
}, []);
const setEmails = useCallback((emails: Setting) => {
dispatchConfig({ type: "SET_EMAILS", payload: emails });
}, []);
const deleteAccess = useCallback((id: string) => {
dispatchConfig({ type: "DELETE_ACCESS", payload: id });
}, []);
@@ -58,9 +77,11 @@ export const ConfigProvider = ({ children }: ContainerProps) => {
value={{
config: {
accesses: config.accesses,
emails: config.emails,
},
deleteAccess,
addAccess,
setEmails,
updateAccess,
}}
>

View File

@@ -1,11 +1,14 @@
import { Access } from "@/domain/access";
import { ConfigData } from ".";
import { Setting } from "@/domain/settings";
type Action =
| { type: "ADD_ACCESS"; payload: Access }
| { type: "DELETE_ACCESS"; payload: string }
| { type: "UPDATE_ACCESS"; payload: Access }
| { type: "SET_ACCESSES"; payload: Access[] };
| { type: "SET_ACCESSES"; payload: Access[] }
| { type: "SET_EMAILS"; payload: Setting }
| { type: "ADD_EMAIL"; payload: string };
export const configReducer = (
state: ConfigData,
@@ -40,6 +43,23 @@ export const configReducer = (
),
};
}
case "SET_EMAILS": {
return {
...state,
emails: action.payload,
};
}
case "ADD_EMAIL": {
return {
...state,
emails: {
...state.emails,
content: {
emails: [...state.emails.content.emails, action.payload],
},
},
};
}
default:
return state;
}

View File

@@ -0,0 +1,26 @@
import { Setting } from "@/domain/settings";
import { getPb } from "./api";
export const getEmails = async () => {
try {
const resp = await getPb()
.collection("settings")
.getFirstListItem<Setting>("name='emails'");
return resp;
} catch (e) {
return {
content: { emails: [] },
};
}
};
export const update = async (setting: Setting) => {
const pb = getPb();
let resp: Setting;
if (setting.id) {
resp = await pb.collection("settings").update(setting.id, setting);
} else {
resp = await pb.collection("settings").create(setting);
}
return resp;
};

View File

@@ -14,6 +14,7 @@ module.exports = {
padding: "2rem",
screens: {
"2xl": "1400px",
'3xl': '1920px',
},
},
extend: {