mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-28 21:56:36 +08:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e9fb331214 | |||
| 5d6d68d0a1 | |||
| c8e2c3620e | |||
| af8e9b477e | |||
| 314f6fd3f4 | |||
| 7eee788720 | |||
| f6e4967a9a | |||
| 7afe4e5d78 | |||
| c6a055d5d3 | |||
| 9a89428405 |
@@ -19,7 +19,7 @@
|
||||
</p>
|
||||
|
||||
> [!WARNING]
|
||||
> 使用 `root` 用户初次登录系统后,务必修改默认密码 `123456`,并按需关闭新用户注册功能。
|
||||
> 使用 `root` 用户初次登录系统后,务必修改默认密码 `123456`。
|
||||
|
||||
## 文档
|
||||
|
||||
|
||||
@@ -40,15 +40,14 @@
|
||||
|
||||
Server:
|
||||
|
||||
* Go 1.24+
|
||||
* Go 1.25+
|
||||
* Gin
|
||||
* GORM
|
||||
* SQLite / PostgreSQL
|
||||
* 现有登录体系
|
||||
|
||||
Agent:
|
||||
|
||||
* Go 1.24+
|
||||
*
|
||||
* 单二进制
|
||||
* 节点本地执行
|
||||
* `openresty_path` 优先
|
||||
|
||||
@@ -6,13 +6,13 @@ This page summarizes the OpenFlare deployment baseline, integration flow, upgrad
|
||||
|
||||
Server:
|
||||
|
||||
* Go 1.24+
|
||||
* Go 1.25+
|
||||
* Node.js 18+
|
||||
* Writable SQLite directory or reachable PostgreSQL instance
|
||||
|
||||
Agent:
|
||||
|
||||
* Go 1.24+
|
||||
* Go 1.25+
|
||||
* Writable Agent data directory
|
||||
* Local mode requires `openresty -t` and `openresty -s reload`
|
||||
* Docker mode requires Docker access
|
||||
|
||||
@@ -4,10 +4,10 @@ OpenFlare Server is the Gin + GORM control plane. It owns the web console, manag
|
||||
|
||||
## Requirements
|
||||
|
||||
| Item | Requirement |
|
||||
| --- | --- |
|
||||
| Go | `1.24+` |
|
||||
| Node.js | `18+` |
|
||||
| Item | Requirement |
|
||||
| --- |-------------------------------------------------------|
|
||||
| Go | `1.25+` |
|
||||
| Node.js | `18+` |
|
||||
| Database | Writable SQLite path or reachable PostgreSQL instance |
|
||||
|
||||
Set `SESSION_SECRET` explicitly in production and prefer PostgreSQL.
|
||||
|
||||
@@ -44,8 +44,6 @@ The settings page maintains these hot-updatable options:
|
||||
| `NodeOfflineThreshold` | Node offline threshold in milliseconds | `120000` |
|
||||
| `AgentUpdateRepo` | Agent update repository | `Rain-kl/OpenFlare` |
|
||||
| `GeoIPProvider` | Node/IP region provider | `ipinfo` |
|
||||
| `RegisterEnabled` | Allow new user registration | `false` |
|
||||
| `PasswordRegisterEnabled` | Allow password registration | `true` |
|
||||
| `DatabaseAutoCleanupEnabled` | Enable daily observability cleanup | `false` |
|
||||
| `DatabaseAutoCleanupRetentionDays` | Retention days | `30` |
|
||||
|
||||
|
||||
@@ -6,13 +6,13 @@
|
||||
|
||||
Server:
|
||||
|
||||
* Go 1.24+
|
||||
* Go 1.25+
|
||||
* Node.js 18+
|
||||
* 可写 SQLite 文件目录,或可访问的 PostgreSQL 实例
|
||||
|
||||
Agent:
|
||||
|
||||
* Go 1.24+
|
||||
* Go 1.25+
|
||||
* 对 Agent 数据目录有写权限
|
||||
* 本机模式下可执行 `openresty -t` 与 `openresty -s reload`
|
||||
* Docker 模式下具备 Docker 执行权限
|
||||
|
||||
@@ -4,10 +4,10 @@ OpenFlare Server 是 Gin + GORM 单体控制面,负责管理端 UI、管理 AP
|
||||
|
||||
## 前置条件
|
||||
|
||||
| 项目 | 要求 |
|
||||
| --- | --- |
|
||||
| Go | `1.24+` |
|
||||
| Node.js | `18+` |
|
||||
| 项目 | 要求 |
|
||||
| --- |-----------------------------------|
|
||||
| Go | `1.25+` |
|
||||
| Node.js | `18+` |
|
||||
| 数据库 | SQLite 文件目录可写,或可访问的 PostgreSQL 实例 |
|
||||
|
||||
生产环境建议显式配置 `SESSION_SECRET`,并优先使用 PostgreSQL。
|
||||
|
||||
@@ -16,6 +16,8 @@ OpenFlare 支持通过认证源配置第三方登录入口。当前支持 GitHub
|
||||
| Client Secret | 第三方平台创建应用后提供 |
|
||||
| OIDC Discovery URL | 仅 OIDC 需要,例如 `https://idp.example.com/.well-known/openid-configuration` |
|
||||
|
||||
**确认系统设置->通用设置->服务器地址能正确和域名匹配**
|
||||
|
||||
认证源名称只能包含字母、数字、短横线或下划线,并且必须以字母或数字开头。认证源名称会出现在回调地址中,保存后如需修改名称,也必须同步修改第三方平台中的回调地址。
|
||||
|
||||
## 回调地址
|
||||
|
||||
@@ -63,8 +63,6 @@ go run . --port 3000 --log-dir ./logs
|
||||
| `NodeOfflineThreshold` | 节点离线阈值(毫秒) | `120000` |
|
||||
| `AgentUpdateRepo` | Agent 自更新仓库 | `Rain-kl/OpenFlare` |
|
||||
| `GeoIPProvider` | 节点/IP 归属解析方式 | `ipinfo` |
|
||||
| `RegisterEnabled` | 是否允许新用户注册 | `false` |
|
||||
| `PasswordRegisterEnabled` | 是否允许通过密码方式注册 | `true` |
|
||||
| `DatabaseAutoCleanupEnabled` | 是否启用每日自动清理观测数据 | `false` |
|
||||
| `DatabaseAutoCleanupRetentionDays` | 自动清理保留天数,至少 1 天 | `30` |
|
||||
| `GlobalApiRateLimitNum` / `GlobalApiRateLimitDuration` | 全局 API 限流次数 / 时间窗口 | `300` / `180` |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
module openflare-agent
|
||||
|
||||
go 1.24.0
|
||||
go 1.25.0
|
||||
|
||||
require openflare v0.0.0
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ RUN corepack enable && pnpm install --frozen-lockfile
|
||||
COPY ./web ./
|
||||
RUN NEXT_PUBLIC_APP_VERSION="$VERSION" pnpm build
|
||||
|
||||
FROM golang:1.24 AS builder2
|
||||
FROM golang:1.25 AS builder2
|
||||
|
||||
ARG VERSION
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"net/http"
|
||||
"openflare/model"
|
||||
)
|
||||
|
||||
// GetDefaultAcmeAccount godoc
|
||||
// @Summary Get default ACME account
|
||||
// @Tags AcmeAccounts
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/acme-accounts/default [get]
|
||||
func GetDefaultAcmeAccount(c *gin.Context) {
|
||||
account, err := model.GetDefaultAcmeAccount()
|
||||
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": account,
|
||||
})
|
||||
}
|
||||
@@ -269,6 +269,30 @@ func LinkExistingOAuthAccount(c *gin.Context) {
|
||||
respondSuccess(c, service.OAuthCallbackResult{Status: "linked", User: cleanUser})
|
||||
}
|
||||
|
||||
func ListExternalAccounts(c *gin.Context) {
|
||||
userID := c.GetInt("id")
|
||||
accounts, err := model.ListExternalAccountsByUserID(userID)
|
||||
if err != nil {
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
respondSuccess(c, accounts)
|
||||
}
|
||||
|
||||
func DeleteExternalAccount(c *gin.Context) {
|
||||
rawID := strings.TrimSpace(c.Param("id"))
|
||||
parsedID, err := strconv.ParseUint(rawID, 10, 64)
|
||||
if err != nil || parsedID == 0 {
|
||||
respondBadRequest(c, "绑定记录 ID 无效")
|
||||
return
|
||||
}
|
||||
if err := model.DeleteExternalAccountForUser(uint(parsedID), c.GetInt("id")); err != nil {
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
respondSuccessMessage(c, "")
|
||||
}
|
||||
|
||||
func parseAuthSourceID(c *gin.Context) (uint, error) {
|
||||
raw := c.Param("source_id")
|
||||
if raw == "" {
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"github.com/gin-gonic/gin"
|
||||
"net/http"
|
||||
"openflare/model"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type DnsAccountInput struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Authorization string `json:"authorization"`
|
||||
}
|
||||
|
||||
// GetDnsAccounts godoc
|
||||
// @Summary List DNS accounts
|
||||
// @Tags DnsAccounts
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/dns-accounts/ [get]
|
||||
func GetDnsAccounts(c *gin.Context) {
|
||||
accounts, err := model.ListDnsAccounts()
|
||||
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": accounts,
|
||||
})
|
||||
}
|
||||
|
||||
// CreateDnsAccount godoc
|
||||
// @Summary Create DNS account
|
||||
// @Tags DnsAccounts
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param payload body DnsAccountInput true "DNS account payload"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/dns-accounts/ [post]
|
||||
func CreateDnsAccount(c *gin.Context) {
|
||||
var input DnsAccountInput
|
||||
if err := json.NewDecoder(c.Request.Body).Decode(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "无效的参数",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
account := &model.DnsAccount{
|
||||
Name: input.Name,
|
||||
Type: input.Type,
|
||||
Authorization: input.Authorization,
|
||||
}
|
||||
|
||||
if err := account.Insert(); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": account,
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateDnsAccount godoc
|
||||
// @Summary Update DNS account
|
||||
// @Tags DnsAccounts
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param id path int true "DNS Account ID"
|
||||
// @Param payload body DnsAccountInput true "DNS account payload"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/dns-accounts/{id}/update [post]
|
||||
func UpdateDnsAccount(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "无效的参数",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var input DnsAccountInput
|
||||
if err := json.NewDecoder(c.Request.Body).Decode(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "无效的参数",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
account, err := model.GetDnsAccountByID(uint(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
account.Name = input.Name
|
||||
account.Type = input.Type
|
||||
account.Authorization = input.Authorization
|
||||
|
||||
if err := account.Update(); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": account,
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteDnsAccount godoc
|
||||
// @Summary Delete DNS account
|
||||
// @Tags DnsAccounts
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param id path int true "DNS Account ID"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/dns-accounts/{id}/delete [post]
|
||||
func DeleteDnsAccount(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "无效的参数",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
account, err := model.GetDnsAccountByID(uint(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Verify no cert uses this before deleting
|
||||
var count int64
|
||||
model.DB.Model(&model.TLSCertificate{}).Where("dns_account_id = ?", id).Count(&count)
|
||||
if count > 0 {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "该 DNS 账号已被证书使用,无法删除",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if err := account.Delete(); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
})
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"net/http"
|
||||
"openflare/common"
|
||||
"openflare/model"
|
||||
"openflare/utils"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func GetAllFiles(c *gin.Context) {
|
||||
p, _ := strconv.Atoi(c.Query("p"))
|
||||
if p < 0 {
|
||||
p = 0
|
||||
}
|
||||
files, err := model.GetAllFiles(p*common.ItemsPerPage, common.ItemsPerPage)
|
||||
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": files,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
func SearchFiles(c *gin.Context) {
|
||||
keyword := c.Query("keyword")
|
||||
files, err := model.SearchFiles(keyword)
|
||||
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": files,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
func UploadFile(c *gin.Context) {
|
||||
form, err := c.MultipartForm()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
uploadPath := common.UploadPath
|
||||
description := c.PostForm("description")
|
||||
if description == "" {
|
||||
description = "无描述信息"
|
||||
}
|
||||
uploader := c.GetString("username")
|
||||
if uploader == "" {
|
||||
uploader = "访客用户"
|
||||
}
|
||||
uploaderId := c.GetInt("id")
|
||||
currentTime := time.Now().Format("2006-01-02 15:04:05")
|
||||
files := form.File["file"]
|
||||
for _, file := range files {
|
||||
filename := filepath.Base(file.Filename)
|
||||
ext := filepath.Ext(filename)
|
||||
link := utils.GetUUID() + ext
|
||||
savePath := filepath.Join(uploadPath, link) // both parts are checked, so this path should be safe to use
|
||||
if err := c.SaveUploadedFile(file, savePath); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
// save to database
|
||||
fileObj := &model.File{
|
||||
Description: description,
|
||||
Uploader: uploader,
|
||||
UploadTime: currentTime,
|
||||
UploaderId: uploaderId,
|
||||
Link: link,
|
||||
Filename: filename,
|
||||
}
|
||||
err = fileObj.Insert()
|
||||
if err != nil {
|
||||
_ = err
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
func DeleteFile(c *gin.Context) {
|
||||
fileIdStr := c.Param("id")
|
||||
fileId, err := strconv.Atoi(fileIdStr)
|
||||
if err != nil || fileId == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "无效的参数",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
fileObj := &model.File{
|
||||
Id: fileId,
|
||||
}
|
||||
model.DB.Where("id = ?", fileId).First(&fileObj)
|
||||
if fileObj.Link == "" {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "文件不存在!",
|
||||
})
|
||||
return
|
||||
}
|
||||
err = fileObj.Delete()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
} else {
|
||||
message := "文件删除成功"
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": message,
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func DownloadFile(c *gin.Context) {
|
||||
path := c.Param("file")
|
||||
fullPath := filepath.Join(common.UploadPath, path)
|
||||
if !strings.HasPrefix(fullPath, common.UploadPath) {
|
||||
// We may being attacked!
|
||||
c.Status(403)
|
||||
return
|
||||
}
|
||||
c.File(fullPath)
|
||||
// Update download counter
|
||||
go func() {
|
||||
model.UpdateDownloadCounter(path)
|
||||
}()
|
||||
}
|
||||
@@ -255,3 +255,114 @@ func DeleteTLSCertificate(c *gin.Context) {
|
||||
"message": "",
|
||||
})
|
||||
}
|
||||
|
||||
// ApplyTLSCertificate godoc
|
||||
// @Summary Apply TLS certificate via ACME
|
||||
// @Tags TLSCertificates
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param payload body service.TLSApplyInput true "TLS apply payload"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]interface{}
|
||||
// @Router /api/tls-certificates/apply [post]
|
||||
func ApplyTLSCertificate(c *gin.Context) {
|
||||
var input service.TLSApplyInput
|
||||
if err := json.NewDecoder(c.Request.Body).Decode(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "无效的参数",
|
||||
})
|
||||
return
|
||||
}
|
||||
certificate, err := service.ApplyTLSCertificate(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,
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateAcmeCertificate godoc
|
||||
// @Summary Update ACME TLS certificate
|
||||
// @Tags TLSCertificates
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param id path int true "Certificate ID"
|
||||
// @Param payload body service.TLSApplyInput true "TLS apply payload"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]interface{}
|
||||
// @Router /api/tls-certificates/{id}/update-acme [post]
|
||||
func UpdateAcmeCertificate(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.TLSApplyInput
|
||||
if err := json.NewDecoder(c.Request.Body).Decode(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "无效的参数",
|
||||
})
|
||||
return
|
||||
}
|
||||
certificate, err := service.UpdateAcmeCertificate(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,
|
||||
})
|
||||
}
|
||||
|
||||
// RenewTLSCertificate godoc
|
||||
// @Summary Renew TLS certificate
|
||||
// @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}/renew [post]
|
||||
func RenewTLSCertificate(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "无效的参数",
|
||||
})
|
||||
return
|
||||
}
|
||||
certificate, err := service.RenewTLSCertificate(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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,9 +2,6 @@ package controller
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"net/http"
|
||||
"openflare/common"
|
||||
"openflare/model"
|
||||
@@ -12,6 +9,10 @@ import (
|
||||
"openflare/utils/validation"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type LoginRequest struct {
|
||||
@@ -115,70 +116,9 @@ func Logout(c *gin.Context) {
|
||||
}
|
||||
|
||||
func Register(c *gin.Context) {
|
||||
if !common.RegisterEnabled {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "管理员关闭了新用户注册",
|
||||
"success": false,
|
||||
})
|
||||
return
|
||||
}
|
||||
if !common.PasswordRegisterEnabled {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "管理员关闭了通过密码进行注册,请使用第三方账户验证的形式进行注册",
|
||||
"success": false,
|
||||
})
|
||||
return
|
||||
}
|
||||
var user model.User
|
||||
err := json.NewDecoder(c.Request.Body).Decode(&user)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "无效的参数",
|
||||
})
|
||||
return
|
||||
}
|
||||
if err := validation.Validate.Struct(&user); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "输入不合法 " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
if common.EmailVerificationEnabled {
|
||||
if user.Email == "" || user.VerificationCode == "" {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "管理员开启了邮箱验证,请输入邮箱地址和验证码",
|
||||
})
|
||||
return
|
||||
}
|
||||
if !security.VerifyCodeWithKey(user.Email, user.VerificationCode, security.EmailVerificationPurpose) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "验证码错误或已过期",
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
cleanUser := model.User{
|
||||
Username: user.Username,
|
||||
Password: user.Password,
|
||||
DisplayName: user.Username,
|
||||
}
|
||||
if common.EmailVerificationEnabled {
|
||||
cleanUser.Email = user.Email
|
||||
}
|
||||
if err := cleanUser.Insert(); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"message": "非法请求",
|
||||
"success": false,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
+23
-17
@@ -1,26 +1,29 @@
|
||||
module openflare
|
||||
|
||||
// +heroku goVersion go1.24
|
||||
go 1.24.0
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/bwmarrin/snowflake v0.3.0
|
||||
github.com/dgraph-io/ristretto/v2 v2.2.0
|
||||
github.com/gin-contrib/cors v1.6.0
|
||||
github.com/gin-contrib/sessions v0.0.5
|
||||
github.com/gin-contrib/static v0.0.1
|
||||
github.com/gin-gonic/gin v1.9.1
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
github.com/go-playground/validator/v10 v10.19.0
|
||||
github.com/go-acme/lego/v4 v4.35.2
|
||||
github.com/go-playground/validator/v10 v10.23.0
|
||||
github.com/go-redis/redis/v8 v8.11.5
|
||||
github.com/google/uuid v1.3.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/oschwald/maxminddb-golang v1.13.1
|
||||
github.com/swaggo/files v1.0.1
|
||||
github.com/swaggo/gin-swagger v1.6.1
|
||||
github.com/swaggo/swag v1.16.4
|
||||
golang.org/x/crypto v0.45.0
|
||||
golang.org/x/net v0.47.0
|
||||
golang.org/x/crypto v0.50.0
|
||||
golang.org/x/net v0.53.0
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/gorm v1.25.10
|
||||
gorm.io/sharding v0.6.2
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -28,16 +31,17 @@ require (
|
||||
github.com/PuerkitoBio/purell v1.1.1 // indirect
|
||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
|
||||
github.com/boj/redistore v0.0.0-20180917114910-cd5dcc76aeff // indirect
|
||||
github.com/bwmarrin/snowflake v0.3.0 // indirect
|
||||
github.com/bytedance/sonic v1.11.2 // indirect
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect
|
||||
github.com/chenzhuoyu/iasm v0.9.1 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.19.5 // indirect
|
||||
github.com/go-openapi/jsonreference v0.19.6 // indirect
|
||||
github.com/go-openapi/spec v0.20.4 // indirect
|
||||
@@ -56,28 +60,30 @@ require (
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/longbridgeapp/sqlparser v0.3.1 // indirect
|
||||
github.com/mailru/easyjson v0.7.6 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-isatty v0.0.21 // indirect
|
||||
github.com/miekg/dns v1.1.72 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.1.1 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
golang.org/x/arch v0.7.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20230817173708-d852ddb80c63 // indirect
|
||||
golang.org/x/sync v0.18.0 // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
golang.org/x/text v0.31.0 // indirect
|
||||
golang.org/x/tools v0.38.0 // indirect
|
||||
google.golang.org/protobuf v1.33.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
|
||||
golang.org/x/mod v0.35.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/text v0.36.0 // indirect
|
||||
golang.org/x/tools v0.44.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
gorm.io/sharding v0.6.2 // indirect
|
||||
modernc.org/libc v1.22.5 // indirect
|
||||
modernc.org/mathutil v1.5.0 // indirect
|
||||
modernc.org/memory v1.5.0 // indirect
|
||||
|
||||
+62
-40
@@ -12,6 +12,8 @@ github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1
|
||||
github.com/bytedance/sonic v1.10.0-rc/go.mod h1:ElCzW+ufi8qKqNW0FY314xriJhyJhuoJ3gFZdAHF7NM=
|
||||
github.com/bytedance/sonic v1.11.2 h1:ywfwo0a/3j9HR8wsYGWsIWl2mvRsI950HyoxiBERw5A=
|
||||
github.com/bytedance/sonic v1.11.2/go.mod h1:iZcSUejdk5aukTND/Eu/ivjQuEL0Cu9/rf50Hi0u/g4=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
|
||||
@@ -23,8 +25,9 @@ github.com/chenzhuoyu/iasm v0.9.1 h1:tUHQJXo3NhBqw6s33wkGn9SP3bvrWLdlVIJ3hQBL7P0
|
||||
github.com/chenzhuoyu/iasm v0.9.1/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgraph-io/ristretto/v2 v2.2.0 h1:bkY3XzJcXoMuELV8F+vS8kzNgicwQFAaGINAEJdWGOM=
|
||||
github.com/dgraph-io/ristretto/v2 v2.2.0/go.mod h1:RZrm63UmcBAaYWC1DotLYBmTvgkrs0+XhBd7Npn7/zI=
|
||||
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da h1:aIftn67I1fkbMa512G+w+Pxci9hJPB8oMnkcP3iZF38=
|
||||
@@ -33,10 +36,10 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
|
||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gin-contrib/cors v1.6.0 h1:0Z7D/bVhE6ja07lI8CTjTonp6SB07o8bNuFyRbsBUQg=
|
||||
github.com/gin-contrib/cors v1.6.0/go.mod h1:cI+h6iOAyxKRtUtC6iF/Si1KSFvGm/gK+kshxlCi8ro=
|
||||
github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4=
|
||||
@@ -54,6 +57,10 @@ github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9g
|
||||
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
|
||||
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
||||
github.com/go-acme/lego/v4 v4.35.2 h1:uVQg+KC/yj9R2g7Q9W5wDqhvQvxV5SMu5eqFVoN5xZU=
|
||||
github.com/go-acme/lego/v4 v4.35.2/go.mod h1:pX2jN5n8OphMGY1IaMjYm5DAEzguBaKRt8AvJAgJXpc=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
|
||||
github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY=
|
||||
github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
|
||||
@@ -74,23 +81,26 @@ github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI=
|
||||
github.com/go-playground/validator/v10 v10.19.0 h1:ol+5Fu+cSq9JD7SoSqe04GMI92cbn0+wvQ3bZ8b/AU4=
|
||||
github.com/go-playground/validator/v10 v10.19.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/go-playground/validator/v10 v10.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL8sThn8IHr/sO+o=
|
||||
github.com/go-playground/validator/v10 v10.23.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI=
|
||||
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
|
||||
github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
|
||||
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
||||
github.com/go-test/deep v1.0.7 h1:/VSMRlnY/JSyqxQUzQLKVMAskpY/NZKFA5j2P+0pP2M=
|
||||
github.com/go-test/deep v1.0.7/go.mod h1:QV8Hv/iy04NyLBxAdO9njL0iVPN1S4d/A3NVv1V36o8=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
|
||||
github.com/gomodule/redigo v2.0.0+incompatible h1:K/R+8tc58AaqLkqG2Ol3Qk+DR/TlNuhuh457pBFPtt0=
|
||||
github.com/gomodule/redigo v2.0.0+incompatible/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
||||
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8=
|
||||
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
|
||||
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
|
||||
@@ -113,8 +123,8 @@ github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 h1:9Nu54bhS/H/Kgo2/7xNSUuC5G28VR8ljfrLKU2G4IjU=
|
||||
github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12/go.mod h1:TBzl5BIHNXfS9+C35ZyJaklL7mLDbgUkcgXzSLa8Tk0=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
@@ -129,6 +139,8 @@ github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/longbridgeapp/assert v1.1.0 h1:L+/HISOhuGbNAAmJNXgk3+Tm5QmSB70kwdktJXgjL+I=
|
||||
github.com/longbridgeapp/assert v1.1.0/go.mod h1:UOI7O3rzlzlz715lQm0atWs6JbrYGuIJUEeOekutL6o=
|
||||
github.com/longbridgeapp/sqlparser v0.3.1 h1:iWOZWGIFgQrJRgobLXUNJdvqGRpbVXkyKUKUA5CNJBE=
|
||||
github.com/longbridgeapp/sqlparser v0.3.1/go.mod h1:GIHaUq8zvYyHLCLMJJykx1CdM6LHtkUih/QaJXySSx4=
|
||||
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
||||
@@ -136,14 +148,17 @@ github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN
|
||||
github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA=
|
||||
github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
|
||||
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
|
||||
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
|
||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
|
||||
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
|
||||
@@ -155,14 +170,16 @@ github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5
|
||||
github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8=
|
||||
github.com/pelletier/go-toml/v2 v2.1.1 h1:LWAJwfNvjQZCFIDKWYQaM62NcYeYViCmWIwmOStowAI=
|
||||
github.com/pelletier/go-toml/v2 v2.1.1/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
|
||||
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
|
||||
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
@@ -174,8 +191,8 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
|
||||
github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg=
|
||||
github.com/swaggo/gin-swagger v1.6.1 h1:Ri06G4gc9N4t4k8hekMigJ9zKTFSlqj/9paAQCQs7cY=
|
||||
@@ -194,24 +211,24 @@ golang.org/x/arch v0.7.0 h1:pskyeJh/3AmoQ8CPE95vxHLqp1G1GfGNXTmcl9NEKTc=
|
||||
golang.org/x/arch v0.7.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
||||
golang.org/x/exp v0.0.0-20230817173708-d852ddb80c63 h1:m64FZMko/V45gv0bNmrNYoDEq8U5YUhetc9cBWKS1TQ=
|
||||
golang.org/x/exp v0.0.0-20230817173708-d852ddb80c63/go.mod h1:0v4NqG35kSWCMzLaMeX+IQrlSnVE/bqGSyC2cz/9Le8=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
|
||||
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
|
||||
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
||||
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -220,9 +237,8 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
@@ -232,16 +248,16 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
||||
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
|
||||
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
|
||||
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
|
||||
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
@@ -257,10 +273,16 @@ gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C
|
||||
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/mysql v1.5.1 h1:WUEH5VF9obL/lTtzjmML/5e6VfFR/788coz2uaVCAZw=
|
||||
gorm.io/driver/mysql v1.5.1/go.mod h1:Jo3Xu7mMhCyj8dlrb3WoCaRd1FhsVh+yMXb1jUInf5o=
|
||||
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
|
||||
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
|
||||
gorm.io/gorm v1.25.10 h1:dQpO+33KalOA+aFYGlK+EfxcI5MbO7EP2yYygwh9h+s=
|
||||
gorm.io/gorm v1.25.10/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||
gorm.io/hints v1.1.2 h1:b5j0kwk5p4+3BtDtYqqfY+ATSxjj+6ptPgVveuynn9o=
|
||||
gorm.io/hints v1.1.2/go.mod h1:/ARdpUHAtyEMCh5NNi3tI7FsGh+Cj/MIUlvNxCNCFWg=
|
||||
gorm.io/plugin/dbresolver v1.5.1 h1:s9Dj9f7r+1rE3nx/Ywzc85nXptUEaeOO0pt27xdopM8=
|
||||
gorm.io/plugin/dbresolver v1.5.1/go.mod h1:l4Cn87EHLEYuqUncpEeTC2tTJQkjngPSD+lo8hIvcT0=
|
||||
gorm.io/sharding v0.6.2 h1:V9inmbdhN+RfWPEKTvbKKKv7qxLz1CneBDQvuL5P7jg=
|
||||
gorm.io/sharding v0.6.2/go.mod h1:dXaAZv0qyUmLkLAciQ+NH2O1D1A4/ttrrZ/XK4xW9HU=
|
||||
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package job
|
||||
|
||||
import (
|
||||
"github.com/robfig/cron/v3"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
var cronRunner *cron.Cron
|
||||
|
||||
func InitCronJobs() {
|
||||
cronRunner = cron.New()
|
||||
|
||||
// Register SSL renew job
|
||||
_, err := cronRunner.AddJob("0 0 * * *", &SSLRenewJob{})
|
||||
if err != nil {
|
||||
slog.Error("failed to register SSL renew cron job", "error", err)
|
||||
} else {
|
||||
slog.Info("registered SSL renew cron job")
|
||||
}
|
||||
|
||||
cronRunner.Start()
|
||||
}
|
||||
|
||||
func StopCronJobs() {
|
||||
if cronRunner != nil {
|
||||
cronRunner.Stop()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package job
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"openflare/model"
|
||||
"openflare/service"
|
||||
"time"
|
||||
)
|
||||
|
||||
type SSLRenewJob struct {
|
||||
}
|
||||
|
||||
func (j *SSLRenewJob) Run() {
|
||||
slog.Info("The scheduled certificate update task is currently in progress ...")
|
||||
|
||||
certificates, err := model.ListTLSCertificates()
|
||||
if err != nil {
|
||||
slog.Error("failed to list certificates in SSL renew job", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
for _, cert := range certificates {
|
||||
if !cert.AutoRenew || cert.Provider != "acme" || cert.ApplyStatus == "applying" {
|
||||
continue
|
||||
}
|
||||
|
||||
sub := cert.NotAfter.Sub(now)
|
||||
// Expiring in less than 7 days (7 * 24 hours)
|
||||
if sub.Hours() < 168 {
|
||||
slog.Info("Update the SSL certificate for the domain", "domain", cert.PrimaryDomain)
|
||||
|
||||
// Invoke renew process (async go-routine handles Lego inside)
|
||||
_, err := service.RenewTLSCertificate(cert.ID)
|
||||
if err != nil {
|
||||
slog.Error("Failed to update the SSL certificate", "domain", cert.PrimaryDomain, "error", err)
|
||||
continue
|
||||
}
|
||||
slog.Info("Triggered the SSL certificate renew for domain", "domain", cert.PrimaryDomain)
|
||||
}
|
||||
}
|
||||
slog.Info("The scheduled certificate update task has completed")
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"log/slog"
|
||||
"openflare/common"
|
||||
_ "openflare/docs"
|
||||
"openflare/job"
|
||||
"openflare/middleware"
|
||||
"openflare/model"
|
||||
"openflare/router"
|
||||
@@ -73,6 +74,9 @@ func main() {
|
||||
defer cancelBackgroundTasks()
|
||||
service.StartDatabaseAutoCleanupScheduler(backgroundCtx)
|
||||
|
||||
job.InitCronJobs()
|
||||
defer job.StopCronJobs()
|
||||
|
||||
// Initialize HTTP server
|
||||
server := gin.Default()
|
||||
//server.Use(gzip.Gzip(gzip.DefaultCompression))
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
type AcmeAccount struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
Email string `json:"email" gorm:"size:255"`
|
||||
URL string `json:"url" gorm:"size:255"`
|
||||
PrivateKey string `json:"-" gorm:"type:text;not null"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func GetAcmeAccountByID(id uint) (*AcmeAccount, error) {
|
||||
account := &AcmeAccount{}
|
||||
err := DB.First(account, id).Error
|
||||
return account, err
|
||||
}
|
||||
|
||||
func GetDefaultAcmeAccount() (*AcmeAccount, error) {
|
||||
account := &AcmeAccount{}
|
||||
err := DB.Order("id asc").First(account).Error
|
||||
if err != nil {
|
||||
// Auto-create a default account placeholder if none exists
|
||||
account.Email = "admin@openflare.dev"
|
||||
err = DB.Create(account).Error
|
||||
}
|
||||
return account, err
|
||||
}
|
||||
|
||||
func (account *AcmeAccount) Insert() error {
|
||||
return DB.Create(account).Error
|
||||
}
|
||||
|
||||
func (account *AcmeAccount) Update() error {
|
||||
return DB.Save(account).Error
|
||||
}
|
||||
|
||||
func (account *AcmeAccount) Delete() error {
|
||||
return DB.Delete(account).Error
|
||||
}
|
||||
@@ -44,6 +44,17 @@ type ExternalAccount struct {
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type ExternalAccountView struct {
|
||||
ID uint `json:"id"`
|
||||
AuthSourceID uint `json:"auth_source_id"`
|
||||
AuthSourceName string `json:"auth_source_name"`
|
||||
AuthSourceType string `json:"auth_source_type"`
|
||||
AuthSourceLabel string `json:"auth_source_label"`
|
||||
ExternalUsername string `json:"external_username"`
|
||||
Email string `json:"email"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func (source *AuthSource) Normalize() {
|
||||
source.Name = strings.TrimSpace(source.Name)
|
||||
source.Type = strings.TrimSpace(strings.ToLower(source.Type))
|
||||
@@ -216,3 +227,48 @@ func LinkExternalAccount(account *ExternalAccount) error {
|
||||
ExternalID: account.ExternalID,
|
||||
}).FirstOrCreate(account).Error
|
||||
}
|
||||
|
||||
func ListExternalAccountsByUserID(userID int) ([]ExternalAccountView, error) {
|
||||
if userID <= 0 {
|
||||
return nil, errors.New("用户 ID 不能为空")
|
||||
}
|
||||
var accounts []ExternalAccount
|
||||
if err := DB.Preload("AuthSource").Where("user_id = ?", userID).Order("id asc").Find(&accounts).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
views := make([]ExternalAccountView, 0, len(accounts))
|
||||
for _, account := range accounts {
|
||||
label := account.AuthSource.DisplayName
|
||||
if label == "" {
|
||||
label = account.AuthSource.Name
|
||||
}
|
||||
views = append(views, ExternalAccountView{
|
||||
ID: account.ID,
|
||||
AuthSourceID: account.AuthSourceID,
|
||||
AuthSourceName: account.AuthSource.Name,
|
||||
AuthSourceType: account.AuthSource.Type,
|
||||
AuthSourceLabel: label,
|
||||
ExternalUsername: account.ExternalUsername,
|
||||
Email: account.Email,
|
||||
CreatedAt: account.CreatedAt,
|
||||
})
|
||||
}
|
||||
return views, nil
|
||||
}
|
||||
|
||||
func DeleteExternalAccountForUser(id uint, userID int) error {
|
||||
if id == 0 {
|
||||
return errors.New("绑定记录 ID 不能为空")
|
||||
}
|
||||
if userID <= 0 {
|
||||
return errors.New("用户 ID 不能为空")
|
||||
}
|
||||
result := DB.Where("id = ? AND user_id = ?", id, userID).Delete(&ExternalAccount{})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return errors.New("绑定记录不存在")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import "time"
|
||||
|
||||
const (
|
||||
legacyDatabaseSchemaVersion = 1
|
||||
currentDatabaseSchemaVersion = 10
|
||||
currentDatabaseSchemaVersion = 11
|
||||
databaseSchemaVersionRowID = 1
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
type DnsAccount struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
Name string `json:"name" gorm:"size:255;not null"`
|
||||
Type string `json:"type" gorm:"size:64;not null"`
|
||||
Authorization string `json:"-" gorm:"type:text;not null"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func ListDnsAccounts() (accounts []*DnsAccount, err error) {
|
||||
err = DB.Order("id desc").Find(&accounts).Error
|
||||
return accounts, err
|
||||
}
|
||||
|
||||
func GetDnsAccountByID(id uint) (*DnsAccount, error) {
|
||||
account := &DnsAccount{}
|
||||
err := DB.First(account, id).Error
|
||||
return account, err
|
||||
}
|
||||
|
||||
func (account *DnsAccount) Insert() error {
|
||||
return DB.Create(account).Error
|
||||
}
|
||||
|
||||
func (account *DnsAccount) Update() error {
|
||||
return DB.Save(account).Error
|
||||
}
|
||||
|
||||
func (account *DnsAccount) Delete() error {
|
||||
return DB.Delete(account).Error
|
||||
}
|
||||
@@ -41,6 +41,8 @@ func registeredModels() []any {
|
||||
&NodeHealthEvent{},
|
||||
&TLSCertificate{},
|
||||
&ManagedDomain{},
|
||||
&AcmeAccount{},
|
||||
&DnsAccount{},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -863,7 +863,7 @@ func TestRunDatabaseSchemaMigrationDoesNotAdvanceVersionWhenValidationFails(t *t
|
||||
|
||||
err := runDatabaseSchemaMigration(db, "sqlite", databaseSchemaMigration{
|
||||
fromVersion: legacyDatabaseSchemaVersion,
|
||||
toVersion: currentDatabaseSchemaVersion,
|
||||
toVersion: 11,
|
||||
migrate: func(tx *gorm.DB, backend string) error {
|
||||
return autoMigrateSchemaMetadata(tx)
|
||||
},
|
||||
|
||||
@@ -1344,6 +1344,31 @@ func validateDatabaseSchemaV10(db *gorm.DB, backend string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// migrateV11 adds acme and dns accounts and extends tls_certificates.
|
||||
func migrateV11(db *gorm.DB, backend string) error {
|
||||
if err := applyCurrentSchema(db, backend); err != nil {
|
||||
return err
|
||||
}
|
||||
// Default values will be applied by gorm for new columns automatically during AutoMigrate.
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateDatabaseSchemaV11(db *gorm.DB, backend string) error {
|
||||
if err := validateDatabaseSchemaV10(db, backend); err != nil {
|
||||
return err
|
||||
}
|
||||
if !db.Migrator().HasTable(&AcmeAccount{}) {
|
||||
return fmt.Errorf("table acme_accounts is missing")
|
||||
}
|
||||
if !db.Migrator().HasTable(&DnsAccount{}) {
|
||||
return fmt.Errorf("table dns_accounts is missing")
|
||||
}
|
||||
if !db.Migrator().HasColumn(&TLSCertificate{}, "provider") {
|
||||
return fmt.Errorf("column tls_certificates.provider is missing")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func databaseSchemaMigrations() []databaseSchemaMigration {
|
||||
return []databaseSchemaMigration{
|
||||
{fromVersion: 1, toVersion: 2, migrate: migrateV2, validate: validateDatabaseSchemaV2},
|
||||
@@ -1355,6 +1380,7 @@ func databaseSchemaMigrations() []databaseSchemaMigration {
|
||||
{fromVersion: 7, toVersion: 8, migrate: migrateV8, validate: validateDatabaseSchemaV8},
|
||||
{fromVersion: 8, toVersion: 9, migrate: migrateV9, validate: validateDatabaseSchemaV9},
|
||||
{fromVersion: 9, toVersion: 10, migrate: migrateV10, validate: validateDatabaseSchemaV10},
|
||||
{fromVersion: 10, toVersion: 11, migrate: migrateV11, validate: validateDatabaseSchemaV11},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1440,7 +1466,7 @@ func initializeFreshDatabaseSchema(db *gorm.DB, backend string) error {
|
||||
if err := ensureDefaultGitHubAuthSource(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateDatabaseSchemaV10(db, backend); err != nil {
|
||||
if err := validateDatabaseSchemaV11(db, backend); err != nil {
|
||||
return err
|
||||
}
|
||||
return saveDatabaseSchemaVersion(db, currentDatabaseSchemaVersion)
|
||||
|
||||
@@ -35,7 +35,6 @@ func InitOptionMap() {
|
||||
common.OptionMap["GitHubOAuthEnabled"] = strconv.FormatBool(common.GitHubOAuthEnabled)
|
||||
common.OptionMap["WeChatAuthEnabled"] = strconv.FormatBool(common.WeChatAuthEnabled)
|
||||
common.OptionMap["TurnstileCheckEnabled"] = strconv.FormatBool(common.TurnstileCheckEnabled)
|
||||
common.OptionMap["RegisterEnabled"] = strconv.FormatBool(common.RegisterEnabled)
|
||||
common.OptionMap["SMTPServer"] = ""
|
||||
common.OptionMap["SMTPPort"] = strconv.Itoa(common.SMTPPort)
|
||||
common.OptionMap["SMTPAccount"] = ""
|
||||
@@ -187,8 +186,6 @@ func updateOptionMap(key string, value string) {
|
||||
common.WeChatAuthEnabled = boolValue
|
||||
case "TurnstileCheckEnabled":
|
||||
common.TurnstileCheckEnabled = boolValue
|
||||
case "RegisterEnabled":
|
||||
common.RegisterEnabled = boolValue
|
||||
}
|
||||
}
|
||||
switch key {
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"openflare/common"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestInitOptionMapDefaultsRegisterDisabled(t *testing.T) {
|
||||
previousRegisterEnabled := common.RegisterEnabled
|
||||
previousOptionMap := common.OptionMap
|
||||
previousDB := DB
|
||||
t.Cleanup(func() {
|
||||
common.RegisterEnabled = previousRegisterEnabled
|
||||
common.OptionMap = previousOptionMap
|
||||
DB = previousDB
|
||||
})
|
||||
|
||||
DB = openTestSQLiteDB(t, "options-defaults.db")
|
||||
common.RegisterEnabled = false
|
||||
common.OptionMap = nil
|
||||
|
||||
InitOptionMap()
|
||||
|
||||
if got := common.OptionMap["RegisterEnabled"]; got != "false" {
|
||||
t.Fatalf("expected RegisterEnabled default to be false, got %q", got)
|
||||
}
|
||||
if common.RegisterEnabled {
|
||||
t.Fatal("expected RegisterEnabled to remain false after InitOptionMap")
|
||||
}
|
||||
}
|
||||
@@ -3,15 +3,28 @@ package model
|
||||
import "time"
|
||||
|
||||
type TLSCertificate struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
Name string `json:"name" gorm:"uniqueIndex;size:255;not null"`
|
||||
CertPEM string `json:"-" gorm:"type:text;not null"`
|
||||
KeyPEM string `json:"-" gorm:"type:text;not null"`
|
||||
NotBefore time.Time `json:"not_before"`
|
||||
NotAfter time.Time `json:"not_after"`
|
||||
Remark string `json:"remark" gorm:"size:255"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
Name string `json:"name" gorm:"uniqueIndex;size:255;not null"`
|
||||
CertPEM string `json:"-" gorm:"type:text;not null"`
|
||||
KeyPEM string `json:"-" gorm:"type:text;not null"`
|
||||
NotBefore time.Time `json:"not_before"`
|
||||
NotAfter time.Time `json:"not_after"`
|
||||
Remark string `json:"remark" gorm:"size:255"`
|
||||
Provider string `json:"provider" gorm:"size:64;default:'upload'"` // upload, acme
|
||||
AcmeAccountID uint `json:"acme_account_id"`
|
||||
DnsAccountID uint `json:"dns_account_id"`
|
||||
KeyAlgorithm string `json:"key_algorithm" gorm:"size:32"`
|
||||
AutoRenew bool `json:"auto_renew"`
|
||||
PrimaryDomain string `json:"primary_domain" gorm:"size:255"`
|
||||
OtherDomains string `json:"other_domains" gorm:"type:text"`
|
||||
DisableCNAME bool `json:"disable_cname"`
|
||||
SkipDNS bool `json:"skip_dns"`
|
||||
DNS1 string `json:"dns1" gorm:"size:128"`
|
||||
DNS2 string `json:"dns2" gorm:"size:128"`
|
||||
ApplyStatus string `json:"apply_status" gorm:"size:64;default:'ready'"`
|
||||
ApplyMessage string `json:"apply_message" gorm:"type:text"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func ListTLSCertificates() (certificates []*TLSCertificate, err error) {
|
||||
|
||||
@@ -24,6 +24,12 @@ func SetApiRouter(router *gin.Engine) {
|
||||
apiRouter.GET("/oauth/:source/authorize", middleware.CriticalRateLimit(), controller.OAuthAuthorize)
|
||||
apiRouter.GET("/oauth/:source/callback", middleware.CriticalRateLimit(), controller.OAuthCallback)
|
||||
apiRouter.POST("/oauth/link-existing", middleware.CriticalRateLimit(), controller.LinkExistingOAuthAccount)
|
||||
externalAccountRoute := apiRouter.Group("/oauth/external-accounts")
|
||||
externalAccountRoute.Use(middleware.UserAuth(), middleware.NoTokenAuth())
|
||||
{
|
||||
externalAccountRoute.GET("/", controller.ListExternalAccounts)
|
||||
externalAccountRoute.POST("/:id/delete", controller.DeleteExternalAccount)
|
||||
}
|
||||
|
||||
userRoute := apiRouter.Group("/user")
|
||||
{
|
||||
@@ -79,14 +85,6 @@ func SetApiRouter(router *gin.Engine) {
|
||||
updateRoute.POST("/manual-upgrade", controller.ConfirmManualServerUpgrade)
|
||||
updateRoute.POST("/upgrade", controller.UpgradeServer)
|
||||
}
|
||||
fileRoute := apiRouter.Group("/file")
|
||||
fileRoute.Use(middleware.AdminAuth())
|
||||
{
|
||||
fileRoute.GET("/", controller.GetAllFiles)
|
||||
fileRoute.GET("/search", controller.SearchFiles)
|
||||
fileRoute.POST("/", middleware.UploadRateLimit(), controller.UploadFile)
|
||||
fileRoute.POST("/:id/delete", controller.DeleteFile)
|
||||
}
|
||||
proxyRoute := apiRouter.Group("/proxy-routes")
|
||||
proxyRoute.Use(middleware.AdminAuth())
|
||||
{
|
||||
@@ -122,8 +120,24 @@ func SetApiRouter(router *gin.Engine) {
|
||||
tlsCertificateRoute.GET("/:id/content", controller.GetTLSCertificateContent)
|
||||
tlsCertificateRoute.POST("/", controller.CreateTLSCertificate)
|
||||
tlsCertificateRoute.POST("/:id/update", controller.UpdateTLSCertificate)
|
||||
tlsCertificateRoute.POST("/:id/update-acme", controller.UpdateAcmeCertificate)
|
||||
tlsCertificateRoute.POST("/import-file", controller.ImportTLSCertificateFile)
|
||||
tlsCertificateRoute.POST("/:id/delete", controller.DeleteTLSCertificate)
|
||||
tlsCertificateRoute.POST("/apply", controller.ApplyTLSCertificate)
|
||||
tlsCertificateRoute.POST("/:id/renew", controller.RenewTLSCertificate)
|
||||
}
|
||||
acmeAccountRoute := apiRouter.Group("/acme-accounts")
|
||||
acmeAccountRoute.Use(middleware.AdminAuth())
|
||||
{
|
||||
acmeAccountRoute.GET("/default", controller.GetDefaultAcmeAccount)
|
||||
}
|
||||
dnsAccountRoute := apiRouter.Group("/dns-accounts")
|
||||
dnsAccountRoute.Use(middleware.AdminAuth())
|
||||
{
|
||||
dnsAccountRoute.GET("/", controller.GetDnsAccounts)
|
||||
dnsAccountRoute.POST("/", controller.CreateDnsAccount)
|
||||
dnsAccountRoute.POST("/:id/update", controller.UpdateDnsAccount)
|
||||
dnsAccountRoute.POST("/:id/delete", controller.DeleteDnsAccount)
|
||||
}
|
||||
configVersionRoute := apiRouter.Group("/config-versions")
|
||||
configVersionRoute.Use(middleware.AdminAuth())
|
||||
|
||||
@@ -382,8 +382,8 @@ func TestPhase1HTTPSAndCertificateImportLifecycle(t *testing.T) {
|
||||
t.Fatalf("expected active config to expose main_config, got %#v", activeConfig["main_config"])
|
||||
}
|
||||
supportFiles, ok := activeConfig["support_files"].([]any)
|
||||
if !ok || len(supportFiles) != 2 {
|
||||
t.Fatalf("expected active config to expose 2 support files, got %#v", activeConfig["support_files"])
|
||||
if !ok || len(supportFiles) != 3 {
|
||||
t.Fatalf("expected active config to expose 3 support files, got %#v", activeConfig["support_files"])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -269,6 +269,57 @@ func TestAuthSourceUpdateAcceptsClientSecret(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestExternalAccountBindingsCanBeListedAndDeleted(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
common.RedisEnabled = false
|
||||
setupTestDB(t)
|
||||
|
||||
engine := gin.New()
|
||||
engine.Use(sessions.Sessions("session", cookie.NewStore([]byte("test-secret"))))
|
||||
router.SetApiRouter(engine)
|
||||
|
||||
loginCookie := loginAsRoot(t, engine)
|
||||
|
||||
source := &model.AuthSource{
|
||||
Name: "logto",
|
||||
Type: model.AuthSourceTypeOIDC,
|
||||
DisplayName: "Logto",
|
||||
ClientID: "logto-client-id",
|
||||
ClientSecret: "logto-client-secret",
|
||||
OpenIDDiscoveryURL: "https://auth.example.com/.well-known/openid-configuration",
|
||||
}
|
||||
if err := model.CreateAuthSource(source); err != nil {
|
||||
t.Fatalf("create auth source: %v", err)
|
||||
}
|
||||
if err := model.LinkExternalAccount(&model.ExternalAccount{
|
||||
AuthSourceID: source.ID,
|
||||
UserID: 1,
|
||||
ExternalID: "logto-user-1",
|
||||
ExternalUsername: "ryan",
|
||||
Email: "ryan@example.com",
|
||||
}); err != nil {
|
||||
t.Fatalf("link external account: %v", err)
|
||||
}
|
||||
|
||||
listResp := performSessionJSONRequest(t, engine, loginCookie, http.MethodGet, "/api/oauth/external-accounts/", nil)
|
||||
var bindings []model.ExternalAccountView
|
||||
decodeResponseData(t, listResp, &bindings)
|
||||
if len(bindings) != 1 {
|
||||
t.Fatalf("expected 1 binding, got %d", len(bindings))
|
||||
}
|
||||
if bindings[0].AuthSourceName != "logto" || bindings[0].ExternalUsername != "ryan" {
|
||||
t.Fatalf("unexpected binding view: %+v", bindings[0])
|
||||
}
|
||||
|
||||
performSessionJSONRequest(t, engine, loginCookie, http.MethodPost, "/api/oauth/external-accounts/1/delete", nil)
|
||||
|
||||
listResp = performSessionJSONRequest(t, engine, loginCookie, http.MethodGet, "/api/oauth/external-accounts/", nil)
|
||||
decodeResponseData(t, listResp, &bindings)
|
||||
if len(bindings) != 0 {
|
||||
t.Fatalf("expected binding to be deleted, got %+v", bindings)
|
||||
}
|
||||
}
|
||||
|
||||
func loginAsRoot(t *testing.T, engine http.Handler) *http.Cookie {
|
||||
t.Helper()
|
||||
payload, err := json.Marshal(map[string]any{
|
||||
|
||||
@@ -2,15 +2,15 @@ package router
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"github.com/gin-contrib/static"
|
||||
"github.com/gin-gonic/gin"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"openflare/controller"
|
||||
"openflare/middleware"
|
||||
"openflare/utils/embedfs"
|
||||
pathpkg "path"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-contrib/static"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func setWebRouter(router *gin.Engine, buildFS embed.FS, indexPage []byte) {
|
||||
@@ -20,8 +20,6 @@ func setWebRouter(router *gin.Engine, buildFS embed.FS, indexPage []byte) {
|
||||
}
|
||||
|
||||
router.Use(middleware.GlobalWebRateLimit())
|
||||
fileDownloadRoute := router.Group("/")
|
||||
fileDownloadRoute.GET("/upload/:file", middleware.DownloadRateLimit(), controller.DownloadFile)
|
||||
router.Use(normalizeStaticExportDataNavigation())
|
||||
router.Use(middleware.Cache())
|
||||
router.Use(static.Serve("/", embedfs.EmbedFolder(buildFS, "web/build")))
|
||||
|
||||
@@ -3,17 +3,11 @@ package service
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"openflare/common"
|
||||
"openflare/model"
|
||||
)
|
||||
|
||||
func TestCompleteOAuthLoginRequiresLinkWhenRegistrationDisabled(t *testing.T) {
|
||||
setupServiceTestDB(t)
|
||||
previousRegisterEnabled := common.RegisterEnabled
|
||||
common.RegisterEnabled = false
|
||||
t.Cleanup(func() {
|
||||
common.RegisterEnabled = previousRegisterEnabled
|
||||
})
|
||||
|
||||
source := createTestAuthSource(t)
|
||||
result, pending, err := CompleteOAuthLogin(source, &OAuthProfile{
|
||||
@@ -48,43 +42,10 @@ func TestCompleteOAuthLoginRequiresLinkWhenRegistrationDisabled(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteOAuthLoginAutoRegistersWhenEnabled(t *testing.T) {
|
||||
setupServiceTestDB(t)
|
||||
previousRegisterEnabled := common.RegisterEnabled
|
||||
common.RegisterEnabled = true
|
||||
t.Cleanup(func() {
|
||||
common.RegisterEnabled = previousRegisterEnabled
|
||||
})
|
||||
|
||||
source := createTestAuthSource(t)
|
||||
result, pending, err := CompleteOAuthLogin(source, &OAuthProfile{
|
||||
ExternalID: "external-2",
|
||||
ExternalUsername: "oidc-user",
|
||||
DisplayName: "OIDC User",
|
||||
Email: "oidc@example.com",
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CompleteOAuthLogin failed: %v", err)
|
||||
}
|
||||
if pending != nil {
|
||||
t.Fatalf("expected no pending account when registration is enabled")
|
||||
}
|
||||
if result.Status != "registered" || result.User == nil {
|
||||
t.Fatalf("expected registered user result, got %#v", result)
|
||||
}
|
||||
account, err := model.FindExternalAccount(source.ID, "external-2")
|
||||
if err != nil {
|
||||
t.Fatalf("expected external account to be linked: %v", err)
|
||||
}
|
||||
if account.UserID != result.User.Id {
|
||||
t.Fatalf("expected external account user %d, got %d", result.User.Id, account.UserID)
|
||||
}
|
||||
}
|
||||
|
||||
func createTestAuthSource(t *testing.T) *model.AuthSource {
|
||||
t.Helper()
|
||||
source := &model.AuthSource{
|
||||
Name: "Test OIDC",
|
||||
Name: "test-oidc",
|
||||
Type: model.AuthSourceTypeOIDC,
|
||||
DisplayName: "Test OIDC",
|
||||
ClientID: "client-id",
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"openflare/model"
|
||||
|
||||
"github.com/go-acme/lego/v4/acme"
|
||||
"github.com/go-acme/lego/v4/certcrypto"
|
||||
"github.com/go-acme/lego/v4/certificate"
|
||||
"github.com/go-acme/lego/v4/challenge/dns01"
|
||||
"github.com/go-acme/lego/v4/lego"
|
||||
"github.com/go-acme/lego/v4/providers/dns/cloudflare"
|
||||
"github.com/go-acme/lego/v4/registration"
|
||||
)
|
||||
|
||||
type AcmeUser struct {
|
||||
Email string
|
||||
Registration *registration.Resource
|
||||
key crypto.PrivateKey
|
||||
}
|
||||
|
||||
func (u *AcmeUser) GetEmail() string {
|
||||
return u.Email
|
||||
}
|
||||
|
||||
func (u *AcmeUser) GetRegistration() *registration.Resource {
|
||||
return u.Registration
|
||||
}
|
||||
|
||||
func (u *AcmeUser) GetPrivateKey() crypto.PrivateKey {
|
||||
return u.key
|
||||
}
|
||||
|
||||
func parsePrivateKey(pemData string) (crypto.PrivateKey, error) {
|
||||
block, _ := pem.Decode([]byte(pemData))
|
||||
if block == nil {
|
||||
return nil, errors.New("failed to parse PEM block containing the key")
|
||||
}
|
||||
|
||||
if key, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil {
|
||||
return key, nil
|
||||
}
|
||||
if key, err := x509.ParsePKCS8PrivateKey(block.Bytes); err == nil {
|
||||
return key, nil
|
||||
}
|
||||
if key, err := x509.ParseECPrivateKey(block.Bytes); err == nil {
|
||||
return key, nil
|
||||
}
|
||||
return nil, errors.New("failed to parse private key")
|
||||
}
|
||||
|
||||
func encodePrivateKey(key crypto.PrivateKey) (string, error) {
|
||||
var pemBlock *pem.Block
|
||||
switch k := key.(type) {
|
||||
case *rsa.PrivateKey:
|
||||
pemBlock = &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(k)}
|
||||
case *ecdsa.PrivateKey:
|
||||
b, err := x509.MarshalECPrivateKey(k)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
pemBlock = &pem.Block{Type: "EC PRIVATE KEY", Bytes: b}
|
||||
default:
|
||||
return "", errors.New("unsupported key type")
|
||||
}
|
||||
return string(pem.EncodeToMemory(pemBlock)), nil
|
||||
}
|
||||
|
||||
func GetOrCreateLegoClient(account *model.AcmeAccount, keyAlgorithm string) (*lego.Client, *AcmeUser, error) {
|
||||
var privateKey crypto.PrivateKey
|
||||
var err error
|
||||
|
||||
if account.PrivateKey == "" {
|
||||
privateKey, err = ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
pemStr, err := encodePrivateKey(privateKey)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
account.PrivateKey = pemStr
|
||||
// Don't save it to DB yet, wait for successful registration.
|
||||
} else {
|
||||
privateKey, err = parsePrivateKey(account.PrivateKey)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
|
||||
user := &AcmeUser{
|
||||
Email: account.Email,
|
||||
key: privateKey,
|
||||
}
|
||||
|
||||
if account.URL != "" {
|
||||
user.Registration = ®istration.Resource{
|
||||
Body: acme.Account{
|
||||
Status: "valid",
|
||||
Contact: []string{"mailto:" + account.Email},
|
||||
},
|
||||
URI: account.URL,
|
||||
}
|
||||
}
|
||||
|
||||
config := lego.NewConfig(user)
|
||||
// Use Let's Encrypt production environment by default
|
||||
config.CADirURL = lego.LEDirectoryProduction
|
||||
|
||||
switch keyAlgorithm {
|
||||
case "RSA2048":
|
||||
config.Certificate.KeyType = certcrypto.RSA2048
|
||||
case "RSA4096":
|
||||
config.Certificate.KeyType = certcrypto.RSA4096
|
||||
case "EC256":
|
||||
config.Certificate.KeyType = certcrypto.EC256
|
||||
case "EC384":
|
||||
config.Certificate.KeyType = certcrypto.EC384
|
||||
default:
|
||||
config.Certificate.KeyType = certcrypto.RSA2048
|
||||
}
|
||||
|
||||
client, err := lego.NewClient(config)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if account.URL == "" {
|
||||
reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
user.Registration = reg
|
||||
account.URL = reg.URI
|
||||
if account.ID == 0 {
|
||||
err = model.DB.Create(account).Error
|
||||
} else {
|
||||
err = model.DB.Save(account).Error
|
||||
}
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return client, user, nil
|
||||
}
|
||||
|
||||
func SetupDNSProvider(client *lego.Client, dnsAccount *model.DnsAccount, dns1, dns2 string, disableCNAME, skipDNS bool) error {
|
||||
var provider challengeProvider
|
||||
|
||||
switch dnsAccount.Type {
|
||||
case "cloudflare":
|
||||
var creds map[string]string
|
||||
if err := json.Unmarshal([]byte(dnsAccount.Authorization), &creds); err != nil {
|
||||
return fmt.Errorf("failed to parse cloudflare credentials: %v", err)
|
||||
}
|
||||
|
||||
config := cloudflare.NewDefaultConfig()
|
||||
config.AuthToken = creds["api_token"]
|
||||
|
||||
p, err := cloudflare.NewDNSProviderConfig(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
provider = p
|
||||
default:
|
||||
return fmt.Errorf("unsupported DNS provider: %s", dnsAccount.Type)
|
||||
}
|
||||
|
||||
// We can use custom DNS servers to verify challenges if provided
|
||||
var resolvers []string
|
||||
if dns1 != "" {
|
||||
resolvers = append(resolvers, dns1+":53")
|
||||
}
|
||||
if dns2 != "" {
|
||||
resolvers = append(resolvers, dns2+":53")
|
||||
}
|
||||
|
||||
var opts []dns01.ChallengeOption
|
||||
|
||||
if len(resolvers) > 0 {
|
||||
opts = append(opts, dns01.AddRecursiveNameservers(resolvers))
|
||||
}
|
||||
|
||||
if disableCNAME {
|
||||
opts = append(opts, dns01.DisableCompletePropagationRequirement())
|
||||
}
|
||||
|
||||
if skipDNS {
|
||||
opts = append(opts, dns01.WrapPreCheck(func(domain, fqdn, value string, check dns01.PreCheckFunc) (bool, error) {
|
||||
// If we skip the local DNS check entirely, we might trigger Let's Encrypt to verify
|
||||
// BEFORE Cloudflare's edge servers have actually synced the TXT record (which takes 5-15 seconds).
|
||||
// So we add a safe 20-second artificial delay before forcing the true return.
|
||||
time.Sleep(20 * time.Second)
|
||||
return true, nil
|
||||
}))
|
||||
}
|
||||
|
||||
return client.Challenge.SetDNS01Provider(provider, opts...)
|
||||
}
|
||||
|
||||
// challengeProvider interface helps to bypass the strict type definition of SetDNS01Provider
|
||||
type challengeProvider interface {
|
||||
Present(domain, token, keyAuth string) error
|
||||
CleanUp(domain, token, keyAuth string) error
|
||||
}
|
||||
|
||||
func ObtainSSL(cert *model.TLSCertificate) error {
|
||||
cert.ApplyStatus = "applying"
|
||||
model.DB.Save(cert)
|
||||
|
||||
acmeAccount, err := model.GetAcmeAccountByID(cert.AcmeAccountID)
|
||||
if err != nil {
|
||||
// Fallback to default ACME account if the specified one is not found (e.g. ID 0 during testing)
|
||||
acmeAccount, err = model.GetDefaultAcmeAccount()
|
||||
if err != nil {
|
||||
updateCertError(cert, fmt.Sprintf("Failed to get ACME account: %v", err))
|
||||
return err
|
||||
}
|
||||
// Self-heal the certificate
|
||||
cert.AcmeAccountID = acmeAccount.ID
|
||||
model.DB.Save(cert)
|
||||
}
|
||||
|
||||
dnsAccount, err := model.GetDnsAccountByID(cert.DnsAccountID)
|
||||
if err != nil {
|
||||
updateCertError(cert, fmt.Sprintf("Failed to get DNS account: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
client, _, err := GetOrCreateLegoClient(acmeAccount, cert.KeyAlgorithm)
|
||||
if err != nil {
|
||||
updateCertError(cert, fmt.Sprintf("Failed to create ACME client: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
err = SetupDNSProvider(client, dnsAccount, cert.DNS1, cert.DNS2, cert.DisableCNAME, cert.SkipDNS)
|
||||
if err != nil {
|
||||
updateCertError(cert, fmt.Sprintf("Failed to setup DNS provider: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
domains := []string{cert.PrimaryDomain}
|
||||
if cert.OtherDomains != "" {
|
||||
for _, d := range strings.Split(cert.OtherDomains, "\n") {
|
||||
d = strings.TrimSpace(d)
|
||||
if d != "" {
|
||||
domains = append(domains, d)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
request := certificate.ObtainRequest{
|
||||
Domains: domains,
|
||||
Bundle: true,
|
||||
}
|
||||
|
||||
certificates, err := client.Certificate.Obtain(request)
|
||||
if err != nil {
|
||||
updateCertError(cert, fmt.Sprintf("Failed to obtain certificate: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
cert.CertPEM = string(certificates.Certificate)
|
||||
cert.KeyPEM = string(certificates.PrivateKey)
|
||||
|
||||
// Parse validity dates
|
||||
certBlock, _ := pem.Decode(certificates.Certificate)
|
||||
if certBlock != nil {
|
||||
parsedCert, err := x509.ParseCertificate(certBlock.Bytes)
|
||||
if err == nil {
|
||||
cert.NotBefore = parsedCert.NotBefore
|
||||
cert.NotAfter = parsedCert.NotAfter
|
||||
}
|
||||
}
|
||||
|
||||
cert.ApplyStatus = "ready"
|
||||
cert.ApplyMessage = ""
|
||||
return model.DB.Save(cert).Error
|
||||
}
|
||||
|
||||
func updateCertError(cert *model.TLSCertificate, message string) {
|
||||
cert.ApplyStatus = "error"
|
||||
cert.ApplyMessage = message
|
||||
model.DB.Save(cert)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"openflare/model"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAcmeAndDnsIntegration(t *testing.T) {
|
||||
setupServiceTestDB(t)
|
||||
|
||||
// 1. Create a DNS Account
|
||||
dnsAccount := &model.DnsAccount{
|
||||
Name: "Test Cloudflare",
|
||||
Type: "cloudflare",
|
||||
Authorization: `{"api_token": "dummy_token"}`,
|
||||
}
|
||||
if err := dnsAccount.Insert(); err != nil {
|
||||
t.Fatalf("Failed to insert DNS Account: %v", err)
|
||||
}
|
||||
|
||||
// 2. Apply for TLS Certificate (using the new ApplyTLSCertificate function)
|
||||
certInput := TLSApplyInput{
|
||||
Name: "Test ACME Cert",
|
||||
PrimaryDomain: "example.com",
|
||||
OtherDomains: "*.example.com",
|
||||
DnsAccountID: dnsAccount.ID,
|
||||
KeyAlgorithm: "RSA2048",
|
||||
AutoRenew: true,
|
||||
}
|
||||
|
||||
cert, err := ApplyTLSCertificate(certInput)
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyTLSCertificate failed: %v", err)
|
||||
}
|
||||
|
||||
if cert.ApplyStatus != "applying" {
|
||||
t.Fatalf("Expected cert ApplyStatus to be applying, got %s", cert.ApplyStatus)
|
||||
}
|
||||
|
||||
if cert.Provider != "acme" {
|
||||
t.Fatalf("Expected cert Provider to be acme, got %s", cert.Provider)
|
||||
}
|
||||
|
||||
// 3. Try to delete the DNS account (should fail since it's used by the cert)
|
||||
// Actually, the delete logic is in the controller for the foreign key check.
|
||||
// But let's check if the controller logic can be tested here, or we just trust the DB setup.
|
||||
var count int64
|
||||
model.DB.Model(&model.TLSCertificate{}).Where("dns_account_id = ?", dnsAccount.ID).Count(&count)
|
||||
if count != 1 {
|
||||
t.Fatalf("Expected 1 certificate associated with DNS account, got %d", count)
|
||||
}
|
||||
|
||||
// 4. Test RenewTLSCertificate
|
||||
renewedCert, err := RenewTLSCertificate(cert.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("RenewTLSCertificate failed: %v", err)
|
||||
}
|
||||
if renewedCert.ApplyStatus != "applying" {
|
||||
t.Fatalf("Expected renewed cert ApplyStatus to be applying, got %s", renewedCert.ApplyStatus)
|
||||
}
|
||||
|
||||
// Wait for the async goroutine to fail (it now registers an LE account, which takes longer)
|
||||
time.Sleep(5 * time.Second)
|
||||
|
||||
// Reload cert and verify error status
|
||||
finalCert, err := model.GetTLSCertificateByID(renewedCert.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to reload cert: %v", err)
|
||||
}
|
||||
if finalCert.ApplyStatus != "error" {
|
||||
t.Fatalf("Expected final cert ApplyStatus to be error, got %s", finalCert.ApplyStatus)
|
||||
}
|
||||
if finalCert.ApplyMessage == "" {
|
||||
t.Fatalf("Expected final cert ApplyMessage to be populated, got empty")
|
||||
}
|
||||
|
||||
// Clean up
|
||||
if err := DeleteTLSCertificate(cert.ID); err != nil {
|
||||
t.Fatalf("DeleteTLSCertificate failed: %v", err)
|
||||
}
|
||||
|
||||
if err := dnsAccount.Delete(); err != nil {
|
||||
t.Fatalf("Failed to delete DNS Account after cert cleanup: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,21 @@ type TLSCertificateContent struct {
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
|
||||
type TLSApplyInput struct {
|
||||
Name string `json:"name"`
|
||||
Remark string `json:"remark"`
|
||||
AcmeAccountID uint `json:"acme_account_id"`
|
||||
DnsAccountID uint `json:"dns_account_id"`
|
||||
KeyAlgorithm string `json:"key_algorithm"`
|
||||
AutoRenew bool `json:"auto_renew"`
|
||||
PrimaryDomain string `json:"primary_domain"`
|
||||
OtherDomains string `json:"other_domains"`
|
||||
DisableCNAME bool `json:"disable_cname"`
|
||||
SkipDNS bool `json:"skip_dns"`
|
||||
DNS1 string `json:"dns1"`
|
||||
DNS2 string `json:"dns2"`
|
||||
}
|
||||
|
||||
func ListTLSCertificates() ([]*model.TLSCertificate, error) {
|
||||
return model.ListTLSCertificates()
|
||||
}
|
||||
@@ -143,6 +158,107 @@ func DeleteTLSCertificate(id uint) error {
|
||||
return certificate.Delete()
|
||||
}
|
||||
|
||||
func ApplyTLSCertificate(input TLSApplyInput) (*model.TLSCertificate, error) {
|
||||
cert := &model.TLSCertificate{
|
||||
Name: strings.TrimSpace(input.Name),
|
||||
Remark: strings.TrimSpace(input.Remark),
|
||||
Provider: "acme",
|
||||
AcmeAccountID: input.AcmeAccountID,
|
||||
DnsAccountID: input.DnsAccountID,
|
||||
KeyAlgorithm: input.KeyAlgorithm,
|
||||
AutoRenew: input.AutoRenew,
|
||||
PrimaryDomain: strings.TrimSpace(input.PrimaryDomain),
|
||||
OtherDomains: strings.TrimSpace(input.OtherDomains),
|
||||
DisableCNAME: input.DisableCNAME,
|
||||
SkipDNS: input.SkipDNS,
|
||||
DNS1: strings.TrimSpace(input.DNS1),
|
||||
DNS2: strings.TrimSpace(input.DNS2),
|
||||
ApplyStatus: "applying",
|
||||
CertPEM: " ", // Temporary empty value, since gorm may prevent empty insert
|
||||
KeyPEM: " ", // Temporary empty value
|
||||
}
|
||||
|
||||
if cert.Name == "" {
|
||||
return nil, errors.New("certificate name cannot be empty")
|
||||
}
|
||||
|
||||
if err := cert.Insert(); err != nil {
|
||||
if isUniqueConstraintError(err) {
|
||||
return nil, errors.New("certificate name already exists")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Async obtain SSL
|
||||
go func(c *model.TLSCertificate) {
|
||||
_ = ObtainSSL(c)
|
||||
}(cert)
|
||||
|
||||
return cert, nil
|
||||
}
|
||||
|
||||
func UpdateAcmeCertificate(id uint, input TLSApplyInput) (*model.TLSCertificate, error) {
|
||||
cert, err := model.GetTLSCertificateByID(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cert.Provider != "acme" {
|
||||
return nil, errors.New("only acme certificates can be updated via this endpoint")
|
||||
}
|
||||
|
||||
cert.Name = strings.TrimSpace(input.Name)
|
||||
if cert.Name == "" {
|
||||
return nil, errors.New("certificate name cannot be empty")
|
||||
}
|
||||
|
||||
cert.Remark = strings.TrimSpace(input.Remark)
|
||||
cert.AcmeAccountID = input.AcmeAccountID
|
||||
cert.DnsAccountID = input.DnsAccountID
|
||||
cert.KeyAlgorithm = input.KeyAlgorithm
|
||||
cert.AutoRenew = input.AutoRenew
|
||||
cert.PrimaryDomain = strings.TrimSpace(input.PrimaryDomain)
|
||||
cert.OtherDomains = strings.TrimSpace(input.OtherDomains)
|
||||
cert.DisableCNAME = input.DisableCNAME
|
||||
cert.SkipDNS = input.SkipDNS
|
||||
cert.DNS1 = strings.TrimSpace(input.DNS1)
|
||||
cert.DNS2 = strings.TrimSpace(input.DNS2)
|
||||
cert.ApplyStatus = "applying"
|
||||
|
||||
if err := cert.Update(); err != nil {
|
||||
if isUniqueConstraintError(err) {
|
||||
return nil, errors.New("certificate name already exists")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Async obtain SSL with updated config
|
||||
go func(c *model.TLSCertificate) {
|
||||
_ = ObtainSSL(c)
|
||||
}(cert)
|
||||
|
||||
return cert, nil
|
||||
}
|
||||
|
||||
func RenewTLSCertificate(id uint) (*model.TLSCertificate, error) {
|
||||
cert, err := model.GetTLSCertificateByID(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cert.Provider != "acme" {
|
||||
return nil, errors.New("only acme certificates can be renewed")
|
||||
}
|
||||
|
||||
// Async obtain SSL
|
||||
go func(c *model.TLSCertificate) {
|
||||
_ = ObtainSSL(c)
|
||||
}(cert)
|
||||
|
||||
cert.ApplyStatus = "applying"
|
||||
cert.Update()
|
||||
|
||||
return cert, nil
|
||||
}
|
||||
|
||||
func buildTLSCertificate(existing *model.TLSCertificate, input TLSCertificateInput) (*model.TLSCertificate, error) {
|
||||
name := strings.TrimSpace(input.Name)
|
||||
certPEM := strings.TrimSpace(input.CertPEM)
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { DnsAccountsPage } from '@/features/dns-accounts/components/dns-accounts-page';
|
||||
|
||||
export default function Page() {
|
||||
return <DnsAccountsPage />;
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { Suspense } from 'react';
|
||||
|
||||
import { LoadingState } from '@/components/feedback/loading-state';
|
||||
import { RegisterForm } from '@/features/auth/components/register-form';
|
||||
|
||||
export default function RegisterPage() {
|
||||
return (
|
||||
<Suspense fallback={<LoadingState />}>
|
||||
<RegisterForm />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { apiRequest } from '@/lib/api/client';
|
||||
import type { AcmeAccountItem } from '@/features/acme-accounts/types';
|
||||
|
||||
export function getDefaultAcmeAccount() {
|
||||
return apiRequest<AcmeAccountItem>('/acme-accounts/default');
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export interface AcmeAccountItem {
|
||||
id: number;
|
||||
email: string;
|
||||
url: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
@@ -63,10 +63,6 @@ export function LoginForm() {
|
||||
queryFn: getPublicStatus,
|
||||
});
|
||||
|
||||
const canUsePasswordRegister =
|
||||
(statusQuery.data?.register_enabled ?? false) &&
|
||||
(statusQuery.data?.password_register_enabled ?? false);
|
||||
|
||||
const loginMutation = useMutation({
|
||||
mutationFn: login,
|
||||
onSuccess: (user) => {
|
||||
@@ -166,17 +162,6 @@ export function LoginForm() {
|
||||
>
|
||||
{TEXT.forgotPassword}
|
||||
</Link>
|
||||
{canUsePasswordRegister ? (
|
||||
<>
|
||||
<span>|</span>
|
||||
<Link
|
||||
href="/register"
|
||||
className="text-[var(--brand-primary)] transition hover:opacity-80"
|
||||
>
|
||||
{TEXT.register}
|
||||
</Link>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</AppCard>
|
||||
</PublicAuthGuard>
|
||||
|
||||
@@ -1,332 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { InlineMessage } from '@/components/feedback/inline-message';
|
||||
import { TurnstileWidget } from '@/components/forms/turnstile-widget';
|
||||
import { AppCard } from '@/components/ui/app-card';
|
||||
import {
|
||||
register as registerUser,
|
||||
sendEmailVerification,
|
||||
} from '@/features/auth/api/auth';
|
||||
import { getPublicStatus } from '@/features/auth/api/public';
|
||||
import {
|
||||
AuthButton,
|
||||
AuthFormField,
|
||||
AuthInput,
|
||||
SecondaryButton,
|
||||
} from '@/features/auth/components/auth-form-primitives';
|
||||
import { PublicAuthGuard } from '@/features/auth/components/public-auth-guard';
|
||||
|
||||
const TEXT = {
|
||||
title: '\u65b0\u7528\u6237\u6ce8\u518c',
|
||||
description:
|
||||
'\u517c\u5bb9\u73b0\u6709\u5bc6\u7801\u6ce8\u518c\u94fe\u8def\uff0c\u540e\u7eed\u53ef\u7ee7\u7eed\u6269\u5c55\u7b2c\u4e09\u65b9\u6ce8\u518c\u3002',
|
||||
usernameRequired: '\u8bf7\u8f93\u5165\u7528\u6237\u540d',
|
||||
usernameTooLong: '\u7528\u6237\u540d\u6700\u957f 12 \u4f4d',
|
||||
passwordTooShort: '\u5bc6\u7801\u81f3\u5c11 8 \u4f4d',
|
||||
passwordTooLong: '\u5bc6\u7801\u6700\u957f 20 \u4f4d',
|
||||
passwordRepeatRequired: '\u8bf7\u518d\u6b21\u8f93\u5165\u5bc6\u7801',
|
||||
passwordMismatch: '\u4e24\u6b21\u8f93\u5165\u7684\u5bc6\u7801\u4e0d\u4e00\u81f4',
|
||||
emailInvalid: '\u8bf7\u8f93\u5165\u6709\u6548\u90ae\u7bb1\u5730\u5740',
|
||||
codeRequired: '\u8bf7\u8f93\u5165\u9a8c\u8bc1\u7801',
|
||||
registerFailed: '\u6ce8\u518c\u5931\u8d25\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5\u3002',
|
||||
emailRequired: '\u8bf7\u8f93\u5165\u90ae\u7bb1\u5730\u5740',
|
||||
verificationSent:
|
||||
'\u9a8c\u8bc1\u7801\u53d1\u9001\u6210\u529f\uff0c\u8bf7\u68c0\u67e5\u90ae\u7bb1\u3002',
|
||||
verificationFailed:
|
||||
'\u9a8c\u8bc1\u7801\u53d1\u9001\u5931\u8d25\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5\u3002',
|
||||
turnstileRequired: '\u8bf7\u5148\u5b8c\u6210\u4eba\u673a\u9a8c\u8bc1\u3002',
|
||||
registerClosed:
|
||||
'\u7ba1\u7406\u5458\u5df2\u5173\u95ed\u65b0\u7528\u6237\u6ce8\u518c\u3002',
|
||||
passwordRegisterClosed:
|
||||
'\u7ba1\u7406\u5458\u5df2\u5173\u95ed\u5bc6\u7801\u6ce8\u518c\uff0c\u8bf7\u4f7f\u7528\u7b2c\u4e09\u65b9\u767b\u5f55\u5165\u53e3\u5b8c\u6210\u6ce8\u518c\u3002',
|
||||
hasAccount: '\u5df2\u6709\u8d26\u53f7\uff1f',
|
||||
backToLogin:
|
||||
'\u8fd4\u56de\u767b\u5f55\u9875\u67e5\u770b\u53ef\u7528\u5165\u53e3\uff1a',
|
||||
clickLogin: '\u70b9\u51fb\u767b\u5f55',
|
||||
username: '\u7528\u6237\u540d',
|
||||
usernameHint: '\u6700\u957f 12 \u4f4d',
|
||||
password: '\u5bc6\u7801',
|
||||
passwordHint: '\u6700\u77ed 8 \u4f4d\uff0c\u6700\u957f 20 \u4f4d',
|
||||
passwordConfirm: '\u786e\u8ba4\u5bc6\u7801',
|
||||
email: '\u90ae\u7bb1\u5730\u5740',
|
||||
emailCode: '\u90ae\u7bb1\u9a8c\u8bc1\u7801',
|
||||
getCode: '\u83b7\u53d6\u9a8c\u8bc1\u7801',
|
||||
gettingCode: '\u53d1\u9001\u4e2d...',
|
||||
register: '\u6ce8\u518c',
|
||||
registering: '\u6ce8\u518c\u4e2d...',
|
||||
};
|
||||
|
||||
const baseSchemaObject = z.object({
|
||||
username: z.string().min(1, TEXT.usernameRequired).max(12, TEXT.usernameTooLong),
|
||||
password: z.string().min(8, TEXT.passwordTooShort).max(20, TEXT.passwordTooLong),
|
||||
password2: z.string().min(8, TEXT.passwordRepeatRequired),
|
||||
email: z.string().optional(),
|
||||
verification_code: z.string().optional(),
|
||||
});
|
||||
|
||||
const baseSchema = baseSchemaObject.refine(
|
||||
(data) => data.password === data.password2,
|
||||
{
|
||||
message: TEXT.passwordMismatch,
|
||||
path: ['password2'],
|
||||
},
|
||||
);
|
||||
|
||||
type RegisterFormValues = z.infer<typeof baseSchema>;
|
||||
|
||||
export function RegisterForm() {
|
||||
const router = useRouter();
|
||||
const [turnstileToken, setTurnstileToken] = useState('');
|
||||
const [message, setMessage] = useState<{
|
||||
tone: 'success' | 'danger' | 'info';
|
||||
text: string;
|
||||
} | null>(null);
|
||||
|
||||
const statusQuery = useQuery({
|
||||
queryKey: ['public-status'],
|
||||
queryFn: getPublicStatus,
|
||||
});
|
||||
|
||||
const needsEmailVerification = statusQuery.data?.email_verification ?? false;
|
||||
const needsTurnstile = statusQuery.data?.turnstile_check ?? false;
|
||||
const registerEnabled = statusQuery.data?.register_enabled ?? false;
|
||||
const passwordRegisterEnabled =
|
||||
statusQuery.data?.password_register_enabled ?? false;
|
||||
|
||||
const schema = useMemo(() => {
|
||||
if (!needsEmailVerification) {
|
||||
return baseSchema;
|
||||
}
|
||||
|
||||
return baseSchemaObject
|
||||
.extend({
|
||||
email: z.string().email(TEXT.emailInvalid),
|
||||
verification_code: z.string().min(1, TEXT.codeRequired),
|
||||
})
|
||||
.refine((data) => data.password === data.password2, {
|
||||
message: TEXT.passwordMismatch,
|
||||
path: ['password2'],
|
||||
});
|
||||
}, [needsEmailVerification]);
|
||||
|
||||
const form = useForm<RegisterFormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
username: '',
|
||||
password: '',
|
||||
password2: '',
|
||||
email: '',
|
||||
verification_code: '',
|
||||
},
|
||||
});
|
||||
|
||||
const registerMutation = useMutation({
|
||||
mutationFn: (values: RegisterFormValues) =>
|
||||
registerUser(
|
||||
{
|
||||
username: values.username,
|
||||
password: values.password,
|
||||
email: values.email,
|
||||
verification_code: values.verification_code,
|
||||
},
|
||||
turnstileToken || undefined,
|
||||
),
|
||||
onSuccess: () => {
|
||||
router.replace('/login');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
setMessage({ tone: 'danger', text: error.message || TEXT.registerFailed });
|
||||
},
|
||||
});
|
||||
|
||||
const verificationMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const email = form.getValues('email');
|
||||
if (!email) {
|
||||
form.setError('email', { message: TEXT.emailRequired });
|
||||
return;
|
||||
}
|
||||
await sendEmailVerification(email, turnstileToken || undefined);
|
||||
},
|
||||
onSuccess: () => {
|
||||
setMessage({ tone: 'success', text: TEXT.verificationSent });
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
setMessage({
|
||||
tone: 'danger',
|
||||
text: error.message || TEXT.verificationFailed,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = form.handleSubmit((values) => {
|
||||
setMessage(null);
|
||||
if (needsTurnstile && !turnstileToken) {
|
||||
setMessage({ tone: 'info', text: TEXT.turnstileRequired });
|
||||
return;
|
||||
}
|
||||
registerMutation.mutate(values);
|
||||
});
|
||||
|
||||
return (
|
||||
<PublicAuthGuard>
|
||||
<AppCard title={TEXT.title} description={TEXT.description}>
|
||||
{!registerEnabled ? (
|
||||
<div className='space-y-4'>
|
||||
<InlineMessage tone='info' message={TEXT.registerClosed} />
|
||||
<div className='text-sm text-[var(--foreground-secondary)]'>
|
||||
{TEXT.hasAccount}
|
||||
<Link
|
||||
href='/login'
|
||||
className='ml-2 text-[var(--brand-primary)] transition hover:opacity-80'
|
||||
>
|
||||
{TEXT.clickLogin}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
) : !passwordRegisterEnabled ? (
|
||||
<div className='space-y-4'>
|
||||
<InlineMessage tone='info' message={TEXT.passwordRegisterClosed} />
|
||||
<div className='text-sm text-[var(--foreground-secondary)]'>
|
||||
{TEXT.backToLogin}
|
||||
<Link
|
||||
href='/login'
|
||||
className='ml-2 text-[var(--brand-primary)] transition hover:opacity-80'
|
||||
>
|
||||
{TEXT.clickLogin}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<form className='space-y-4' onSubmit={handleSubmit}>
|
||||
<AuthFormField label={TEXT.username} hint={TEXT.usernameHint}>
|
||||
<AuthInput
|
||||
placeholder={TEXT.username}
|
||||
{...form.register('username')}
|
||||
/>
|
||||
{form.formState.errors.username ? (
|
||||
<span className='text-xs text-[var(--status-danger-foreground)]'>
|
||||
{form.formState.errors.username.message}
|
||||
</span>
|
||||
) : null}
|
||||
</AuthFormField>
|
||||
|
||||
<AuthFormField label={TEXT.password} hint={TEXT.passwordHint}>
|
||||
<AuthInput
|
||||
type='password'
|
||||
placeholder={TEXT.password}
|
||||
{...form.register('password')}
|
||||
/>
|
||||
{form.formState.errors.password ? (
|
||||
<span className='text-xs text-[var(--status-danger-foreground)]'>
|
||||
{form.formState.errors.password.message}
|
||||
</span>
|
||||
) : null}
|
||||
</AuthFormField>
|
||||
|
||||
<AuthFormField label={TEXT.passwordConfirm}>
|
||||
<AuthInput
|
||||
type='password'
|
||||
placeholder={TEXT.passwordConfirm}
|
||||
{...form.register('password2')}
|
||||
/>
|
||||
{form.formState.errors.password2 ? (
|
||||
<span className='text-xs text-[var(--status-danger-foreground)]'>
|
||||
{form.formState.errors.password2.message}
|
||||
</span>
|
||||
) : null}
|
||||
</AuthFormField>
|
||||
|
||||
{needsEmailVerification ? (
|
||||
<>
|
||||
<AuthFormField label={TEXT.email}>
|
||||
<AuthInput
|
||||
type='email'
|
||||
placeholder={TEXT.email}
|
||||
{...form.register('email')}
|
||||
/>
|
||||
{form.formState.errors.email ? (
|
||||
<span className='text-xs text-[var(--status-danger-foreground)]'>
|
||||
{form.formState.errors.email.message}
|
||||
</span>
|
||||
) : null}
|
||||
</AuthFormField>
|
||||
|
||||
<AuthFormField label={TEXT.emailCode}>
|
||||
<div className='flex flex-col gap-3 sm:flex-row'>
|
||||
<AuthInput
|
||||
placeholder={TEXT.emailCode}
|
||||
className='flex-1'
|
||||
{...form.register('verification_code')}
|
||||
/>
|
||||
<SecondaryButton
|
||||
type='button'
|
||||
onClick={() => {
|
||||
if (needsTurnstile && !turnstileToken) {
|
||||
setMessage({
|
||||
tone: 'info',
|
||||
text: TEXT.turnstileRequired,
|
||||
});
|
||||
return;
|
||||
}
|
||||
setMessage(null);
|
||||
verificationMutation.mutate();
|
||||
}}
|
||||
disabled={verificationMutation.isPending}
|
||||
>
|
||||
{verificationMutation.isPending
|
||||
? TEXT.gettingCode
|
||||
: TEXT.getCode}
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
{form.formState.errors.verification_code ? (
|
||||
<span className='text-xs text-[var(--status-danger-foreground)]'>
|
||||
{form.formState.errors.verification_code.message}
|
||||
</span>
|
||||
) : null}
|
||||
</AuthFormField>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{needsTurnstile && statusQuery.data?.turnstile_site_key ? (
|
||||
<TurnstileWidget
|
||||
siteKey={statusQuery.data.turnstile_site_key}
|
||||
onVerify={(token) => setTurnstileToken(token)}
|
||||
onExpire={() => setTurnstileToken('')}
|
||||
onError={() => setTurnstileToken('')}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{message ? (
|
||||
<InlineMessage tone={message.tone} message={message.text} />
|
||||
) : null}
|
||||
|
||||
<AuthButton type='submit' disabled={registerMutation.isPending}>
|
||||
{registerMutation.isPending ? TEXT.registering : TEXT.register}
|
||||
</AuthButton>
|
||||
</form>
|
||||
|
||||
<div className='mt-6 text-sm text-[var(--foreground-secondary)]'>
|
||||
{TEXT.hasAccount}
|
||||
<Link
|
||||
href='/login'
|
||||
className='ml-2 text-[var(--brand-primary)] transition hover:opacity-80'
|
||||
>
|
||||
{TEXT.clickLogin}
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</AppCard>
|
||||
</PublicAuthGuard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { apiRequest } from '@/lib/api/client';
|
||||
import type {
|
||||
DnsAccountItem,
|
||||
DnsAccountMutationPayload,
|
||||
} from '@/features/dns-accounts/types';
|
||||
|
||||
export function getDnsAccounts() {
|
||||
return apiRequest<DnsAccountItem[]>('/dns-accounts/');
|
||||
}
|
||||
|
||||
export function createDnsAccount(payload: DnsAccountMutationPayload) {
|
||||
return apiRequest<DnsAccountItem>('/dns-accounts/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function updateDnsAccount(id: number, payload: DnsAccountMutationPayload) {
|
||||
return apiRequest<DnsAccountItem>(`/dns-accounts/${id}/update`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteDnsAccount(id: number) {
|
||||
return apiRequest<void>(`/dns-accounts/${id}/delete`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
'use client';
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useMemo, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
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 { PageHeader } from '@/components/layout/page-header';
|
||||
import { AppCard } from '@/components/ui/app-card';
|
||||
import { AppModal } from '@/components/ui/app-modal';
|
||||
import {
|
||||
deleteDnsAccount,
|
||||
getDnsAccounts,
|
||||
createDnsAccount,
|
||||
} from '@/features/dns-accounts/api/dns-accounts';
|
||||
import type { DnsAccountItem } from '@/features/dns-accounts/types';
|
||||
import { getErrorMessage } from '@/features/websites/utils';
|
||||
import {
|
||||
DangerButton,
|
||||
PrimaryButton,
|
||||
ResourceField,
|
||||
ResourceInput,
|
||||
ResourceSelect,
|
||||
} from '@/features/shared/components/resource-primitives';
|
||||
import { formatDateTime } from '@/lib/utils/date';
|
||||
|
||||
export function DnsAccountsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [feedback, setFeedback] = useState<{ tone: 'info' | 'success' | 'danger'; message: string } | null>(null);
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||
|
||||
const dnsAccountsQuery = useQuery({
|
||||
queryKey: ['dns-accounts'],
|
||||
queryFn: getDnsAccounts,
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteDnsAccount,
|
||||
onSuccess: async () => {
|
||||
setFeedback({ tone: 'success', message: 'DNS 账号已删除。' });
|
||||
await queryClient.invalidateQueries({ queryKey: ['dns-accounts'] });
|
||||
},
|
||||
onError: (error) => {
|
||||
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
|
||||
},
|
||||
});
|
||||
|
||||
const handleDelete = (account: DnsAccountItem) => {
|
||||
if (!window.confirm(`确认删除 DNS 账号 ${account.name} 吗?`)) {
|
||||
return;
|
||||
}
|
||||
setFeedback(null);
|
||||
deleteMutation.mutate(account.id);
|
||||
};
|
||||
|
||||
const accounts = useMemo(() => dnsAccountsQuery.data ?? [], [dnsAccountsQuery.data]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="DNS 账号"
|
||||
description="统一管理 DNS 服务商账号,用于 ACME 证书的 DNS 验证申请。"
|
||||
action={
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link
|
||||
href="/website/certificate"
|
||||
className="inline-flex min-h-[46px] items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-3 text-sm font-medium text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
|
||||
>
|
||||
返回
|
||||
</Link>
|
||||
<PrimaryButton type="button" onClick={() => setIsCreateOpen(true)}>
|
||||
添加账号
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{feedback ? <InlineMessage tone={feedback.tone} message={feedback.message} /> : null}
|
||||
|
||||
<AppCard title="DNS 账号列表">
|
||||
{dnsAccountsQuery.isLoading ? (
|
||||
<LoadingState />
|
||||
) : dnsAccountsQuery.isError ? (
|
||||
<ErrorState title="加载失败" description={getErrorMessage(dnsAccountsQuery.error)} />
|
||||
) : accounts.length === 0 ? (
|
||||
<EmptyState title="暂无 DNS 账号" description="点击右上角“添加账号”开始录入。" />
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{accounts.map((account) => (
|
||||
<div key={account.id} className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
|
||||
{account.name} <span className="ml-2 text-xs font-normal text-[var(--foreground-secondary)]">({account.type})</span>
|
||||
</p>
|
||||
<div className="text-xs leading-5 text-[var(--foreground-secondary)]">
|
||||
<p>创建于:{formatDateTime(account.created_at)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<DangerButton
|
||||
type="button"
|
||||
onClick={() => handleDelete(account)}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="px-3 py-2 text-xs"
|
||||
>
|
||||
删除
|
||||
</DangerButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</AppCard>
|
||||
</div>
|
||||
|
||||
{isCreateOpen && (
|
||||
<DnsAccountCreateModal isOpen={isCreateOpen} onClose={() => setIsCreateOpen(false)} onCreated={() => {
|
||||
setFeedback({ tone: 'success', message: 'DNS 账号已添加。' });
|
||||
setIsCreateOpen(false);
|
||||
queryClient.invalidateQueries({ queryKey: ['dns-accounts'] });
|
||||
}} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DnsAccountCreateModal({ isOpen, onClose, onCreated }: { isOpen: boolean; onClose: () => void; onCreated: () => void }) {
|
||||
const [error, setError] = useState('');
|
||||
const { register, handleSubmit, formState } = useForm({
|
||||
defaultValues: { name: '', type: 'cloudflare', authorization: '' },
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: createDnsAccount,
|
||||
onSuccess: onCreated,
|
||||
onError: (err) => setError(getErrorMessage(err)),
|
||||
});
|
||||
|
||||
const onSubmit = handleSubmit((values) => {
|
||||
setError('');
|
||||
// for cloudflare we wrap the token in JSON if it isn't already (the backend expects JSON)
|
||||
let auth = values.authorization;
|
||||
if (!auth.startsWith('{')) {
|
||||
auth = JSON.stringify({ api_token: values.authorization });
|
||||
}
|
||||
createMutation.mutate({ ...values, authorization: auth });
|
||||
});
|
||||
|
||||
return (
|
||||
<AppModal isOpen={isOpen} onClose={onClose} title="添加 DNS 账号">
|
||||
<form onSubmit={onSubmit} className="space-y-5">
|
||||
{error && <InlineMessage tone="danger" message={error} />}
|
||||
<ResourceField label="账号名称" error={formState.errors.name?.message as string}>
|
||||
<ResourceInput placeholder="例如:我的 Cloudflare" {...register('name', { required: '请输入名称' })} />
|
||||
</ResourceField>
|
||||
<ResourceField label="DNS 服务商">
|
||||
<ResourceSelect {...register('type')}>
|
||||
<option value="cloudflare">Cloudflare</option>
|
||||
</ResourceSelect>
|
||||
</ResourceField>
|
||||
<ResourceField label="API Token (Authorization)" hint="输入对应 DNS 平台提供的 API Token。">
|
||||
<ResourceInput placeholder="xxxxxxxxxxxxxxxxxxxxxxxxxxx" {...register('authorization', { required: '请输入 Token' })} />
|
||||
</ResourceField>
|
||||
<PrimaryButton type="submit" disabled={createMutation.isPending}>
|
||||
{createMutation.isPending ? '提交中...' : '提交'}
|
||||
</PrimaryButton>
|
||||
</form>
|
||||
</AppModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export interface DnsAccountItem {
|
||||
id: number;
|
||||
name: string;
|
||||
type: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface DnsAccountMutationPayload {
|
||||
name: string;
|
||||
type: string;
|
||||
authorization: string;
|
||||
}
|
||||
@@ -718,14 +718,6 @@ type PowListValues = {
|
||||
user_agents: string;
|
||||
};
|
||||
|
||||
const defaultPowList: PowListValues = {
|
||||
ips: '',
|
||||
ip_cidrs: '',
|
||||
paths: '',
|
||||
path_regexes: '',
|
||||
user_agents: '',
|
||||
};
|
||||
|
||||
const powSchema = z
|
||||
.object({
|
||||
pow_enabled: z.boolean(),
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
AuthSourcePayload,
|
||||
DatabaseCleanupPayload,
|
||||
DatabaseCleanupResult,
|
||||
ExternalAccountBinding,
|
||||
GeoIPLookupResult,
|
||||
OptionBatchPayload,
|
||||
OptionItem,
|
||||
@@ -76,6 +77,16 @@ export function deleteAuthSource(id: number) {
|
||||
});
|
||||
}
|
||||
|
||||
export function getExternalAccountBindings() {
|
||||
return apiRequest<ExternalAccountBinding[]>('/oauth/external-accounts/');
|
||||
}
|
||||
|
||||
export function deleteExternalAccountBinding(id: number) {
|
||||
return apiRequest<void>(`/oauth/external-accounts/${id}/delete`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export function getBootstrapToken() {
|
||||
return apiRequest<BootstrapTokenPayload>('/nodes/bootstrap-token');
|
||||
}
|
||||
|
||||
@@ -21,9 +21,11 @@ import { getPublicStatus } from '@/features/auth/api/public';
|
||||
import {
|
||||
bindEmail,
|
||||
cleanupDatabaseObservability,
|
||||
deleteExternalAccountBinding,
|
||||
generateAccessToken,
|
||||
getAuthSources,
|
||||
getBootstrapToken,
|
||||
getExternalAccountBindings,
|
||||
getOptions,
|
||||
getSettingsProfile,
|
||||
lookupGeoIP,
|
||||
@@ -55,6 +57,10 @@ import { formatDateTime } from '@/lib/utils/date';
|
||||
|
||||
const settingsQueryKey = ['settings', 'options'] as const;
|
||||
const authSourcesQueryKey = ['settings', 'auth-sources'] as const;
|
||||
const externalAccountBindingsQueryKey = [
|
||||
'settings',
|
||||
'external-accounts',
|
||||
] as const;
|
||||
const installerScriptUrl =
|
||||
'https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-agent.sh';
|
||||
|
||||
@@ -66,7 +72,6 @@ const defaultSystemFields = {
|
||||
GitHubOAuthEnabled: false,
|
||||
WeChatAuthEnabled: false,
|
||||
TurnstileCheckEnabled: false,
|
||||
RegisterEnabled: false,
|
||||
SMTPServer: '',
|
||||
SMTPPort: '587',
|
||||
SMTPAccount: '',
|
||||
@@ -270,6 +275,11 @@ export function SettingsPage() {
|
||||
queryFn: getSettingsProfile,
|
||||
});
|
||||
|
||||
const externalAccountsQuery = useQuery({
|
||||
queryKey: externalAccountBindingsQueryKey,
|
||||
queryFn: getExternalAccountBindings,
|
||||
});
|
||||
|
||||
const optionsQuery = useQuery({
|
||||
queryKey: settingsQueryKey,
|
||||
queryFn: getOptions,
|
||||
@@ -354,7 +364,6 @@ export function SettingsPage() {
|
||||
GitHubOAuthEnabled: toBoolean(optionMap.GitHubOAuthEnabled, false),
|
||||
WeChatAuthEnabled: toBoolean(optionMap.WeChatAuthEnabled, false),
|
||||
TurnstileCheckEnabled: toBoolean(optionMap.TurnstileCheckEnabled, false),
|
||||
RegisterEnabled: toBoolean(optionMap.RegisterEnabled, false),
|
||||
SMTPServer: optionMap.SMTPServer ?? '',
|
||||
SMTPPort: optionMap.SMTPPort ?? '587',
|
||||
SMTPAccount: optionMap.SMTPAccount ?? '',
|
||||
@@ -642,6 +651,19 @@ export function SettingsPage() {
|
||||
});
|
||||
};
|
||||
|
||||
const handleUnbindAuthSource = (id: number, label: string) => {
|
||||
if (!window.confirm(`确定解绑「${label}」吗?`)) {
|
||||
return;
|
||||
}
|
||||
void runBusyAction(`auth-source-unbind-${id}`, async () => {
|
||||
await deleteExternalAccountBinding(id);
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: externalAccountBindingsQueryKey,
|
||||
});
|
||||
setFeedback({ tone: 'success', message: '第三方账号已解绑。' });
|
||||
});
|
||||
};
|
||||
|
||||
const handleToggleOption = (
|
||||
key: keyof typeof systemFields,
|
||||
nextValue: boolean,
|
||||
@@ -676,8 +698,21 @@ export function SettingsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
if (externalAccountsQuery.isError) {
|
||||
return (
|
||||
<ErrorState
|
||||
title="账号绑定加载失败"
|
||||
description={getErrorMessage(externalAccountsQuery.error)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const publicStatus = publicStatusQuery.data;
|
||||
const profile = profileQuery.data;
|
||||
const externalAccounts = externalAccountsQuery.data ?? [];
|
||||
const externalAccountMap = new Map(
|
||||
externalAccounts.map((account) => [account.auth_source_name, account]),
|
||||
);
|
||||
|
||||
if (!publicStatus || !profile) {
|
||||
return (
|
||||
@@ -832,18 +867,73 @@ export function SettingsPage() {
|
||||
登录状态下发起授权会直接绑定到当前账号。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<div className="space-y-3">
|
||||
{(publicStatus.auth_sources ?? []).length > 0 ? (
|
||||
publicStatus.auth_sources.map((source) => (
|
||||
<PrimaryButton
|
||||
key={source.id}
|
||||
type="button"
|
||||
onClick={() => handleBindAuthSource(source.name)}
|
||||
disabled={busyKey === `auth-source-bind-${source.name}`}
|
||||
>
|
||||
绑定 {source.display_name || source.name}
|
||||
</PrimaryButton>
|
||||
))
|
||||
publicStatus.auth_sources.map((source) => {
|
||||
const binding = externalAccountMap.get(source.name);
|
||||
const label = source.display_name || source.name;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={source.id}
|
||||
className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-card)] px-4 py-3"
|
||||
>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<p className="text-sm font-medium text-[var(--foreground-primary)]">
|
||||
{label}
|
||||
</p>
|
||||
{binding ? (
|
||||
<>
|
||||
<p className="text-sm break-all text-[var(--foreground-secondary)]">
|
||||
已绑定:
|
||||
{binding.external_username ||
|
||||
binding.email ||
|
||||
'第三方账号'}
|
||||
</p>
|
||||
{binding.email ? (
|
||||
<p className="text-xs break-all text-[var(--foreground-muted)]">
|
||||
邮箱:{binding.email}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="text-xs text-[var(--foreground-muted)]">
|
||||
绑定时间:
|
||||
{formatDateTime(binding.created_at)}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-[var(--foreground-secondary)]">
|
||||
未绑定
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{binding ? (
|
||||
<DangerButton
|
||||
type="button"
|
||||
onClick={() =>
|
||||
handleUnbindAuthSource(binding.id, label)
|
||||
}
|
||||
disabled={
|
||||
busyKey === `auth-source-unbind-${binding.id}`
|
||||
}
|
||||
>
|
||||
解绑
|
||||
</DangerButton>
|
||||
) : (
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
onClick={() => handleBindAuthSource(source.name)}
|
||||
disabled={
|
||||
busyKey === `auth-source-bind-${source.name}`
|
||||
}
|
||||
>
|
||||
绑定 {label}
|
||||
</PrimaryButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<span className="text-sm text-[var(--foreground-secondary)]">
|
||||
当前未启用认证源。
|
||||
@@ -1479,15 +1569,7 @@ export function SettingsPage() {
|
||||
}
|
||||
disabled={busyKey === 'toggle-EmailVerificationEnabled'}
|
||||
/>
|
||||
<ToggleField
|
||||
label="允许新用户注册"
|
||||
description="关闭后将禁止所有新用户注册入口。"
|
||||
checked={systemFields.RegisterEnabled}
|
||||
onChange={(checked) =>
|
||||
handleToggleOption('RegisterEnabled', checked)
|
||||
}
|
||||
disabled={busyKey === 'toggle-RegisterEnabled'}
|
||||
/>
|
||||
|
||||
</div>
|
||||
<div className="mt-5 text-sm text-[var(--foreground-secondary)]">
|
||||
当前已配置 {authSourcesQuery.data?.length ?? 0} 个认证源。
|
||||
|
||||
@@ -25,6 +25,17 @@ export interface AuthSource {
|
||||
icon_url: string;
|
||||
}
|
||||
|
||||
export interface ExternalAccountBinding {
|
||||
id: number;
|
||||
auth_source_id: number;
|
||||
auth_source_name: string;
|
||||
auth_source_type: AuthSourceType;
|
||||
auth_source_label: string;
|
||||
external_username: string;
|
||||
email: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export type AuthSourcePayload = Omit<
|
||||
AuthSource,
|
||||
'id' | 'client_secret_configured'
|
||||
|
||||
@@ -9,8 +9,8 @@ import type {
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
interface ResourceFieldProps {
|
||||
label: string;
|
||||
hint?: string;
|
||||
label: ReactNode;
|
||||
hint?: ReactNode;
|
||||
error?: string;
|
||||
className?: string;
|
||||
tooltip?: string;
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
TlsCertificateFileImportPayload,
|
||||
TlsCertificateItem,
|
||||
TlsCertificateMutationPayload,
|
||||
TlsCertificateApplyPayload,
|
||||
} from '@/features/tls-certificates/types';
|
||||
|
||||
export function getTlsCertificates() {
|
||||
@@ -52,6 +53,26 @@ export function importTlsCertificateFiles(payload: TlsCertificateFileImportPaylo
|
||||
|
||||
export function deleteTlsCertificate(id: number) {
|
||||
return apiRequest<void>(`/tls-certificates/${id}/delete`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export function applyTlsCertificate(payload: TlsCertificateApplyPayload) {
|
||||
return apiRequest<TlsCertificateItem>('/tls-certificates/apply', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function renewTlsCertificate(id: number) {
|
||||
return apiRequest<TlsCertificateItem>(`/tls-certificates/${id}/renew`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export function updateAcmeCertificate(id: number, payload: TlsCertificateApplyPayload) {
|
||||
return apiRequest<TlsCertificateItem>(`/tls-certificates/${id}/update-acme`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
+86
-4
@@ -14,11 +14,13 @@ import { StatusBadge } from '@/components/ui/status-badge';
|
||||
import {
|
||||
deleteTlsCertificate,
|
||||
getTlsCertificates,
|
||||
renewTlsCertificate,
|
||||
} 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 { CertificateApplyModal } from '@/features/websites/components/certificate-apply-modal';
|
||||
import { getCertificateStatus, getErrorMessage } from '@/features/websites/utils';
|
||||
import {
|
||||
DangerButton,
|
||||
@@ -38,11 +40,13 @@ export function TlsCertificatesPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
|
||||
const [isImportOpen, setIsImportOpen] = useState(false);
|
||||
const [isApplyOpen, setIsApplyOpen] = useState(false);
|
||||
const [selectedCertificateId, setSelectedCertificateId] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
const [isDetailOpen, setIsDetailOpen] = useState(false);
|
||||
const [isEditorOpen, setIsEditorOpen] = useState(false);
|
||||
const [editAcmeCertificate, setEditAcmeCertificate] = useState<TlsCertificateItem | null>(null);
|
||||
|
||||
const certificatesQuery = useQuery({
|
||||
queryKey: certificatesQueryKey,
|
||||
@@ -60,6 +64,17 @@ export function TlsCertificatesPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const renewCertificateMutation = useMutation({
|
||||
mutationFn: renewTlsCertificate,
|
||||
onSuccess: async (cert) => {
|
||||
setFeedback({ tone: 'success', message: `证书 ${cert.name} 续期任务已提交。` });
|
||||
await queryClient.invalidateQueries({ queryKey: ['tls-certificates'] });
|
||||
},
|
||||
onError: (error) => {
|
||||
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
|
||||
},
|
||||
});
|
||||
|
||||
const certificates = useMemo(
|
||||
() => certificatesQuery.data ?? [],
|
||||
[certificatesQuery.data],
|
||||
@@ -74,14 +89,27 @@ export function TlsCertificatesPage() {
|
||||
deleteCertificateMutation.mutate(certificate.id);
|
||||
};
|
||||
|
||||
const handleRenewCertificate = (certificate: TlsCertificateItem) => {
|
||||
if (!window.confirm(`确认提交证书 ${certificate.name} 的续期申请吗?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setFeedback(null);
|
||||
renewCertificateMutation.mutate(certificate.id);
|
||||
};
|
||||
|
||||
const handleOpenCertificateDetail = (certificate: TlsCertificateItem) => {
|
||||
setSelectedCertificateId(certificate.id);
|
||||
setIsDetailOpen(true);
|
||||
};
|
||||
|
||||
const handleOpenCertificateEditor = (certificate: TlsCertificateItem) => {
|
||||
setSelectedCertificateId(certificate.id);
|
||||
setIsEditorOpen(true);
|
||||
if (certificate.provider === 'acme') {
|
||||
setEditAcmeCertificate(certificate);
|
||||
} else {
|
||||
setSelectedCertificateId(certificate.id);
|
||||
setIsEditorOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -108,8 +136,17 @@ export function TlsCertificatesPage() {
|
||||
>
|
||||
刷新证书
|
||||
</SecondaryButton>
|
||||
<Link
|
||||
href="/website/dns-account"
|
||||
className="inline-flex min-h-[46px] items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-3 text-sm font-medium text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
|
||||
>
|
||||
DNS 账号
|
||||
</Link>
|
||||
<PrimaryButton type="button" onClick={() => setIsImportOpen(true)}>
|
||||
添加证书
|
||||
导入证书
|
||||
</PrimaryButton>
|
||||
<PrimaryButton type="button" onClick={() => setIsApplyOpen(true)}>
|
||||
申请证书
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
}
|
||||
@@ -159,6 +196,9 @@ export function TlsCertificatesPage() {
|
||||
<div className="text-xs leading-5 text-[var(--foreground-secondary)]">
|
||||
<p>生效:{formatDateTime(certificate.not_before)}</p>
|
||||
<p>到期:{formatDateTime(certificate.not_after)}</p>
|
||||
<p>来源:{certificate.provider === 'acme' ? 'ACME 申请' : '手动上传'}</p>
|
||||
{certificate.apply_status === 'applying' && <p className="text-blue-500">状态:申请中...</p>}
|
||||
{certificate.apply_status === 'error' && <p className="text-red-500">状态:申请失败 ({certificate.apply_message})</p>}
|
||||
<p>备注:{certificate.remark || '暂无备注'}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -178,6 +218,16 @@ export function TlsCertificatesPage() {
|
||||
>
|
||||
编辑
|
||||
</SecondaryButton>
|
||||
{certificate.provider === 'acme' && (
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={() => handleRenewCertificate(certificate)}
|
||||
disabled={renewCertificateMutation.isPending}
|
||||
className="px-3 py-2 text-xs"
|
||||
>
|
||||
续期
|
||||
</SecondaryButton>
|
||||
)}
|
||||
<DangerButton
|
||||
type="button"
|
||||
onClick={() => handleDeleteCertificate(certificate)}
|
||||
@@ -209,6 +259,33 @@ export function TlsCertificatesPage() {
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{isApplyOpen ? (
|
||||
<CertificateApplyModal
|
||||
isOpen={isApplyOpen}
|
||||
onClose={() => setIsApplyOpen(false)}
|
||||
onApplied={(certificate) => {
|
||||
setFeedback({
|
||||
tone: 'success',
|
||||
message: `证书 ${certificate.name} 申请任务已提交。`,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{editAcmeCertificate ? (
|
||||
<CertificateApplyModal
|
||||
isOpen={true}
|
||||
onClose={() => setEditAcmeCertificate(null)}
|
||||
editCertificate={editAcmeCertificate}
|
||||
onApplied={(certificate) => {
|
||||
setFeedback({
|
||||
tone: 'success',
|
||||
message: `证书 ${certificate.name} 配置已更新,重新申请中...`,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{isDetailOpen ? (
|
||||
<CertificateDetailModal
|
||||
certificateId={selectedCertificateId}
|
||||
@@ -216,7 +293,12 @@ export function TlsCertificatesPage() {
|
||||
onClose={() => setIsDetailOpen(false)}
|
||||
onEdit={() => {
|
||||
setIsDetailOpen(false);
|
||||
setIsEditorOpen(true);
|
||||
const certificate = certificates.find(
|
||||
(item) => item.id === selectedCertificateId,
|
||||
);
|
||||
if (certificate) {
|
||||
handleOpenCertificateEditor(certificate);
|
||||
}
|
||||
}}
|
||||
onDelete={() => {
|
||||
const certificate = certificates.find(
|
||||
|
||||
@@ -3,6 +3,19 @@ export interface TlsCertificateItem {
|
||||
name: string;
|
||||
cert_pem?: string;
|
||||
key_pem?: string;
|
||||
provider: string;
|
||||
acme_account_id: number;
|
||||
dns_account_id: number;
|
||||
key_algorithm: string;
|
||||
auto_renew: boolean;
|
||||
primary_domain: string;
|
||||
other_domains: string;
|
||||
disable_cname: boolean;
|
||||
skip_dns: boolean;
|
||||
dns1: string;
|
||||
dns2: string;
|
||||
apply_status: string;
|
||||
apply_message: string;
|
||||
not_before: string;
|
||||
not_after: string;
|
||||
remark: string;
|
||||
@@ -27,6 +40,21 @@ export interface TlsCertificateMutationPayload {
|
||||
remark: string;
|
||||
}
|
||||
|
||||
export interface TlsCertificateApplyPayload {
|
||||
name: string;
|
||||
remark: string;
|
||||
acme_account_id: number;
|
||||
dns_account_id: number;
|
||||
key_algorithm: string;
|
||||
auto_renew: boolean;
|
||||
primary_domain: string;
|
||||
other_domains: string;
|
||||
disable_cname: boolean;
|
||||
skip_dns: boolean;
|
||||
dns1: string;
|
||||
dns2: string;
|
||||
}
|
||||
|
||||
export interface TlsCertificateFileImportPayload {
|
||||
name: string;
|
||||
remark: string;
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
'use client';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
import { InlineMessage } from '@/components/feedback/inline-message';
|
||||
import { AppModal } from '@/components/ui/app-modal';
|
||||
import { applyTlsCertificate, updateAcmeCertificate } from '@/features/tls-certificates/api/tls-certificates';
|
||||
import type { TlsCertificateItem } from '@/features/tls-certificates/types';
|
||||
import { getDnsAccounts } from '@/features/dns-accounts/api/dns-accounts';
|
||||
import { getDefaultAcmeAccount } from '@/features/acme-accounts/api/acme-accounts';
|
||||
import {
|
||||
acmeApplySchema,
|
||||
defaultAcmeApplyValues,
|
||||
type AcmeApplyFormValues,
|
||||
} from '@/features/websites/schemas';
|
||||
import { getErrorMessage } from '@/features/websites/utils';
|
||||
import {
|
||||
PrimaryButton,
|
||||
ResourceField,
|
||||
ResourceInput,
|
||||
ResourceSelect,
|
||||
ToggleField,
|
||||
} from '@/features/shared/components/resource-primitives';
|
||||
|
||||
interface CertificateApplyModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onApplied?: (certificate: TlsCertificateItem) => void;
|
||||
editCertificate?: TlsCertificateItem | null;
|
||||
}
|
||||
|
||||
export function CertificateApplyModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onApplied,
|
||||
editCertificate,
|
||||
}: CertificateApplyModalProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [feedback, setFeedback] = useState<{ tone: 'success' | 'danger'; message: string } | null>(null);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
|
||||
const dnsAccountsQuery = useQuery({
|
||||
queryKey: ['dns-accounts'],
|
||||
queryFn: getDnsAccounts,
|
||||
enabled: isOpen,
|
||||
});
|
||||
|
||||
const defaultAcmeAccountQuery = useQuery({
|
||||
queryKey: ['acme-accounts', 'default'],
|
||||
queryFn: getDefaultAcmeAccount,
|
||||
enabled: isOpen,
|
||||
});
|
||||
|
||||
const form = useForm<AcmeApplyFormValues>({
|
||||
resolver: zodResolver(acmeApplySchema),
|
||||
defaultValues: defaultAcmeApplyValues,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
setFeedback(null);
|
||||
setShowAdvanced(false);
|
||||
|
||||
if (editCertificate) {
|
||||
form.reset({
|
||||
name: editCertificate.name,
|
||||
primary_domain: editCertificate.primary_domain || '',
|
||||
other_domains: editCertificate.other_domains || '',
|
||||
remark: editCertificate.remark || '',
|
||||
acme_account_id: editCertificate.acme_account_id,
|
||||
dns_account_id: editCertificate.dns_account_id,
|
||||
key_algorithm: editCertificate.key_algorithm as any || 'EC256',
|
||||
auto_renew: editCertificate.auto_renew,
|
||||
dns1: editCertificate.dns1 || '',
|
||||
dns2: editCertificate.dns2 || '',
|
||||
disable_cname: editCertificate.disable_cname,
|
||||
skip_dns: editCertificate.skip_dns,
|
||||
});
|
||||
if (editCertificate.dns1 || editCertificate.dns2 || editCertificate.disable_cname || editCertificate.skip_dns) {
|
||||
setShowAdvanced(true);
|
||||
}
|
||||
} else {
|
||||
form.reset(defaultAcmeApplyValues);
|
||||
}
|
||||
}, [isOpen, form, editCertificate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (defaultAcmeAccountQuery.data) {
|
||||
form.setValue('acme_account_id', defaultAcmeAccountQuery.data.id);
|
||||
}
|
||||
}, [defaultAcmeAccountQuery.data, form]);
|
||||
|
||||
const applyMutation = useMutation({
|
||||
mutationFn: (values: AcmeApplyFormValues) =>
|
||||
editCertificate
|
||||
? updateAcmeCertificate(editCertificate.id, values)
|
||||
: applyTlsCertificate(values),
|
||||
onSuccess: async (certificate) => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['tls-certificates'] });
|
||||
onApplied?.(certificate);
|
||||
onClose();
|
||||
},
|
||||
onError: (error) => {
|
||||
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = form.handleSubmit((values) => {
|
||||
setFeedback(null);
|
||||
applyMutation.mutate(values);
|
||||
});
|
||||
|
||||
return (
|
||||
<AppModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={editCertificate ? "编辑并重新申请证书" : "申请证书"}
|
||||
description={editCertificate ? "修改 ACME 证书配置。保存后将使用新配置重新申请证书。" : "使用 ACME (Let's Encrypt 等) 自动申请和续期证书,支持通配符域名。"}
|
||||
size="xl"
|
||||
>
|
||||
<form className="space-y-5" onSubmit={onSubmit}>
|
||||
{feedback ? (
|
||||
<InlineMessage tone={feedback.tone} message={feedback.message} />
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<ResourceField
|
||||
label="证书名称"
|
||||
error={form.formState.errors.name?.message}
|
||||
>
|
||||
<ResourceInput placeholder="例如:主站证书" {...form.register('name')} />
|
||||
</ResourceField>
|
||||
<ResourceField
|
||||
label="主域名"
|
||||
error={form.formState.errors.primary_domain?.message}
|
||||
>
|
||||
<ResourceInput placeholder="example.com 或 *.example.com" {...form.register('primary_domain')} />
|
||||
</ResourceField>
|
||||
</div>
|
||||
|
||||
<ResourceField
|
||||
label="其他域名"
|
||||
hint="每行一个域名。如申请通配符证书,请填写对应的根域名以便一并签发。"
|
||||
error={form.formState.errors.other_domains?.message}
|
||||
>
|
||||
<textarea
|
||||
className="w-full rounded-xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-3 text-sm text-[var(--foreground-primary)] outline-none transition focus:border-[var(--brand-primary)]"
|
||||
rows={3}
|
||||
placeholder="example.net"
|
||||
{...form.register('other_domains')}
|
||||
/>
|
||||
</ResourceField>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<ResourceField
|
||||
label="DNS 服务商账号"
|
||||
error={form.formState.errors.dns_account_id?.message}
|
||||
>
|
||||
<ResourceSelect {...form.register('dns_account_id')}>
|
||||
<option value={0}>请选择 DNS 账号</option>
|
||||
{dnsAccountsQuery.data?.map((acc) => (
|
||||
<option key={acc.id} value={acc.id}>
|
||||
{acc.name} ({acc.type})
|
||||
</option>
|
||||
))}
|
||||
</ResourceSelect>
|
||||
</ResourceField>
|
||||
|
||||
<ResourceField
|
||||
label="密钥算法"
|
||||
error={form.formState.errors.key_algorithm?.message}
|
||||
>
|
||||
<ResourceSelect {...form.register('key_algorithm')}>
|
||||
<option value="RSA2048">RSA 2048</option>
|
||||
<option value="RSA4096">RSA 4096</option>
|
||||
<option value="EC256">ECC 256</option>
|
||||
<option value="EC384">ECC 384</option>
|
||||
</ResourceSelect>
|
||||
</ResourceField>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-1">
|
||||
<ResourceField
|
||||
label="备注"
|
||||
error={form.formState.errors.remark?.message}
|
||||
>
|
||||
<ResourceInput placeholder="可选,用于记录证书用途。" {...form.register('remark')} />
|
||||
</ResourceField>
|
||||
|
||||
<ToggleField
|
||||
label="开启自动续签"
|
||||
description="开启后,将在证书过期前 7 天自动续期。"
|
||||
checked={form.watch('auto_renew')}
|
||||
onChange={(checked) => form.setValue('auto_renew', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between px-4 py-3 text-sm font-medium text-[var(--foreground-primary)] transition hover:bg-[var(--surface-muted)]"
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
>
|
||||
<span>高级选项</span>
|
||||
<svg
|
||||
className={`h-4 w-4 transition-transform duration-200 ${showAdvanced ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
{showAdvanced && (
|
||||
<div className="space-y-4 border-t border-[var(--border-default)] px-4 py-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<ResourceField
|
||||
label="DNS 验证服务器 1"
|
||||
hint="可选,如 8.8.8.8"
|
||||
error={form.formState.errors.dns1?.message}
|
||||
>
|
||||
<ResourceInput placeholder="为空则使用默认权威 DNS" {...form.register('dns1')} />
|
||||
</ResourceField>
|
||||
<ResourceField
|
||||
label="DNS 验证服务器 2"
|
||||
hint="可选,如 1.1.1.1"
|
||||
error={form.formState.errors.dns2?.message}
|
||||
>
|
||||
<ResourceInput placeholder="为空则使用默认权威 DNS" {...form.register('dns2')} />
|
||||
</ResourceField>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<ToggleField
|
||||
label="跳过 CNAME 检查"
|
||||
description="在执行 DNS-01 验证时不追踪 CNAME 记录。"
|
||||
checked={form.watch('disable_cname')}
|
||||
onChange={(checked) => form.setValue('disable_cname', checked)}
|
||||
/>
|
||||
<ToggleField
|
||||
label="跳过 DNS 前置检查"
|
||||
description="直接请求 Let's Encrypt 验证而不做本地校验。"
|
||||
checked={form.watch('skip_dns')}
|
||||
onChange={(checked) => form.setValue('skip_dns', checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<PrimaryButton type="submit" disabled={applyMutation.isPending}>
|
||||
{applyMutation.isPending ? '提交中...' : (editCertificate ? '保存并申请' : '开始申请')}
|
||||
</PrimaryButton>
|
||||
</form>
|
||||
</AppModal>
|
||||
);
|
||||
}
|
||||
@@ -65,3 +65,36 @@ export const defaultFileImportValues: FileImportFormValues = {
|
||||
name: '',
|
||||
remark: '',
|
||||
};
|
||||
|
||||
export const acmeApplySchema = z.object({
|
||||
name: z.string().trim().min(1, '请输入证书名称').max(255),
|
||||
primary_domain: z.string().trim().min(1, '请输入主域名'),
|
||||
other_domains: z.string(),
|
||||
dns_account_id: z.coerce.number().min(1, '请选择 DNS 账号'),
|
||||
acme_account_id: z.coerce.number(),
|
||||
key_algorithm: z.string(),
|
||||
auto_renew: z.boolean(),
|
||||
disable_cname: z.boolean().default(false),
|
||||
skip_dns: z.boolean().default(false),
|
||||
dns1: z.string().default(''),
|
||||
dns2: z.string().default(''),
|
||||
remark: z.string().max(255),
|
||||
});
|
||||
|
||||
export type AcmeApplyFormValues = z.infer<typeof acmeApplySchema>;
|
||||
|
||||
export const defaultAcmeApplyValues: AcmeApplyFormValues = {
|
||||
name: '',
|
||||
primary_domain: '',
|
||||
other_domains: '',
|
||||
dns_account_id: 0,
|
||||
acme_account_id: 0,
|
||||
key_algorithm: 'RSA2048',
|
||||
auto_renew: true,
|
||||
disable_cname: false,
|
||||
skip_dns: false,
|
||||
dns1: '',
|
||||
dns2: '',
|
||||
remark: '',
|
||||
};
|
||||
|
||||
|
||||
@@ -11,6 +11,11 @@ export const dashboardNavigation: NavigationItem[] = [
|
||||
label: '节点',
|
||||
icon: 'node',
|
||||
},
|
||||
{
|
||||
href: '/proxy-route',
|
||||
label: '规则',
|
||||
icon: 'proxy',
|
||||
},
|
||||
{
|
||||
href: '/website',
|
||||
label: '网站',
|
||||
@@ -21,11 +26,6 @@ export const dashboardNavigation: NavigationItem[] = [
|
||||
label: '源站',
|
||||
icon: 'origin',
|
||||
},
|
||||
{
|
||||
href: '/proxy-route',
|
||||
label: '规则',
|
||||
icon: 'proxy',
|
||||
},
|
||||
{
|
||||
href: '/config-version',
|
||||
label: '发布',
|
||||
@@ -42,12 +42,6 @@ export const dashboardNavigation: NavigationItem[] = [
|
||||
label: '性能',
|
||||
icon: 'performance',
|
||||
},
|
||||
{
|
||||
href: '/user',
|
||||
label: '用户',
|
||||
icon: 'user',
|
||||
},
|
||||
|
||||
{
|
||||
href: '/setting',
|
||||
label: '设置',
|
||||
|
||||
Reference in New Issue
Block a user