init
This commit is contained in:
commit
b10790c212
40 changed files with 4149 additions and 0 deletions
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
pages
|
||||
log
|
||||
*.db
|
||||
caddy
|
||||
caddydash
|
||||
config.d
|
||||
90
Caddyfile
Normal file
90
Caddyfile
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
{
|
||||
debug
|
||||
admin :2019
|
||||
http_port 80
|
||||
https_port 443
|
||||
metrics
|
||||
order ja4h_header first
|
||||
order webdav before file_server
|
||||
order cache before rewrite
|
||||
cache {
|
||||
cache_name CaddyCache
|
||||
}
|
||||
log {
|
||||
level INFO
|
||||
output file ./log/caddy.log {
|
||||
roll_size 10MB
|
||||
roll_keep 10
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(log) {
|
||||
log {
|
||||
format transform `{request>headers>X-Forwarded-For>[0]:request>remote_ip} - {user_id} [{ts}] "{request>method} {request>uri} {request>proto}" {status} {size} "{request>headers>Referer>[0]}" "{request>headers>User-Agent>[0]}"` {
|
||||
time_format "02/Jan/2006:15:04:05 -0700"
|
||||
}
|
||||
output file ./log/{args[0]}/access.log {
|
||||
roll_size 10MB
|
||||
roll_keep 10
|
||||
roll_keep_for 24h
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(error_page) {
|
||||
handle_errors {
|
||||
rewrite * /{err.status_code}.html
|
||||
root * ./pages/errors
|
||||
file_server
|
||||
}
|
||||
}
|
||||
|
||||
(encode) {
|
||||
encode {
|
||||
zstd
|
||||
br
|
||||
gzip
|
||||
minimum_length 512
|
||||
}
|
||||
}
|
||||
|
||||
(cache) {
|
||||
cache {
|
||||
allowed_http_verbs GET
|
||||
stale {args[0]}
|
||||
ttl {args[1]}
|
||||
}
|
||||
}
|
||||
|
||||
(header_realip) {
|
||||
header_up X-Real-IP {remote_host}
|
||||
header_up X-Real-IP {http.request.header.CF-Connecting-IP}
|
||||
header_up X-Forwarded-For {http.request.header.CF-Connecting-IP}
|
||||
header_up X-Forwarded-Proto {http.request.header.CF-Visitor}
|
||||
}
|
||||
|
||||
(tls) {
|
||||
tls {
|
||||
dns {args[0]} {args[1]}
|
||||
}
|
||||
}
|
||||
|
||||
(rate_limit) {
|
||||
route /* {
|
||||
rate_limit {remote.ip} {args[0]}r/m 10000 429
|
||||
}
|
||||
}
|
||||
|
||||
(route_nocache) {
|
||||
route {args[0]} {
|
||||
rate_limit {remote.ip} {args[1]}r/m 10000 429
|
||||
cache {
|
||||
stale 0s
|
||||
ttl 0s
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
import ./config.d/*
|
||||
#import ./config/*
|
||||
373
LICENSE
Normal file
373
LICENSE
Normal file
|
|
@ -0,0 +1,373 @@
|
|||
Mozilla Public License Version 2.0
|
||||
==================================
|
||||
|
||||
1. Definitions
|
||||
--------------
|
||||
|
||||
1.1. "Contributor"
|
||||
means each individual or legal entity that creates, contributes to
|
||||
the creation of, or owns Covered Software.
|
||||
|
||||
1.2. "Contributor Version"
|
||||
means the combination of the Contributions of others (if any) used
|
||||
by a Contributor and that particular Contributor's Contribution.
|
||||
|
||||
1.3. "Contribution"
|
||||
means Covered Software of a particular Contributor.
|
||||
|
||||
1.4. "Covered Software"
|
||||
means Source Code Form to which the initial Contributor has attached
|
||||
the notice in Exhibit A, the Executable Form of such Source Code
|
||||
Form, and Modifications of such Source Code Form, in each case
|
||||
including portions thereof.
|
||||
|
||||
1.5. "Incompatible With Secondary Licenses"
|
||||
means
|
||||
|
||||
(a) that the initial Contributor has attached the notice described
|
||||
in Exhibit B to the Covered Software; or
|
||||
|
||||
(b) that the Covered Software was made available under the terms of
|
||||
version 1.1 or earlier of the License, but not also under the
|
||||
terms of a Secondary License.
|
||||
|
||||
1.6. "Executable Form"
|
||||
means any form of the work other than Source Code Form.
|
||||
|
||||
1.7. "Larger Work"
|
||||
means a work that combines Covered Software with other material, in
|
||||
a separate file or files, that is not Covered Software.
|
||||
|
||||
1.8. "License"
|
||||
means this document.
|
||||
|
||||
1.9. "Licensable"
|
||||
means having the right to grant, to the maximum extent possible,
|
||||
whether at the time of the initial grant or subsequently, any and
|
||||
all of the rights conveyed by this License.
|
||||
|
||||
1.10. "Modifications"
|
||||
means any of the following:
|
||||
|
||||
(a) any file in Source Code Form that results from an addition to,
|
||||
deletion from, or modification of the contents of Covered
|
||||
Software; or
|
||||
|
||||
(b) any new file in Source Code Form that contains any Covered
|
||||
Software.
|
||||
|
||||
1.11. "Patent Claims" of a Contributor
|
||||
means any patent claim(s), including without limitation, method,
|
||||
process, and apparatus claims, in any patent Licensable by such
|
||||
Contributor that would be infringed, but for the grant of the
|
||||
License, by the making, using, selling, offering for sale, having
|
||||
made, import, or transfer of either its Contributions or its
|
||||
Contributor Version.
|
||||
|
||||
1.12. "Secondary License"
|
||||
means either the GNU General Public License, Version 2.0, the GNU
|
||||
Lesser General Public License, Version 2.1, the GNU Affero General
|
||||
Public License, Version 3.0, or any later versions of those
|
||||
licenses.
|
||||
|
||||
1.13. "Source Code Form"
|
||||
means the form of the work preferred for making modifications.
|
||||
|
||||
1.14. "You" (or "Your")
|
||||
means an individual or a legal entity exercising rights under this
|
||||
License. For legal entities, "You" includes any entity that
|
||||
controls, is controlled by, or is under common control with You. For
|
||||
purposes of this definition, "control" means (a) the power, direct
|
||||
or indirect, to cause the direction or management of such entity,
|
||||
whether by contract or otherwise, or (b) ownership of more than
|
||||
fifty percent (50%) of the outstanding shares or beneficial
|
||||
ownership of such entity.
|
||||
|
||||
2. License Grants and Conditions
|
||||
--------------------------------
|
||||
|
||||
2.1. Grants
|
||||
|
||||
Each Contributor hereby grants You a world-wide, royalty-free,
|
||||
non-exclusive license:
|
||||
|
||||
(a) under intellectual property rights (other than patent or trademark)
|
||||
Licensable by such Contributor to use, reproduce, make available,
|
||||
modify, display, perform, distribute, and otherwise exploit its
|
||||
Contributions, either on an unmodified basis, with Modifications, or
|
||||
as part of a Larger Work; and
|
||||
|
||||
(b) under Patent Claims of such Contributor to make, use, sell, offer
|
||||
for sale, have made, import, and otherwise transfer either its
|
||||
Contributions or its Contributor Version.
|
||||
|
||||
2.2. Effective Date
|
||||
|
||||
The licenses granted in Section 2.1 with respect to any Contribution
|
||||
become effective for each Contribution on the date the Contributor first
|
||||
distributes such Contribution.
|
||||
|
||||
2.3. Limitations on Grant Scope
|
||||
|
||||
The licenses granted in this Section 2 are the only rights granted under
|
||||
this License. No additional rights or licenses will be implied from the
|
||||
distribution or licensing of Covered Software under this License.
|
||||
Notwithstanding Section 2.1(b) above, no patent license is granted by a
|
||||
Contributor:
|
||||
|
||||
(a) for any code that a Contributor has removed from Covered Software;
|
||||
or
|
||||
|
||||
(b) for infringements caused by: (i) Your and any other third party's
|
||||
modifications of Covered Software, or (ii) the combination of its
|
||||
Contributions with other software (except as part of its Contributor
|
||||
Version); or
|
||||
|
||||
(c) under Patent Claims infringed by Covered Software in the absence of
|
||||
its Contributions.
|
||||
|
||||
This License does not grant any rights in the trademarks, service marks,
|
||||
or logos of any Contributor (except as may be necessary to comply with
|
||||
the notice requirements in Section 3.4).
|
||||
|
||||
2.4. Subsequent Licenses
|
||||
|
||||
No Contributor makes additional grants as a result of Your choice to
|
||||
distribute the Covered Software under a subsequent version of this
|
||||
License (see Section 10.2) or under the terms of a Secondary License (if
|
||||
permitted under the terms of Section 3.3).
|
||||
|
||||
2.5. Representation
|
||||
|
||||
Each Contributor represents that the Contributor believes its
|
||||
Contributions are its original creation(s) or it has sufficient rights
|
||||
to grant the rights to its Contributions conveyed by this License.
|
||||
|
||||
2.6. Fair Use
|
||||
|
||||
This License is not intended to limit any rights You have under
|
||||
applicable copyright doctrines of fair use, fair dealing, or other
|
||||
equivalents.
|
||||
|
||||
2.7. Conditions
|
||||
|
||||
Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
|
||||
in Section 2.1.
|
||||
|
||||
3. Responsibilities
|
||||
-------------------
|
||||
|
||||
3.1. Distribution of Source Form
|
||||
|
||||
All distribution of Covered Software in Source Code Form, including any
|
||||
Modifications that You create or to which You contribute, must be under
|
||||
the terms of this License. You must inform recipients that the Source
|
||||
Code Form of the Covered Software is governed by the terms of this
|
||||
License, and how they can obtain a copy of this License. You may not
|
||||
attempt to alter or restrict the recipients' rights in the Source Code
|
||||
Form.
|
||||
|
||||
3.2. Distribution of Executable Form
|
||||
|
||||
If You distribute Covered Software in Executable Form then:
|
||||
|
||||
(a) such Covered Software must also be made available in Source Code
|
||||
Form, as described in Section 3.1, and You must inform recipients of
|
||||
the Executable Form how they can obtain a copy of such Source Code
|
||||
Form by reasonable means in a timely manner, at a charge no more
|
||||
than the cost of distribution to the recipient; and
|
||||
|
||||
(b) You may distribute such Executable Form under the terms of this
|
||||
License, or sublicense it under different terms, provided that the
|
||||
license for the Executable Form does not attempt to limit or alter
|
||||
the recipients' rights in the Source Code Form under this License.
|
||||
|
||||
3.3. Distribution of a Larger Work
|
||||
|
||||
You may create and distribute a Larger Work under terms of Your choice,
|
||||
provided that You also comply with the requirements of this License for
|
||||
the Covered Software. If the Larger Work is a combination of Covered
|
||||
Software with a work governed by one or more Secondary Licenses, and the
|
||||
Covered Software is not Incompatible With Secondary Licenses, this
|
||||
License permits You to additionally distribute such Covered Software
|
||||
under the terms of such Secondary License(s), so that the recipient of
|
||||
the Larger Work may, at their option, further distribute the Covered
|
||||
Software under the terms of either this License or such Secondary
|
||||
License(s).
|
||||
|
||||
3.4. Notices
|
||||
|
||||
You may not remove or alter the substance of any license notices
|
||||
(including copyright notices, patent notices, disclaimers of warranty,
|
||||
or limitations of liability) contained within the Source Code Form of
|
||||
the Covered Software, except that You may alter any license notices to
|
||||
the extent required to remedy known factual inaccuracies.
|
||||
|
||||
3.5. Application of Additional Terms
|
||||
|
||||
You may choose to offer, and to charge a fee for, warranty, support,
|
||||
indemnity or liability obligations to one or more recipients of Covered
|
||||
Software. However, You may do so only on Your own behalf, and not on
|
||||
behalf of any Contributor. You must make it absolutely clear that any
|
||||
such warranty, support, indemnity, or liability obligation is offered by
|
||||
You alone, and You hereby agree to indemnify every Contributor for any
|
||||
liability incurred by such Contributor as a result of warranty, support,
|
||||
indemnity or liability terms You offer. You may include additional
|
||||
disclaimers of warranty and limitations of liability specific to any
|
||||
jurisdiction.
|
||||
|
||||
4. Inability to Comply Due to Statute or Regulation
|
||||
---------------------------------------------------
|
||||
|
||||
If it is impossible for You to comply with any of the terms of this
|
||||
License with respect to some or all of the Covered Software due to
|
||||
statute, judicial order, or regulation then You must: (a) comply with
|
||||
the terms of this License to the maximum extent possible; and (b)
|
||||
describe the limitations and the code they affect. Such description must
|
||||
be placed in a text file included with all distributions of the Covered
|
||||
Software under this License. Except to the extent prohibited by statute
|
||||
or regulation, such description must be sufficiently detailed for a
|
||||
recipient of ordinary skill to be able to understand it.
|
||||
|
||||
5. Termination
|
||||
--------------
|
||||
|
||||
5.1. The rights granted under this License will terminate automatically
|
||||
if You fail to comply with any of its terms. However, if You become
|
||||
compliant, then the rights granted under this License from a particular
|
||||
Contributor are reinstated (a) provisionally, unless and until such
|
||||
Contributor explicitly and finally terminates Your grants, and (b) on an
|
||||
ongoing basis, if such Contributor fails to notify You of the
|
||||
non-compliance by some reasonable means prior to 60 days after You have
|
||||
come back into compliance. Moreover, Your grants from a particular
|
||||
Contributor are reinstated on an ongoing basis if such Contributor
|
||||
notifies You of the non-compliance by some reasonable means, this is the
|
||||
first time You have received notice of non-compliance with this License
|
||||
from such Contributor, and You become compliant prior to 30 days after
|
||||
Your receipt of the notice.
|
||||
|
||||
5.2. If You initiate litigation against any entity by asserting a patent
|
||||
infringement claim (excluding declaratory judgment actions,
|
||||
counter-claims, and cross-claims) alleging that a Contributor Version
|
||||
directly or indirectly infringes any patent, then the rights granted to
|
||||
You by any and all Contributors for the Covered Software under Section
|
||||
2.1 of this License shall terminate.
|
||||
|
||||
5.3. In the event of termination under Sections 5.1 or 5.2 above, all
|
||||
end user license agreements (excluding distributors and resellers) which
|
||||
have been validly granted by You or Your distributors under this License
|
||||
prior to termination shall survive termination.
|
||||
|
||||
************************************************************************
|
||||
* *
|
||||
* 6. Disclaimer of Warranty *
|
||||
* ------------------------- *
|
||||
* *
|
||||
* Covered Software is provided under this License on an "as is" *
|
||||
* basis, without warranty of any kind, either expressed, implied, or *
|
||||
* statutory, including, without limitation, warranties that the *
|
||||
* Covered Software is free of defects, merchantable, fit for a *
|
||||
* particular purpose or non-infringing. The entire risk as to the *
|
||||
* quality and performance of the Covered Software is with You. *
|
||||
* Should any Covered Software prove defective in any respect, You *
|
||||
* (not any Contributor) assume the cost of any necessary servicing, *
|
||||
* repair, or correction. This disclaimer of warranty constitutes an *
|
||||
* essential part of this License. No use of any Covered Software is *
|
||||
* authorized under this License except under this disclaimer. *
|
||||
* *
|
||||
************************************************************************
|
||||
|
||||
************************************************************************
|
||||
* *
|
||||
* 7. Limitation of Liability *
|
||||
* -------------------------- *
|
||||
* *
|
||||
* Under no circumstances and under no legal theory, whether tort *
|
||||
* (including negligence), contract, or otherwise, shall any *
|
||||
* Contributor, or anyone who distributes Covered Software as *
|
||||
* permitted above, be liable to You for any direct, indirect, *
|
||||
* special, incidental, or consequential damages of any character *
|
||||
* including, without limitation, damages for lost profits, loss of *
|
||||
* goodwill, work stoppage, computer failure or malfunction, or any *
|
||||
* and all other commercial damages or losses, even if such party *
|
||||
* shall have been informed of the possibility of such damages. This *
|
||||
* limitation of liability shall not apply to liability for death or *
|
||||
* personal injury resulting from such party's negligence to the *
|
||||
* extent applicable law prohibits such limitation. Some *
|
||||
* jurisdictions do not allow the exclusion or limitation of *
|
||||
* incidental or consequential damages, so this exclusion and *
|
||||
* limitation may not apply to You. *
|
||||
* *
|
||||
************************************************************************
|
||||
|
||||
8. Litigation
|
||||
-------------
|
||||
|
||||
Any litigation relating to this License may be brought only in the
|
||||
courts of a jurisdiction where the defendant maintains its principal
|
||||
place of business and such litigation shall be governed by laws of that
|
||||
jurisdiction, without reference to its conflict-of-law provisions.
|
||||
Nothing in this Section shall prevent a party's ability to bring
|
||||
cross-claims or counter-claims.
|
||||
|
||||
9. Miscellaneous
|
||||
----------------
|
||||
|
||||
This License represents the complete agreement concerning the subject
|
||||
matter hereof. If any provision of this License is held to be
|
||||
unenforceable, such provision shall be reformed only to the extent
|
||||
necessary to make it enforceable. Any law or regulation which provides
|
||||
that the language of a contract shall be construed against the drafter
|
||||
shall not be used to construe this License against a Contributor.
|
||||
|
||||
10. Versions of the License
|
||||
---------------------------
|
||||
|
||||
10.1. New Versions
|
||||
|
||||
Mozilla Foundation is the license steward. Except as provided in Section
|
||||
10.3, no one other than the license steward has the right to modify or
|
||||
publish new versions of this License. Each version will be given a
|
||||
distinguishing version number.
|
||||
|
||||
10.2. Effect of New Versions
|
||||
|
||||
You may distribute the Covered Software under the terms of the version
|
||||
of the License under which You originally received the Covered Software,
|
||||
or under the terms of any subsequent version published by the license
|
||||
steward.
|
||||
|
||||
10.3. Modified Versions
|
||||
|
||||
If you create software not governed by this License, and you want to
|
||||
create a new license for such software, you may create and use a
|
||||
modified version of this License if you rename the license and remove
|
||||
any references to the name of the license steward (except to note that
|
||||
such modified license differs from this License).
|
||||
|
||||
10.4. Distributing Source Code Form that is Incompatible With Secondary
|
||||
Licenses
|
||||
|
||||
If You choose to distribute Source Code Form that is Incompatible With
|
||||
Secondary Licenses under the terms of this version of the License, the
|
||||
notice described in Exhibit B of this License must be attached.
|
||||
|
||||
Exhibit A - Source Code Form License Notice
|
||||
-------------------------------------------
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
If it is not possible or desirable to put the notice in a particular
|
||||
file, then You may include the notice in a location (such as a LICENSE
|
||||
file in a relevant directory) where a recipient would be likely to look
|
||||
for such a notice.
|
||||
|
||||
You may add additional accurate notices of copyright ownership.
|
||||
|
||||
Exhibit B - "Incompatible With Secondary Licenses" Notice
|
||||
---------------------------------------------------------
|
||||
|
||||
This Source Code Form is "Incompatible With Secondary Licenses", as
|
||||
defined by the Mozilla Public License, v. 2.0.
|
||||
1
README.md
Normal file
1
README.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
# caddydash
|
||||
92
api/api.go
Normal file
92
api/api.go
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"caddydash/apic"
|
||||
"caddydash/config"
|
||||
"caddydash/db"
|
||||
"caddydash/user"
|
||||
|
||||
"github.com/infinite-iroha/touka"
|
||||
)
|
||||
|
||||
func ApiGroup(v0 touka.IRouter, cdb *db.ConfigDB, cfg *config.Config) {
|
||||
api := v0.Group("/api")
|
||||
api.GET("/config/filenames", func(c *touka.Context) {
|
||||
filenames, err := cdb.GetFileNames()
|
||||
if err != nil {
|
||||
c.JSON(500, touka.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(200, filenames)
|
||||
})
|
||||
|
||||
// 配置参数相关
|
||||
api.GET("/config/file/:filename", GetConfig(cdb)) // 读取配置(与写入一致)
|
||||
api.PUT("/config/file/:filename", PutConfig(cdb, cfg)) // 写入配置
|
||||
api.DELETE("/config/file/:filename", DeleteConfig(cdb, cfg)) //删除配置
|
||||
|
||||
api.GET("/config/files/params", FilesParams(cdb)) // 获取所有配置, 需进行decode
|
||||
api.GET("/config/files/templates", FilesTemplates(cdb)) // 获取所有模板
|
||||
api.GET("/config/files/rendered", FilesRendered(cdb)) // 获取所有渲染产物
|
||||
|
||||
api.GET("/config/templates", GetTemplates(cdb)) // 获取可用模板名称
|
||||
|
||||
// caddy实例相关
|
||||
api.POST("/caddy/stop", apic.StopCaddy()) // 无需payload
|
||||
api.POST("/caddy/run", apic.StartCaddy(cfg))
|
||||
api.POST("/caddy/restart", apic.RestartCaddy(cfg))
|
||||
api.GET("/caddy/status", apic.IsCaddyRunning())
|
||||
|
||||
auth := api.Group("/auth")
|
||||
{
|
||||
auth.POST("/login", func(c *touka.Context) {
|
||||
AuthLogin(c, cfg, cdb)
|
||||
})
|
||||
auth.POST("/logout", func(c *touka.Context) {
|
||||
AuthLogout(c)
|
||||
})
|
||||
auth.GET("/logout", func(c *touka.Context) {
|
||||
// 重定向到/
|
||||
c.Redirect(302, "/")
|
||||
c.Abort()
|
||||
return
|
||||
})
|
||||
auth.GET("/init", func(c *touka.Context) {
|
||||
// 返回是否init管理员
|
||||
isInit := user.IsAdminInit()
|
||||
if isInit {
|
||||
c.JSON(200, touka.H{"admin_init": true})
|
||||
} else {
|
||||
c.JSON(200, touka.H{"admin_init": false})
|
||||
}
|
||||
})
|
||||
auth.POST("/init", func(c *touka.Context) {
|
||||
username := c.PostForm("username")
|
||||
password := c.PostForm("password")
|
||||
// 验证是否为空
|
||||
if username == "" || password == "" {
|
||||
c.JSON(400, touka.H{"error": "username and password are required"})
|
||||
return
|
||||
}
|
||||
// 初始化管理员
|
||||
err := user.InitAdminUser(username, password, cdb)
|
||||
if err != nil {
|
||||
c.JSON(500, touka.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(200, touka.H{"message": "admin initialized"})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// GetTemplates 获取可用的tmpls name
|
||||
func GetTemplates(cdb *db.ConfigDB) touka.HandlerFunc {
|
||||
return func(c *touka.Context) {
|
||||
templates, err := cdb.RangeTemplates()
|
||||
if err != nil {
|
||||
c.JSON(500, touka.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(200, templates)
|
||||
}
|
||||
}
|
||||
127
api/auth.go
Normal file
127
api/auth.go
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"caddydash/config"
|
||||
"caddydash/db"
|
||||
"caddydash/user"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/fenthope/sessions"
|
||||
"github.com/infinite-iroha/touka"
|
||||
)
|
||||
|
||||
var (
|
||||
exactMatchPaths = map[string]struct{}{
|
||||
"/login": {},
|
||||
"/login.html": {},
|
||||
"/v0/api/auth/login": {},
|
||||
"/v0/api/auth/init": {},
|
||||
"/init.html": {},
|
||||
"/favicon.ico": {},
|
||||
}
|
||||
prefixMatchPaths = []string{ // 保持前缀匹配,因为数量少
|
||||
"/js/",
|
||||
"/css/",
|
||||
}
|
||||
loginMatchPaths = map[string]struct{}{
|
||||
"/login": {},
|
||||
"/login.html": {},
|
||||
"/v0/api/auth/login": {},
|
||||
}
|
||||
initMatchPaths = map[string]struct{}{
|
||||
"/v0/api/auth/init": {},
|
||||
"/init.html": {},
|
||||
}
|
||||
)
|
||||
|
||||
func isPassPath(requestPath string) bool {
|
||||
// 精确匹配
|
||||
if _, ok := exactMatchPaths[requestPath]; ok {
|
||||
return true
|
||||
}
|
||||
|
||||
// 前缀匹配
|
||||
for _, prefix := range prefixMatchPaths {
|
||||
if strings.HasPrefix(requestPath, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isLoginPath(requestPath string) bool {
|
||||
if _, ok := loginMatchPaths[requestPath]; ok {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isInitPath(requestPath string) bool {
|
||||
if _, ok := initMatchPaths[requestPath]; ok {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func SessionMiddleware(cdb *db.ConfigDB) touka.HandlerFunc {
|
||||
return func(c *touka.Context) {
|
||||
session := sessions.Default(c)
|
||||
requestPath := c.Request.URL.Path
|
||||
pass := isPassPath(requestPath)
|
||||
if !user.IsAdminInit() && !pass || !user.IsAdminInit() && isLoginPath(requestPath) {
|
||||
c.Redirect(http.StatusFound, "/init.html")
|
||||
c.Abort()
|
||||
return
|
||||
} else if user.IsAdminInit() && isInitPath(requestPath) {
|
||||
c.Redirect(http.StatusFound, "/login.html")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
if session.Get("authenticated") != true && !pass {
|
||||
c.Redirect(http.StatusFound, "/login.html")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
func AuthLogin(c *touka.Context, cfg *config.Config, cdb *db.ConfigDB) {
|
||||
username := c.PostForm("username")
|
||||
password := c.PostForm("password")
|
||||
// 输入验证
|
||||
if username == "" || password == "" {
|
||||
c.Errorf("Username or password not provided")
|
||||
c.JSON(http.StatusBadRequest, touka.H{"error": "Need username and password"})
|
||||
return
|
||||
}
|
||||
|
||||
// 验证账户密码
|
||||
pass, err := user.CheckLogin(username, password, cdb)
|
||||
if err != nil {
|
||||
c.Errorf("Failed to check login: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, touka.H{"error": "Internal Auth Check Error"})
|
||||
return
|
||||
}
|
||||
if !pass {
|
||||
c.Errorf("Invalid username or password")
|
||||
c.JSON(http.StatusUnauthorized, touka.H{"error": "Invalid username or password"})
|
||||
return
|
||||
}
|
||||
session := sessions.Default(c)
|
||||
session.Set("authenticated", true)
|
||||
session.Save()
|
||||
c.Infof("Login successful for user: %s", username)
|
||||
c.JSON(http.StatusOK, touka.H{"success": true})
|
||||
}
|
||||
|
||||
func AuthLogout(c *touka.Context) {
|
||||
session := sessions.Default(c)
|
||||
session.Clear()
|
||||
session.Set("authenticated", false)
|
||||
session.Save()
|
||||
c.Redirect(http.StatusFound, "/login.html")
|
||||
}
|
||||
225
api/config.go
Normal file
225
api/config.go
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"caddydash/config"
|
||||
"caddydash/db"
|
||||
"caddydash/gen"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/infinite-iroha/touka"
|
||||
)
|
||||
|
||||
func GetConfig(cdb *db.ConfigDB) touka.HandlerFunc {
|
||||
return func(c *touka.Context) {
|
||||
filename := c.Param("filename")
|
||||
params, err := cdb.GetParams(filename)
|
||||
if err != nil {
|
||||
c.JSON(500, touka.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// 解码[]byte的gob数据
|
||||
var config gen.CaddyUniConfig
|
||||
err = gen.DecodeGobConfig(params.ParamsOrigin, &config)
|
||||
if err != nil {
|
||||
c.JSON(500, touka.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(200, config)
|
||||
}
|
||||
}
|
||||
|
||||
func PutConfig(cdb *db.ConfigDB, cfg *config.Config) touka.HandlerFunc {
|
||||
return func(c *touka.Context) {
|
||||
filename := c.Param("filename")
|
||||
var config gen.CaddyUniConfig
|
||||
err := c.ShouldBindJSON(&config)
|
||||
if err != nil {
|
||||
c.JSON(500, touka.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var paramsGOB []byte
|
||||
var paramsOrigin []byte
|
||||
|
||||
// Mode标识符固定为uni, 模板已被统合为只有uni
|
||||
paramsGOB, err = gen.EncodeGobConfig(config)
|
||||
if err != nil {
|
||||
c.Warnf("encode gob config error: %v", err)
|
||||
c.JSON(500, touka.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// 把json变为gob []byte
|
||||
paramsOrigin, err = gen.EncodeGobConfig(config)
|
||||
if err != nil {
|
||||
c.Warnf("encode origin config error: %v", err)
|
||||
c.JSON(500, touka.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
paramsEntry := db.ParamsEntry{
|
||||
Filename: filename,
|
||||
TemplateType: config.Mode,
|
||||
ParamsGOB: paramsGOB,
|
||||
ParamsOrigin: paramsOrigin,
|
||||
}
|
||||
|
||||
err = WriteConfig(cdb, paramsEntry, cfg, filename)
|
||||
if err != nil {
|
||||
c.Warnf("write config error: %v", err)
|
||||
c.JSON(500, touka.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, touka.H{"message": "config saved and rendered"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 渲染并写入配置
|
||||
func WriteConfig(cdb *db.ConfigDB, paramsEntry db.ParamsEntry, cfg *config.Config, filename string) error {
|
||||
var err error
|
||||
err = cdb.SaveParams(paramsEntry)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("save params error: %w", err)
|
||||
return err
|
||||
}
|
||||
err = gen.RenderConfig(filename, cdb)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("render config error: %w", err)
|
||||
return err
|
||||
}
|
||||
// 写入文件
|
||||
renderedEntry, err := cdb.GetRenderedConfig(filename)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("get rendered config error: %w", err)
|
||||
return err
|
||||
}
|
||||
err = os.WriteFile(cfg.Server.CaddyDir+"config.d/"+filename, renderedEntry.RenderedContent, 0644)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("write rendered config file error: %w", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func DeleteConfig(cdb *db.ConfigDB, cfg *config.Config) touka.HandlerFunc {
|
||||
return func(c *touka.Context) {
|
||||
filename := c.Param("filename")
|
||||
err := cdb.DeleteParams(filename)
|
||||
if err != nil {
|
||||
c.JSON(500, touka.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// 删除文件
|
||||
err = os.Remove(cfg.Server.CaddyDir + "config.d/" + filename)
|
||||
if err != nil {
|
||||
c.Warnf("delete rendered config file error: %v", err)
|
||||
}
|
||||
c.JSON(200, touka.H{"message": "config deleted"})
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
func PutConfig(cdb *db.ConfigDB, cfg *config.Config) touka.HandlerFunc {
|
||||
return func(c *touka.Context) {
|
||||
filename := c.Param("filename")
|
||||
var config gen.CaddyUniConfig
|
||||
err := c.ShouldBindJSON(&config)
|
||||
if err != nil {
|
||||
c.JSON(500, touka.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
var (
|
||||
paramsGOB []byte
|
||||
paramsOrigin []byte
|
||||
)
|
||||
switch config.TmplType {
|
||||
case "file_server":
|
||||
caddyfscfg := gen.CaddyFileServerConfig{
|
||||
Domain: config.Domain,
|
||||
FileDirPath: config.FileServer.FileDirPath,
|
||||
EnableBrowser: config.FileServer.EnableBrowser,
|
||||
Headers: gen.HeadersMapToHeadersUp(config.Headers),
|
||||
EnableLog: config.Log.EnableLog,
|
||||
LogDomain: config.Log.LogDomain,
|
||||
EnableErrorPage: config.ErrorPage.EnableErrorPage,
|
||||
EnableEncode: config.Encode.EnableEncode,
|
||||
}
|
||||
paramsGOB, err = gen.EncodeGobConfig(caddyfscfg)
|
||||
if err != nil {
|
||||
c.Warnf("encode gob config error: %v", err)
|
||||
c.JSON(500, touka.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// 把json变为gob []byte
|
||||
paramsOrigin, err = gen.EncodeGobConfig(config)
|
||||
if err != nil {
|
||||
c.Warnf("encode origin config error: %v", err)
|
||||
c.JSON(500, touka.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
paramsEntry := db.ParamsEntry{
|
||||
Filename: filename,
|
||||
TemplateType: config.TmplType,
|
||||
ParamsGOB: paramsGOB,
|
||||
ParamsOrigin: paramsOrigin,
|
||||
}
|
||||
|
||||
err = WriteConfig(cdb, paramsEntry, cfg, filename)
|
||||
if err != nil {
|
||||
c.Warnf("write config error: %v", err)
|
||||
c.JSON(500, touka.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, touka.H{"message": "config saved and rendered"})
|
||||
return
|
||||
|
||||
case "reverse_proxy":
|
||||
caddyrpCfg := gen.CaddyReverseProxyConfig{
|
||||
Domain: config.Domain,
|
||||
ReverseProxy: config.UpStream.UpStream,
|
||||
Headers: gen.HeadersMapToHeadersUp(config.Headers),
|
||||
HeadersUp: gen.HeadersMapToHeadersUp(config.UpStream.UpStreamHeaders),
|
||||
EnableLog: config.Log.EnableLog,
|
||||
LogDomain: config.Log.LogDomain,
|
||||
EnableErrorPage: config.ErrorPage.EnableErrorPage,
|
||||
EnableEncode: config.Encode.EnableEncode,
|
||||
}
|
||||
paramsGOB, err = gen.EncodeGobConfig(caddyrpCfg)
|
||||
if err != nil {
|
||||
c.Warnf("encode gob config error: %v", err)
|
||||
c.JSON(500, touka.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// 把json变为gob []byte
|
||||
paramsOrigin, err = gen.EncodeGobConfig(config)
|
||||
if err != nil {
|
||||
c.Warnf("encode origin config error: %v", err)
|
||||
c.JSON(500, touka.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
paramsEntry := db.ParamsEntry{
|
||||
Filename: filename,
|
||||
TemplateType: config.TmplType,
|
||||
ParamsGOB: paramsGOB,
|
||||
ParamsOrigin: paramsOrigin,
|
||||
}
|
||||
|
||||
err = WriteConfig(cdb, paramsEntry, cfg, filename)
|
||||
if err != nil {
|
||||
c.Warnf("write config error: %v", err)
|
||||
c.JSON(500, touka.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, touka.H{"message": "config saved and rendered"})
|
||||
return
|
||||
|
||||
default:
|
||||
c.JSON(500, touka.H{"error": "unknown template type"})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
*/
|
||||
40
api/files.go
Normal file
40
api/files.go
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"caddydash/db"
|
||||
|
||||
"github.com/infinite-iroha/touka"
|
||||
)
|
||||
|
||||
func FilesParams(cdb *db.ConfigDB) touka.HandlerFunc {
|
||||
return func(c *touka.Context) {
|
||||
params, err := cdb.RangeAllParams()
|
||||
if err != nil {
|
||||
c.JSON(500, touka.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(200, params)
|
||||
}
|
||||
}
|
||||
|
||||
func FilesTemplates(cdb *db.ConfigDB) touka.HandlerFunc {
|
||||
return func(c *touka.Context) {
|
||||
templates, err := cdb.GetAllTemplates()
|
||||
if err != nil {
|
||||
c.JSON(500, touka.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(200, templates)
|
||||
}
|
||||
}
|
||||
|
||||
func FilesRendered(cdb *db.ConfigDB) touka.HandlerFunc {
|
||||
return func(c *touka.Context) {
|
||||
rendered, err := cdb.RangeAllReandered()
|
||||
if err != nil {
|
||||
c.JSON(500, touka.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(200, rendered)
|
||||
}
|
||||
}
|
||||
170
apic/run.go
Normal file
170
apic/run.go
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
package apic
|
||||
|
||||
import (
|
||||
"caddydash/config"
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/infinite-iroha/touka"
|
||||
)
|
||||
|
||||
type CaddyRunning struct {
|
||||
running bool
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (c *CaddyRunning) IsRunning() bool {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.running
|
||||
}
|
||||
|
||||
func (c *CaddyRunning) SetRunning(running bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.running = running
|
||||
}
|
||||
|
||||
var caddyRunning = &CaddyRunning{}
|
||||
|
||||
func RunCaddy(cfg *config.Config) error {
|
||||
if caddyRunning.IsRunning() {
|
||||
return nil
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
caddyPath := cfg.Server.CaddyDir + "caddy"
|
||||
cmd := exec.CommandContext(ctx, caddyPath, "run", "--config", "Caddyfile")
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
caddyRunning.SetRunning(true)
|
||||
|
||||
err := cmd.Start()
|
||||
if err != nil {
|
||||
cancel()
|
||||
return err
|
||||
}
|
||||
|
||||
go func() {
|
||||
waitErr := cmd.Wait()
|
||||
caddyRunning.SetRunning(false)
|
||||
log.Printf("Caddy process exited with error: %v", waitErr)
|
||||
cancel()
|
||||
if waitErr != nil {
|
||||
// 如果 Caddy 非正常退出(例如崩溃),Wait() 会返回 *exec.ExitError
|
||||
// 对于优雅关闭,如果 Caddy 接收到 SIGTERM 并正常退出,Wait() 返回 nil
|
||||
// 只有当进程以非零状态码退出时,Wait() 才返回非 nil 的 ExitError
|
||||
// 这里的日志输出应由外部日志库处理,而不是标准库log
|
||||
if exitErr, ok := waitErr.(*exec.ExitError); ok {
|
||||
log.Printf("Caddy process exited with non-zero status: %v", exitErr)
|
||||
} else {
|
||||
log.Printf("Caddy process exited with error: %v", waitErr)
|
||||
}
|
||||
} else {
|
||||
log.Println("Caddy process exited gracefully.")
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
func StartCaddy(cfg *config.Config) touka.HandlerFunc {
|
||||
return func(c *touka.Context) {
|
||||
if caddyRunning.IsRunning() {
|
||||
c.JSON(200, map[string]string{"message": "Caddy is already running"})
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
err := RunCaddy(cfg)
|
||||
if err != nil {
|
||||
c.Errorf("Failed to start Caddy: %v", err)
|
||||
c.JSON(500, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}()
|
||||
c.JSON(200, map[string]string{"message": "Caddy is starting"})
|
||||
}
|
||||
}
|
||||
|
||||
func IsCaddyRunning() touka.HandlerFunc {
|
||||
return func(c *touka.Context) {
|
||||
if caddyRunning.IsRunning() {
|
||||
c.JSON(200, map[string]string{"message": "Caddy is running"})
|
||||
} else {
|
||||
c.JSON(200, map[string]string{"message": "Caddy is not running"})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func StopCaddy() touka.HandlerFunc {
|
||||
return func(c *touka.Context) {
|
||||
if !caddyRunning.IsRunning() {
|
||||
c.JSON(200, map[string]string{"message": "Caddy is not running"})
|
||||
return
|
||||
}
|
||||
client := c.GetHTTPC()
|
||||
rb := client.NewRequestBuilder("POST", "http://127.0.0.1:2019/stop")
|
||||
resp, err := rb.Execute()
|
||||
if err != nil {
|
||||
c.JSON(500, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
c.JSON(resp.StatusCode, map[string]string{"error": "Failed to stop Caddy"})
|
||||
return
|
||||
}
|
||||
|
||||
caddyRunning.SetRunning(false)
|
||||
c.JSON(200, map[string]string{"message": "Caddy stopped successfully"})
|
||||
}
|
||||
}
|
||||
|
||||
func RestartCaddy(cfg *config.Config) touka.HandlerFunc {
|
||||
return func(c *touka.Context) {
|
||||
if !caddyRunning.IsRunning() {
|
||||
c.JSON(200, map[string]string{"message": "Caddy is not running, starting it now"})
|
||||
go func() {
|
||||
err := RunCaddy(cfg)
|
||||
if err != nil {
|
||||
c.Errorf("Failed to start Caddy: %v", err)
|
||||
|
||||
}
|
||||
}()
|
||||
return
|
||||
}
|
||||
|
||||
// StopCaddy
|
||||
client := c.GetHTTPC()
|
||||
rb := client.NewRequestBuilder("POST", "http://127.0.0.1:2019/stop")
|
||||
resp, err := rb.Execute()
|
||||
if err != nil {
|
||||
c.JSON(500, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
c.JSON(resp.StatusCode, map[string]string{"error": "Failed to stop Caddy for restart"})
|
||||
return
|
||||
}
|
||||
|
||||
// 等待caddy关闭
|
||||
for caddyRunning.IsRunning() {
|
||||
time.Sleep(500 * time.Millisecond) // 等待500ms
|
||||
}
|
||||
|
||||
// StartCaddy
|
||||
go func() {
|
||||
err := RunCaddy(cfg)
|
||||
if err != nil {
|
||||
c.Errorf("Failed to restart Caddy: %v", err)
|
||||
}
|
||||
}()
|
||||
c.JSON(200, map[string]string{"message": "Caddy is restarting"})
|
||||
|
||||
}
|
||||
}
|
||||
11
config.toml
Normal file
11
config.toml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
[Server]
|
||||
port = 81
|
||||
host = "0.0.0.0"
|
||||
debug = false
|
||||
caddyDir = "./"
|
||||
|
||||
[Tmpl]
|
||||
path = "./tmpl"
|
||||
|
||||
[database]
|
||||
filepath = "caddydash.db"
|
||||
110
config/config.go
Normal file
110
config/config.go
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Server ServerConfig
|
||||
Tmpl TmplConfig
|
||||
DB DatabaseConfig `toml:"database"`
|
||||
}
|
||||
|
||||
/*
|
||||
[server]
|
||||
host = "0.0.0.0"
|
||||
port = 8080
|
||||
debug = false
|
||||
caddyDir = "./"
|
||||
*/
|
||||
type ServerConfig struct {
|
||||
Port int `toml:"port"`
|
||||
Host string `toml:"host"`
|
||||
Debug bool `toml:"debug"`
|
||||
CaddyDir string `toml:"caddyDir"`
|
||||
}
|
||||
|
||||
/*
|
||||
[tmpl]
|
||||
path = "./tmpl"
|
||||
*/
|
||||
type TmplConfig struct {
|
||||
Path string `toml:"path"`
|
||||
}
|
||||
|
||||
/*
|
||||
[database]
|
||||
filepath = "sqlite.db"
|
||||
*/
|
||||
type DatabaseConfig struct {
|
||||
Filepath string `toml:"filepath"`
|
||||
}
|
||||
|
||||
// LoadConfig 从 TOML 配置文件加载配置
|
||||
func LoadConfig(filePath string) (*Config, error) {
|
||||
if !FileExists(filePath) {
|
||||
// 楔入配置文件
|
||||
err := DefaultConfig().WriteConfig(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return DefaultConfig(), nil
|
||||
}
|
||||
|
||||
var config Config
|
||||
if _, err := toml.DecodeFile(filePath, &config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
// 写入配置文件
|
||||
func (c *Config) WriteConfig(filePath string) error {
|
||||
file, err := os.Create(filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
encoder := toml.NewEncoder(file)
|
||||
return encoder.Encode(c)
|
||||
}
|
||||
|
||||
// 检测文件是否存在
|
||||
func FileExists(filename string) bool {
|
||||
_, err := os.Stat(filename)
|
||||
return !os.IsNotExist(err)
|
||||
}
|
||||
|
||||
// 默认配置结构体
|
||||
func DefaultConfig() *Config {
|
||||
/*
|
||||
[server]
|
||||
host = "0.0.0.0"
|
||||
port = 81
|
||||
debug = false
|
||||
caddyDir = "./"
|
||||
|
||||
[tmpl]
|
||||
path = "./tmpl"
|
||||
|
||||
[database]
|
||||
filepath = "caddydash.db"
|
||||
*/
|
||||
return &Config{
|
||||
Server: ServerConfig{
|
||||
Host: "0.0.0.0",
|
||||
Port: 81,
|
||||
Debug: false,
|
||||
CaddyDir: "./",
|
||||
},
|
||||
Tmpl: TmplConfig{
|
||||
Path: "./tmpl",
|
||||
},
|
||||
DB: DatabaseConfig{
|
||||
Filepath: "caddydash.db",
|
||||
},
|
||||
}
|
||||
}
|
||||
11
config/config.toml
Normal file
11
config/config.toml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
[server]
|
||||
host = "0.0.0.0"
|
||||
port = 8080
|
||||
debug = false
|
||||
caddyDir = "./"
|
||||
|
||||
[tmpl]
|
||||
path = "/data/github/WJQSERVER/caddydash/tmpl"
|
||||
|
||||
[database]
|
||||
filepath = "test.db"
|
||||
157
db/db.go
Normal file
157
db/db.go
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
//_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
// TemplateEntry 对应 templates 表
|
||||
type TemplateEntry struct {
|
||||
Filename string `json:"filename"`
|
||||
TemplateType string `json:"template_type"`
|
||||
Content []byte `json:"content"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
// ParamsEntry 对应 config_params 表
|
||||
type ParamsEntry struct {
|
||||
Filename string `json:"filename"`
|
||||
TemplateType string `json:"template_type"`
|
||||
ParamsGOB []byte `json:"params_gob"`
|
||||
ParamsOrigin []byte `json:"params_origin"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
// RenderedConfigEntry 对应 rendered_configs 表
|
||||
type RenderedConfigEntry struct {
|
||||
Filename string `json:"filename"`
|
||||
RenderedContent []byte `json:"rendered_content"`
|
||||
RenderedAt int64 `json:"rendered_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
// UsersTable
|
||||
type UsersTable struct {
|
||||
UserName string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
// ConfigDB
|
||||
type ConfigDB struct {
|
||||
DB *sql.DB
|
||||
}
|
||||
|
||||
func InitDB(filepath string) (*ConfigDB, error) {
|
||||
db, err := loadDB(filepath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cdb := &ConfigDB{DB: db}
|
||||
// 尝试 Ping 数据库以验证连接是否成功
|
||||
if err = db.Ping(); err != nil {
|
||||
db.Close() // 如果连接失败,确保关闭数据库句柄
|
||||
return nil, fmt.Errorf("db: failed to connect to database: %w", err)
|
||||
}
|
||||
err = cdb.createTables()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cdb, nil
|
||||
}
|
||||
|
||||
func loadDB(filepath string) (*sql.DB, error) {
|
||||
db, err := sql.Open("sqlite", fmt.Sprintf("file:%s?cache=shared&_journal=WAL", filepath))
|
||||
if err != nil {
|
||||
log.Fatalf("Can not connect to database: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func (cdb *ConfigDB) createTables() error {
|
||||
tx, err := cdb.DB.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to begin transaction for table creation: %w", err)
|
||||
}
|
||||
defer tx.Rollback() // 确保在出错时回滚事务
|
||||
|
||||
// 创建 templates 表
|
||||
_, err = tx.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS templates (
|
||||
filename TEXT PRIMARY KEY,
|
||||
template_type TEXT NOT NULL,
|
||||
content BLOB NOT NULL,
|
||||
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
|
||||
updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
|
||||
);`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create 'templates' table: %w", err)
|
||||
}
|
||||
|
||||
// 2. 创建 config_params 表
|
||||
_, err = tx.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS config_params (
|
||||
filename TEXT PRIMARY KEY,
|
||||
template_type TEXT NOT NULL,
|
||||
params_gob BLOB NOT NULL,
|
||||
params_origin BLOB NOT NULL,
|
||||
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
|
||||
updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
|
||||
-- FOREIGN KEY(filename) REFERENCES templates(filename) ON DELETE CASCADE -- 已移除
|
||||
);`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create 'config_params' table: %w", err)
|
||||
}
|
||||
|
||||
// 3. 创建 rendered_configs 表 (外键指向 config_params 表)
|
||||
_, err = tx.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS rendered_configs (
|
||||
filename TEXT PRIMARY KEY,
|
||||
rendered_content BLOB NOT NULL,
|
||||
rendered_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
|
||||
updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
|
||||
FOREIGN KEY(filename) REFERENCES config_params(filename) ON DELETE CASCADE -- 修改外键引用
|
||||
);`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create 'rendered_configs' table: %w", err)
|
||||
}
|
||||
|
||||
//
|
||||
// 4. 创建 users 表
|
||||
_, err = tx.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
username TEXT PRIMARY KEY,
|
||||
password TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
|
||||
updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
|
||||
);`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create 'users' table: %w", err)
|
||||
}
|
||||
|
||||
// 提交事务
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("failed to commit transaction for table creation: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cdb *ConfigDB) CloseDB() error {
|
||||
if cdb.DB == nil {
|
||||
return nil // 数据库未打开或已关闭
|
||||
}
|
||||
err := cdb.DB.Close()
|
||||
if err != nil {
|
||||
return fmt.Errorf("db: failed to close database: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
504
db/operation.go
Normal file
504
db/operation.go
Normal file
|
|
@ -0,0 +1,504 @@
|
|||
package db
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"encoding/gob"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"text/template"
|
||||
)
|
||||
|
||||
// 用户校验操作
|
||||
/*
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
username TEXT PRIMARY KEY,
|
||||
password TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
|
||||
updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
|
||||
);`)
|
||||
*/
|
||||
|
||||
// AddUser 向 'users' 表中添加一个新用户.
|
||||
func (cdb *ConfigDB) AddUser(username, password string) error {
|
||||
insertSQL := `
|
||||
INSERT INTO users (username, password)
|
||||
VALUES (?, ?);
|
||||
`
|
||||
_, err := cdb.DB.Exec(insertSQL, username, password)
|
||||
if err != nil {
|
||||
return fmt.Errorf("db: failed to add user '%s': %w", username, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetUserByUsername 从 'users' 表中根据用户名获取用户信息.
|
||||
func (cdb *ConfigDB) GetUserByUsername(username string) (*UsersTable, error) {
|
||||
querySQL := `SELECT username, password, created_at, updated_at FROM users WHERE username = ?;`
|
||||
row := cdb.DB.QueryRow(querySQL, username)
|
||||
|
||||
user := &UsersTable{}
|
||||
err := row.Scan(&user.UserName, &user.Password, &user.CreatedAt, &user.UpdatedAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, fmt.Errorf("db: user '%s' not found: %w", username, err)
|
||||
}
|
||||
return nil, fmt.Errorf("db: failed to get user '%s': %w", username, err)
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// DeleteUser 从 'users' 表中删除一个用户.
|
||||
func (cdb *ConfigDB) DeleteUser(username string) error {
|
||||
_, err := cdb.DB.Exec(`DELETE FROM users WHERE username = ?;`, username)
|
||||
if err != nil {
|
||||
return fmt.Errorf("db: failed to delete user '%s': %w", username, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateUserPassword 更新用户的密码.
|
||||
func (cdb *ConfigDB) UpdateUserPassword(username, newPassword string) error {
|
||||
updateSQL := `
|
||||
UPDATE users
|
||||
SET password = ?, updated_at = strftime('%s', 'now')
|
||||
WHERE username = ?;
|
||||
`
|
||||
result, err := cdb.DB.Exec(updateSQL, newPassword, username)
|
||||
if err != nil {
|
||||
return fmt.Errorf("db: failed to update user '%s' password: %w", username, err)
|
||||
}
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("db: failed to get rows affected by update: %w", err)
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
return fmt.Errorf("db: no user with username '%s' found to update", username)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RangeUserNames 获取所有用户的用户名.
|
||||
func (cdb *ConfigDB) RangeUserNames() ([]string, error) {
|
||||
querySQL := `SELECT username FROM users;`
|
||||
rows, err := cdb.DB.Query(querySQL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db: failed to get usernames from users: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var usernames []string
|
||||
for rows.Next() {
|
||||
var username string
|
||||
if err := rows.Scan(&username); err != nil {
|
||||
return nil, fmt.Errorf("db: failed to scan username: %w", err)
|
||||
}
|
||||
usernames = append(usernames, username)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("db: error during user rows iteration: %w", err)
|
||||
}
|
||||
|
||||
return usernames, nil
|
||||
}
|
||||
|
||||
// HasAnyUser 检查 'users' 表中是否存在任何用户.
|
||||
func (cdb *ConfigDB) HasAnyUser() (bool, error) {
|
||||
querySQL := `SELECT EXISTS(SELECT 1 FROM users LIMIT 1);`
|
||||
var exists bool
|
||||
err := cdb.DB.QueryRow(querySQL).Scan(&exists)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("db: failed to check if any user exists: %w", err)
|
||||
}
|
||||
return exists, nil
|
||||
}
|
||||
|
||||
// IsUserExists 检查指定用户名的用户是否存在.
|
||||
func (cdb *ConfigDB) IsUserExists(username string) (bool, error) {
|
||||
querySQL := `SELECT EXISTS(SELECT 1 FROM users WHERE username = ? LIMIT 1);`
|
||||
var exists bool
|
||||
err := cdb.DB.QueryRow(querySQL, username).Scan(&exists)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("db: failed to check if user '%s' exists: %w", username, err)
|
||||
}
|
||||
return exists, nil
|
||||
}
|
||||
|
||||
// GetPasswordByUsername 从 'users' 表中根据用户名获取密码.
|
||||
func (cdb *ConfigDB) GetPasswordByUsername(username string) (string, error) {
|
||||
querySQL := `SELECT password FROM users WHERE username = ?;`
|
||||
var password string
|
||||
err := cdb.DB.QueryRow(querySQL, username).Scan(&password)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", fmt.Errorf("db: user '%s' not found: %w", username, err)
|
||||
}
|
||||
return "", fmt.Errorf("db: failed to get password for user '%s': %w", username, err)
|
||||
}
|
||||
return password, nil
|
||||
}
|
||||
|
||||
// --- 模板操作 (Templates Table) ---
|
||||
|
||||
// SaveTemplate 在 'templates' 表中保存或更新一个模板.
|
||||
func (cdb *ConfigDB) SaveTemplate(entry TemplateEntry) error {
|
||||
insertSQL := `
|
||||
INSERT INTO templates (filename, template_type, content)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(filename) DO UPDATE SET
|
||||
template_type = EXCLUDED.template_type,
|
||||
content = EXCLUDED.content,
|
||||
updated_at = strftime('%s', 'now');
|
||||
`
|
||||
_, err := cdb.DB.Exec(insertSQL, entry.Filename, entry.TemplateType, entry.Content)
|
||||
if err != nil {
|
||||
return fmt.Errorf("db: failed to save template '%s': %w", entry.Filename, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetTemplate 从 'templates' 表中获取一个模板内容.
|
||||
func (cdb *ConfigDB) GetTemplate(filename string) (*TemplateEntry, error) {
|
||||
querySQL := `SELECT filename, template_type, content, created_at, updated_at FROM templates WHERE filename = ?;`
|
||||
row := cdb.DB.QueryRow(querySQL, filename)
|
||||
|
||||
entry := &TemplateEntry{}
|
||||
err := row.Scan(&entry.Filename, &entry.TemplateType, &entry.Content, &entry.CreatedAt, &entry.UpdatedAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, fmt.Errorf("db: template '%s' not found: %w", filename, err)
|
||||
}
|
||||
return nil, fmt.Errorf("db: failed to get template '%s': %w", filename, err)
|
||||
}
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
// DeleteTemplate 从 'templates' 表中删除一个模板.
|
||||
// 请注意: 此操作不会级联删除 'config_params' 或 'rendered_configs' 中的关联数据;
|
||||
// 因为 'templates' 表不再是它们的外键父表.
|
||||
func (cdb *ConfigDB) DeleteTemplate(filename string) error {
|
||||
_, err := cdb.DB.Exec(`DELETE FROM templates WHERE filename = ?;`, filename)
|
||||
if err != nil {
|
||||
return fmt.Errorf("db: failed to delete template '%s': %w", filename, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RangeTempaltes 获取所有模板的名称
|
||||
func (cdb *ConfigDB) RangeTemplates() ([]string, error) {
|
||||
querySQL := `SELECT filename FROM templates;`
|
||||
rows, err := cdb.DB.Query(querySQL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db: failed to get filenames from templates: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var filenames []string
|
||||
for rows.Next() {
|
||||
var filename string
|
||||
if err := rows.Scan(&filename); err != nil {
|
||||
return nil, fmt.Errorf("db: failed to scan template filename: %w", err)
|
||||
}
|
||||
filenames = append(filenames, filename)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("db: error during template rows iteration: %w", err)
|
||||
}
|
||||
|
||||
return filenames, nil
|
||||
}
|
||||
|
||||
// GetAllTempaltes
|
||||
func (cdb *ConfigDB) GetAllTemplates() ([]TemplateEntry, error) {
|
||||
querySQL := `SELECT filename, template_type, content, created_at, updated_at FROM templates;`
|
||||
rows, err := cdb.DB.Query(querySQL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db: failed to get all templates: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var templates []TemplateEntry
|
||||
for rows.Next() {
|
||||
var entry TemplateEntry
|
||||
if err := rows.Scan(&entry.Filename, &entry.TemplateType, &entry.Content, &entry.CreatedAt, &entry.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("db: failed to scan template entry: %w", err)
|
||||
}
|
||||
templates = append(templates, entry)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("db: error during templates rows iteration: %w", err)
|
||||
}
|
||||
|
||||
return templates, nil
|
||||
}
|
||||
|
||||
// --- 参数操作 (Config_Params Table) ---
|
||||
|
||||
/*
|
||||
filename TEXT PRIMARY KEY,
|
||||
template_type TEXT NOT NULL,
|
||||
params_gob BLOB NOT NULL,
|
||||
params_origin BLOB NOT NULL,
|
||||
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
|
||||
updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
|
||||
*/
|
||||
|
||||
// SaveParams 在 'config_params' 表中保存或更新一个模板的参数.
|
||||
// entry.ParamsGOB 应该是一个经过 GOB 编码的字节切片.
|
||||
func (cdb *ConfigDB) SaveParams(entry ParamsEntry) error {
|
||||
insertSQL := `
|
||||
INSERT INTO config_params (filename, template_type, params_gob, params_origin)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(filename) DO UPDATE SET
|
||||
template_type = EXCLUDED.template_type,
|
||||
params_gob = EXCLUDED.params_gob,
|
||||
params_origin = EXCLUDED.params_origin,
|
||||
updated_at = strftime('%s', 'now');
|
||||
`
|
||||
_, err := cdb.DB.Exec(insertSQL, entry.Filename, entry.TemplateType, entry.ParamsGOB, entry.ParamsOrigin)
|
||||
if err != nil {
|
||||
return fmt.Errorf("db: failed to save params for '%s': %w", entry.Filename, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetParams 从 'config_params' 表中获取一个模板的参数.
|
||||
// 返回的 ParamsGOB 是 GOB 编码的字节切片; 调用方需要自行解码.
|
||||
func (cdb *ConfigDB) GetParams(filename string) (*ParamsEntry, error) {
|
||||
querySQL := `SELECT filename, template_type, params_gob, params_origin, created_at, updated_at FROM config_params WHERE filename = ?;`
|
||||
row := cdb.DB.QueryRow(querySQL, filename)
|
||||
|
||||
entry := &ParamsEntry{}
|
||||
err := row.Scan(&entry.Filename, &entry.TemplateType, &entry.ParamsGOB, &entry.ParamsOrigin, &entry.CreatedAt, &entry.UpdatedAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, fmt.Errorf("db: params for '%s' not found: %w", filename, err)
|
||||
}
|
||||
return nil, fmt.Errorf("db: failed to get params for '%s': %w", filename, err)
|
||||
}
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
// DeleteParams 从 'config_params' 表中删除一个模板的参数.
|
||||
// 此操作将级联删除 'rendered_configs' 表中与该 filename 关联的所有渲染产物.
|
||||
func (cdb *ConfigDB) DeleteParams(filename string) error {
|
||||
_, err := cdb.DB.Exec(`DELETE FROM config_params WHERE filename = ?;`, filename)
|
||||
if err != nil {
|
||||
return fmt.Errorf("db: failed to delete params for '%s': %w", filename, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetFileNames
|
||||
func (cdb *ConfigDB) GetFileNames() ([]string, error) {
|
||||
querySQL := `SELECT filename FROM config_params;`
|
||||
rows, err := cdb.DB.Query(querySQL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db: failed to get filenames from config_params: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var filenames []string
|
||||
for rows.Next() {
|
||||
var filename string
|
||||
if err := rows.Scan(&filename); err != nil {
|
||||
return nil, fmt.Errorf("db: failed to scan filename: %w", err)
|
||||
}
|
||||
filenames = append(filenames, filename)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("db: error during rows iteration: %w", err)
|
||||
}
|
||||
|
||||
return filenames, nil
|
||||
}
|
||||
|
||||
// RangeAllParams
|
||||
func (cdb *ConfigDB) RangeAllParams() ([]ParamsEntry, error) {
|
||||
querySQL := `SELECT filename, template_type, params_gob, params_origin, created_at, updated_at FROM config_params;`
|
||||
rows, err := cdb.DB.Query(querySQL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db: failed to get all params: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var params []ParamsEntry
|
||||
for rows.Next() {
|
||||
var entry ParamsEntry
|
||||
if err := rows.Scan(&entry.Filename, &entry.TemplateType, &entry.ParamsGOB, &entry.CreatedAt, &entry.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("db: failed to scan params entry: %w", err)
|
||||
}
|
||||
params = append(params, entry)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("db: error during params rows iteration: %w", err)
|
||||
}
|
||||
|
||||
return params, nil
|
||||
}
|
||||
|
||||
// --- 渲染产物操作 (Rendered_Configs Table) ---
|
||||
|
||||
// SaveRenderedConfig 在 'rendered_configs' 表中保存或更新一个渲染后的配置文件内容.
|
||||
// 注意: 该操作依赖于 'config_params' 表中已存在对应的 filename; 否则会违反外键约束.
|
||||
func (cdb *ConfigDB) SaveRenderedConfig(entry RenderedConfigEntry) error {
|
||||
insertSQL := `
|
||||
INSERT INTO rendered_configs (filename, rendered_content, rendered_at)
|
||||
VALUES (?, ?, strftime('%s', 'now'))
|
||||
ON CONFLICT(filename) DO UPDATE SET
|
||||
rendered_content = EXCLUDED.rendered_content,
|
||||
rendered_at = strftime('%s', 'now'),
|
||||
updated_at = strftime('%s', 'now');
|
||||
`
|
||||
_, err := cdb.DB.Exec(insertSQL, entry.Filename, entry.RenderedContent)
|
||||
if err != nil {
|
||||
return fmt.Errorf("db: failed to save rendered config for '%s': %w", entry.Filename, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetRenderedConfig 从 'rendered_configs' 表中获取一个渲染后的配置文件内容.
|
||||
func (cdb *ConfigDB) GetRenderedConfig(filename string) (*RenderedConfigEntry, error) {
|
||||
querySQL := `SELECT filename, rendered_content, rendered_at, updated_at FROM rendered_configs WHERE filename = ?;`
|
||||
row := cdb.DB.QueryRow(querySQL, filename)
|
||||
|
||||
entry := &RenderedConfigEntry{}
|
||||
err := row.Scan(&entry.Filename, &entry.RenderedContent, &entry.RenderedAt, &entry.UpdatedAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, fmt.Errorf("db: rendered config '%s' not found: %w", filename, err)
|
||||
}
|
||||
return nil, fmt.Errorf("db: failed to get rendered config '%s': %w", filename, err)
|
||||
}
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
// DeleteRenderedConfig 从 'rendered_configs' 表中删除一个渲染后的配置文件内容.
|
||||
func (cdb *ConfigDB) DeleteRenderedConfig(filename string) error {
|
||||
_, err := cdb.DB.Exec(`DELETE FROM rendered_configs WHERE filename = ?;`, filename)
|
||||
if err != nil {
|
||||
return fmt.Errorf("db: failed to delete rendered config for '%s': %w", filename, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RangeAllReandered
|
||||
func (cdb *ConfigDB) RangeAllReandered() ([]RenderedConfigEntry, error) {
|
||||
querySQL := `SELECT filename, rendered_content, rendered_at, updated_at FROM rendered_configs;`
|
||||
rows, err := cdb.DB.Query(querySQL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db: failed to get all rendered configs: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var renderedConfigs []RenderedConfigEntry
|
||||
for rows.Next() {
|
||||
var entry RenderedConfigEntry
|
||||
if err := rows.Scan(&entry.Filename, &entry.RenderedContent, &entry.RenderedAt, &entry.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("db: failed to scan rendered config entry: %w", err)
|
||||
}
|
||||
renderedConfigs = append(renderedConfigs, entry)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("db: error during rendered configs rows iteration: %w", err)
|
||||
}
|
||||
|
||||
return renderedConfigs, nil
|
||||
}
|
||||
|
||||
// --- GOB 编码/解码辅助函数 ---
|
||||
|
||||
// gobEncode 将 Go 值编码为 GOB 格式的字节切片.
|
||||
// 'data' 必须是可被 GOB 编码的值; 例如基本类型, 切片, 映射, 结构体.
|
||||
func gobEncode(data interface{}) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
enc := gob.NewEncoder(&buf)
|
||||
if err := enc.Encode(data); err != nil {
|
||||
return nil, fmt.Errorf("db: failed to GOB encode data: %w", err)
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// gobDecode 将 GOB 格式的字节切片解码到 Go 值.
|
||||
// 'data' 是 GOB 编码的字节切片.
|
||||
// 'valuePtr' 必须是指向目标 Go 值的指针; 其类型必须与编码时的数据类型兼容.
|
||||
func gobDecode(data []byte, valuePtr interface{}) error {
|
||||
buf := bytes.NewBuffer(data)
|
||||
dec := gob.NewDecoder(buf)
|
||||
if err := dec.Decode(valuePtr); err != nil {
|
||||
return fmt.Errorf("db: failed to GOB decode data: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- 业务逻辑: 渲染并保存 (由使用者调用) ---
|
||||
// RenderAndSaveConfig 从数据库获取模板和参数; 渲染后保存到 'rendered_configs' 表.
|
||||
// 这是一个组合操作; 通常由应用程序逻辑在需要时调用.
|
||||
// filename: 要渲染的配置文件的唯一标识.
|
||||
// templateParser: 一个实现了 TemplateParser 接口的模板解析器实例.
|
||||
// dynamicParams: 运行时动态提供的参数; 它们会覆盖存储在数据库中的同名参数.
|
||||
func (cdb *ConfigDB) RenderAndSaveConfig(filename string, dynamicParams map[string]interface{}) error {
|
||||
// 1. 获取模板内容.
|
||||
tmplEntry, err := cdb.GetTemplate(filename)
|
||||
if err != nil {
|
||||
return fmt.Errorf("db: failed to get template '%s' for rendering: %w", filename, err)
|
||||
}
|
||||
|
||||
// 2. 获取存储的参数.
|
||||
paramsEntry, err := cdb.GetParams(filename)
|
||||
var storedParams map[string]interface{}
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
storedParams = make(map[string]interface{}) // 参数不存在; 使用空 map.
|
||||
} else {
|
||||
return fmt.Errorf("db: failed to get parameters for '%s': %w", filename, err)
|
||||
}
|
||||
} else {
|
||||
// 解码 GOB 参数到 map.
|
||||
if err := gobDecode(paramsEntry.ParamsGOB, &storedParams); err != nil {
|
||||
return fmt.Errorf("db: failed to decode stored parameters for '%s': %w", filename, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 合并参数: 动态传入的参数覆盖存储的参数.
|
||||
if dynamicParams != nil {
|
||||
for k, v := range dynamicParams {
|
||||
storedParams[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 渲染模板.
|
||||
var parsedTmpl *template.Template
|
||||
var parseErr error
|
||||
|
||||
// 使用传入的 templateParser 实例来解析模板内容.
|
||||
// 注意: templateParser.Parse 是在提供的实例上调用; 以解析特定内容.
|
||||
//parsedTmpl, parseErr = templateParser.Parse(string(tmplEntry.Content))
|
||||
parsedTmpl, parseErr = template.New(tmplEntry.Filename).Parse(string(tmplEntry.Content))
|
||||
|
||||
if parseErr != nil {
|
||||
return fmt.Errorf("db: failed to parse template content for '%s': %w", tmplEntry.Filename, parseErr)
|
||||
}
|
||||
|
||||
var renderedContentBuilder strings.Builder
|
||||
if err := parsedTmpl.Execute(&renderedContentBuilder, storedParams); err != nil {
|
||||
return fmt.Errorf("db: failed to render template '%s': %w", tmplEntry.Filename, err)
|
||||
}
|
||||
|
||||
// 4. 保存渲染结果.
|
||||
renderedEntry := RenderedConfigEntry{
|
||||
Filename: filename,
|
||||
RenderedContent: []byte(renderedContentBuilder.String()),
|
||||
}
|
||||
if err := cdb.SaveRenderedConfig(renderedEntry); err != nil {
|
||||
return fmt.Errorf("db: failed to save rendered config for '%s': %w", filename, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
40
db/struct.go
Normal file
40
db/struct.go
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
package db
|
||||
|
||||
/*
|
||||
// 通用配置结构
|
||||
type CaddyfileConfig struct {
|
||||
Domain string `json:"domain"`
|
||||
TmplType string `json:"tmpl_type"`
|
||||
UpStream UpStreamConfig `json:"upstream"`
|
||||
FileServer FileServerConfig `json:"file_server"`
|
||||
Headers map[string][]string `json:"headers"`
|
||||
Log LogConfig `json:"log"`
|
||||
ErrorPage ErrorPageConfig `json:"error_page"`
|
||||
Encode EncodeConfig `json:"encode"`
|
||||
}
|
||||
|
||||
type UpStreamConfig struct {
|
||||
UpStream string `json:"upstream"`
|
||||
MutiUpStream bool `json:"muti_upstream"`
|
||||
UpStreams []string `json:"upstream_servers"`
|
||||
UpStreamHeaders map[string][]string `json:"upstream_headers"`
|
||||
}
|
||||
|
||||
type FileServerConfig struct {
|
||||
FileDirPath string `json:"file_dir_path"`
|
||||
EnableBrowser bool `json:"enable_browser"`
|
||||
}
|
||||
|
||||
type LogConfig struct {
|
||||
EnableLog bool `json:"enable_log"`
|
||||
LogDomain string `json:"log_domain"`
|
||||
}
|
||||
|
||||
type ErrorPageConfig struct {
|
||||
EnableErrorPage bool `json:"enable_error_page"`
|
||||
}
|
||||
|
||||
type EncodeConfig struct {
|
||||
EnableEncode bool `json:"enable_encode"`
|
||||
}
|
||||
*/
|
||||
0
deploy/install.sh
Normal file
0
deploy/install.sh
Normal file
411
frontend/css/style.css
Normal file
411
frontend/css/style.css
Normal file
|
|
@ -0,0 +1,411 @@
|
|||
/* --- 全局与变量定义 --- */
|
||||
:root {
|
||||
--bg-color: #f8f9fa;
|
||||
--surface-color: #ffffff;
|
||||
--primary-color: #4f46e5;
|
||||
--primary-color-hover: #4338ca;
|
||||
--danger-color: #e11d48;
|
||||
--danger-color-hover: #be123c;
|
||||
--success-color: #22c55e;
|
||||
--success-color-hover: #16a34a;
|
||||
--warning-color: #f59e0b;
|
||||
--warning-color-hover: #d97706;
|
||||
--checking-color: #f59e0b;
|
||||
--text-color: #1f2937;
|
||||
--text-color-secondary: #6b7280;
|
||||
--border-color: #e5e7eb;
|
||||
--border-radius-large: 12px;
|
||||
--border-radius-small: 8px;
|
||||
--font-family: 'Inter', sans-serif;
|
||||
--sidebar-width: 260px;
|
||||
--transition-speed: 0.3s;
|
||||
}
|
||||
[data-theme="dark"] {
|
||||
--bg-color: #111827;
|
||||
--surface-color: #1f2937;
|
||||
--text-color: #f9fafb;
|
||||
--text-color-secondary: #9ca3af;
|
||||
--border-color: #374151;
|
||||
}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: var(--font-family);
|
||||
background-color: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
line-height: 1.6;
|
||||
overflow: hidden;
|
||||
transition: background-color var(--transition-speed), color var(--transition-speed);
|
||||
}
|
||||
.hidden { display: none !important; }
|
||||
|
||||
/* --- 登录页 --- */
|
||||
.login-page-body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
}
|
||||
.login-container {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
padding: 40px;
|
||||
background-color: var(--surface-color);
|
||||
border-radius: var(--border-radius-large);
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: 0 10px 25px rgba(0,0,0,0.05);
|
||||
text-align: center;
|
||||
}
|
||||
.login-header { margin-bottom: 32px; }
|
||||
.login-header .fa-rocket {
|
||||
font-size: 2.5rem;
|
||||
color: var(--primary-color);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.login-header h1 {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.login-header p { color: var(--text-color-secondary); }
|
||||
#login-form .form-group {
|
||||
text-align: left;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
#init-form .form-group {
|
||||
text-align: left;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.btn-login {
|
||||
margin-top: 16px;
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
padding-left: 24px;
|
||||
padding-right: 24px;
|
||||
}
|
||||
.error-text {
|
||||
color: var(--danger-color);
|
||||
font-size: 0.875rem;
|
||||
text-align: left;
|
||||
margin-top: -12px;
|
||||
margin-bottom: 16px;
|
||||
min-height: 1.2em;
|
||||
}
|
||||
|
||||
/* --- Toast 通知 --- */
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 2000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.toast {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
background-color: var(--surface-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-small);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
width: 320px;
|
||||
opacity: 0;
|
||||
transform: translateX(100%);
|
||||
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||
}
|
||||
.toast.show { opacity: 1; transform: translateX(0); }
|
||||
.toast-icon { font-size: 1.25rem; }
|
||||
.toast-message { flex-grow: 1; font-size: 0.9rem; font-weight: 500; }
|
||||
.toast-close {
|
||||
background: none; border: none; color: var(--text-color-secondary);
|
||||
cursor: pointer; font-size: 1.1rem; padding: 4px;
|
||||
}
|
||||
.toast.success .toast-icon { color: var(--success-color); }
|
||||
.toast.error .toast-icon { color: var(--danger-color); }
|
||||
.toast.info .toast-icon { color: var(--primary-color); }
|
||||
|
||||
/* --- 交互式对话框/Modal --- */
|
||||
#dialog-container {
|
||||
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
|
||||
z-index: 1000; display: flex; align-items: center; justify-content: center;
|
||||
background-color: rgba(0,0,0,0.3);
|
||||
opacity: 0; visibility: hidden;
|
||||
transition: opacity 0.2s ease, visibility 0.2s;
|
||||
}
|
||||
#dialog-container.active {
|
||||
opacity: 1; visibility: visible;
|
||||
}
|
||||
.dialog-box {
|
||||
background-color: var(--surface-color); border-radius: var(--border-radius-large);
|
||||
padding: 24px; width: 90%; max-width: 400px;
|
||||
box-shadow: 0 10px 25px rgba(0,0,0,0.1); text-align: center;
|
||||
transform: scale(0.95);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
#dialog-container.active .dialog-box {
|
||||
transform: scale(1);
|
||||
}
|
||||
.dialog-message { margin: 16px 0 24px; font-size: 1rem; color: var(--text-color-secondary); }
|
||||
.dialog-actions { display: flex; justify-content: center; gap: 12px; }
|
||||
.dialog-actions .btn { width: auto; }
|
||||
|
||||
/* --- 主应用布局 --- */
|
||||
.app-container { display: flex; height: 100vh; }
|
||||
.main-content { flex-grow: 1; padding: 24px 32px; overflow-y: auto; }
|
||||
#view-container { position: relative; }
|
||||
.view { animation: fadeIn 0.5s ease; }
|
||||
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
|
||||
.sidebar {
|
||||
width: var(--sidebar-width);
|
||||
background-color: var(--surface-color);
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid var(--border-color);
|
||||
flex-shrink: 0;
|
||||
transition: transform var(--transition-speed) ease, background-color var(--transition-speed), border-color var(--transition-speed);
|
||||
}
|
||||
.sidebar-header {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
margin-bottom: 32px; font-size: 1.5rem; color: var(--primary-color);
|
||||
}
|
||||
.sidebar-header h1 { font-size: 1.25rem; font-weight: 600; color: var(--text-color); }
|
||||
.sidebar-nav { flex-grow: 1; }
|
||||
.sidebar-nav ul li a {
|
||||
display: flex; align-items: center; gap: 16px; padding: 12px;
|
||||
border-radius: var(--border-radius-small); color: var(--text-color-secondary);
|
||||
font-weight: 500; transition: all 0.2s ease;
|
||||
}
|
||||
.sidebar-nav ul li a:hover { background-color: var(--bg-color); color: var(--text-color); }
|
||||
.sidebar-nav ul li a.active { background-color: var(--primary-color); color: white; }
|
||||
.sidebar-nav ul li a.active i { color: white; }
|
||||
.sidebar-bottom { margin-top: auto; padding-top: 16px; border-top: 1px solid var(--border-color); transition: border-color var(--transition-speed); }
|
||||
.theme-switcher {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
background-color: var(--bg-color);
|
||||
border-radius: var(--border-radius-small);
|
||||
margin-bottom: 16px;
|
||||
transition: background-color var(--transition-speed);
|
||||
}
|
||||
.theme-switcher i { color: var(--text-color-secondary); }
|
||||
.switch { position: relative; display: inline-block; width: 44px; height: 24px; }
|
||||
.switch input { opacity: 0; width: 0; height: 0; }
|
||||
.slider {
|
||||
position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0;
|
||||
background-color: #ccc; border-radius: 24px;
|
||||
transition: var(--transition-speed);
|
||||
}
|
||||
.slider:before {
|
||||
position: absolute; content: ""; height: 18px; width: 18px; left: 3px; bottom: 3px;
|
||||
background-color: white; border-radius: 50%;
|
||||
transition: var(--transition-speed);
|
||||
}
|
||||
input:checked + .slider { background-color: var(--primary-color); }
|
||||
input:checked + .slider:before { transform: translateX(20px); }
|
||||
.caddy-control-panel {
|
||||
margin-top: 16px;
|
||||
padding: 12px;
|
||||
background-color: var(--bg-color);
|
||||
border-radius: var(--border-radius-small);
|
||||
transition: background-color var(--transition-speed);
|
||||
}
|
||||
#caddy-action-button-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.caddy-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.status-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
transition: background-color var(--transition-speed);
|
||||
}
|
||||
.status-dot.running { background-color: var(--success-color); }
|
||||
.status-dot.stopped { background-color: var(--danger-color); }
|
||||
.status-dot.checking { background-color: var(--checking-color); }
|
||||
.status-dot.error { background-color: var(--text-color-secondary); }
|
||||
.status-text {
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
.logout-section {
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
transition: border-color var(--transition-speed);
|
||||
}
|
||||
.main-header {
|
||||
display: flex; align-items: center; margin-bottom: 24px;
|
||||
}
|
||||
.main-header h2 { font-size: 1.75rem; font-weight: 700; flex-grow: 1; }
|
||||
#menu-toggle-btn { display: none; margin-right: 16px; }
|
||||
.card-panel {
|
||||
background-color: var(--surface-color);
|
||||
border-radius: var(--border-radius-large);
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: 0 4px 6px -1px rgba(0,0,0,0.05), 0 2px 4px -1px rgba(0,0,0,0.03);
|
||||
transition: background-color var(--transition-speed), border-color var(--transition-speed);
|
||||
}
|
||||
.form-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.form-panel-header h3 {
|
||||
flex-grow: 1;
|
||||
}
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
|
||||
padding: 10px 18px; border: 1px solid transparent;
|
||||
border-radius: var(--border-radius-small); font-weight: 600;
|
||||
cursor: pointer; transition: all 0.2s ease;
|
||||
width: 100%;
|
||||
}
|
||||
.btn:active { transform: scale(0.97); }
|
||||
.btn:disabled {
|
||||
background-color: #e5e7eb;
|
||||
color: #9ca3af;
|
||||
cursor: not-allowed;
|
||||
border-color: transparent;
|
||||
transform: none;
|
||||
}
|
||||
[data-theme="dark"] .btn:disabled {
|
||||
background-color: #374151;
|
||||
color: #6b7280;
|
||||
}
|
||||
.btn-primary { background-color: var(--primary-color); color: white; }
|
||||
.btn-primary:hover:not(:disabled) { background-color: var(--primary-color-hover); }
|
||||
.btn-secondary { background-color: var(--surface-color); color: var(--text-color); border-color: var(--border-color); }
|
||||
.btn-secondary:hover:not(:disabled) { border-color: #ced4da; background-color: var(--bg-color); }
|
||||
.btn-danger { background-color: var(--danger-color); color: white; }
|
||||
.btn-danger:hover:not(:disabled) { background-color: var(--danger-color-hover); }
|
||||
.btn-success { background-color: var(--success-color); color: white; }
|
||||
.btn-success:hover:not(:disabled) { background-color: var(--success-color-hover); }
|
||||
.btn-warning { background-color: var(--warning-color); color: white; }
|
||||
.btn-warning:hover:not(:disabled) { background-color: var(--warning-color-hover); }
|
||||
.btn-small { padding: 6px 12px; font-size: 0.875rem; width: auto; }
|
||||
.btn-icon {
|
||||
background: none; border: none; color: var(--text-color-secondary); cursor: pointer;
|
||||
width: 40px; height: 40px; font-size: 1.1rem; border-radius: 50%;
|
||||
width: auto;
|
||||
}
|
||||
.btn-icon:hover { background-color: var(--bg-color); color: var(--text-color); }
|
||||
.main-header .btn-primary { width: auto; }
|
||||
.form-actions .btn { width: auto; }
|
||||
.config-list-container { display: flex; flex-direction: column; gap: 12px; }
|
||||
.config-item {
|
||||
display: flex; align-items: center; padding: 12px 16px; background-color: var(--bg-color);
|
||||
border: 1px solid var(--border-color); border-radius: var(--border-radius-small);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.config-item:hover { transform: translateY(-2px); box-shadow: 0 4px 8px rgba(0,0,0,0.05); }
|
||||
.config-item-name { font-weight: 500; flex-grow: 1; }
|
||||
.config-item-actions { display: flex; gap: 8px; }
|
||||
.form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 24px; margin-bottom: 24px; }
|
||||
.form-group label, fieldset legend {
|
||||
display: block; font-weight: 500; margin-bottom: 8px; color: var(--text-color-secondary); font-size: 0.875rem;
|
||||
}
|
||||
.form-group input {
|
||||
width: 100%; padding: 12px; background-color: var(--surface-color); border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-small); color: var(--text-color); font-size: 1rem;
|
||||
transition: border-color 0.2s, box-shadow 0.2s, background-color var(--transition-speed);
|
||||
}
|
||||
.form-group input:focus {
|
||||
outline: none; border-color: var(--primary-color); box-shadow: 0 0 0 2px rgba(79, 70, 229, 0.2);
|
||||
}
|
||||
fieldset { border: none; padding: 0; margin: 0 0 24px 0; }
|
||||
.sub-fieldset {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-small);
|
||||
padding: 16px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.sub-legend {
|
||||
font-weight: 500;
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.checkbox-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 16px; margin-top: 8px; }
|
||||
.custom-checkbox {
|
||||
position: relative; display: inline-flex; align-items: center; cursor: pointer; gap: 12px;
|
||||
}
|
||||
.custom-checkbox input { display: none; }
|
||||
.custom-checkbox .checkmark {
|
||||
width: 20px; height: 20px; border: 2px solid var(--border-color);
|
||||
border-radius: 6px; transition: all 0.2s;
|
||||
}
|
||||
.custom-checkbox input:checked + .checkmark { background-color: var(--primary-color); border-color: var(--primary-color); }
|
||||
.custom-checkbox .checkmark::after {
|
||||
content: "\f00c"; font-family: "Font Awesome 6 Free"; font-weight: 900;
|
||||
font-size: 12px; color: white; position: absolute; top: 3px; left: 3px;
|
||||
transform: scale(0); transition: transform 0.2s;
|
||||
}
|
||||
.custom-checkbox input:checked + .checkmark::after { transform: scale(1); }
|
||||
.header-entry {
|
||||
display: grid; grid-template-columns: 1fr 1fr auto; gap: 12px; margin-bottom: 12px;
|
||||
}
|
||||
.header-entry input {
|
||||
width: 100%; padding: 10px; background-color: var(--bg-color); border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-small); color: var(--text-color);
|
||||
}
|
||||
.custom-select { position: relative; }
|
||||
.select-selected {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 12px; background-color: var(--surface-color); border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-small); cursor: pointer; user-select: none;
|
||||
transition: border-color 0.2s, box-shadow 0.2s, background-color var(--transition-speed);
|
||||
}
|
||||
.select-selected.select-arrow-active {
|
||||
border-color: var(--primary-color); box-shadow: 0 0 0 2px rgba(79, 70, 229, 0.2);
|
||||
}
|
||||
.select-selected::after {
|
||||
content: '\f078'; font-family: 'Font Awesome 6 Free'; font-weight: 900;
|
||||
transition: transform var(--transition-speed) ease;
|
||||
}
|
||||
.select-selected.select-arrow-active::after { transform: rotate(180deg); }
|
||||
.select-items {
|
||||
position: absolute; background-color: var(--surface-color); top: 100%; left: 0; right: 0;
|
||||
border: 1px solid var(--border-color); border-radius: var(--border-radius-small);
|
||||
box-shadow: 0 8px 16px rgba(0,0,0,0.1); z-index: 99;
|
||||
margin-top: 8px; max-height: 200px; overflow-y: auto;
|
||||
opacity: 0; transform: translateY(-10px); visibility: hidden;
|
||||
transition: opacity 0.2s ease, transform 0.2s ease, visibility 0.2s;
|
||||
}
|
||||
.select-items.select-show { opacity: 1; transform: translateY(0); visibility: visible; }
|
||||
.select-items div {
|
||||
padding: 12px 16px; cursor: pointer; transition: background-color 0.2s;
|
||||
}
|
||||
.select-items div:hover, .same-as-selected { background-color: var(--bg-color); }
|
||||
#rendered-output-panel pre {
|
||||
background-color: var(--bg-color); padding: 16px; border-radius: var(--border-radius-small);
|
||||
overflow-x: auto; white-space: pre-wrap; word-break: break-all;
|
||||
border: 1px solid var(--border-color); transition: background-color var(--transition-speed);
|
||||
}
|
||||
@media (max-width: 992px) {
|
||||
.sidebar {
|
||||
position: fixed; z-index: 200; height: 100%;
|
||||
transform: translateX(-100%);
|
||||
box-shadow: 0 0 20px rgba(0,0,0,0.1);
|
||||
}
|
||||
.sidebar.is-open { transform: translateX(0); }
|
||||
#menu-toggle-btn { display: inline-flex; }
|
||||
.main-header .btn-text { display: none; }
|
||||
.sidebar-nav span, .sidebar-footer span { display: inline; }
|
||||
.main-content { padding: 16px; }
|
||||
}
|
||||
148
frontend/index.html
Normal file
148
frontend/index.html
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>CaddyDash</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
||||
<link rel="stylesheet" href="css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-container">
|
||||
<aside class="sidebar" id="sidebar">
|
||||
<header class="sidebar-header">
|
||||
<i class="fa-solid fa-rocket"></i>
|
||||
<h1>CaddyDash</h1>
|
||||
</header>
|
||||
<nav class="sidebar-nav">
|
||||
<ul>
|
||||
<li><a href="#" class="active"><i class="fa-solid fa-sitemap"></i> <span>配置管理</span></a></li>
|
||||
<li><a href="#"><i class="fa-solid fa-chart-line"></i> <span>仪表盘 (预留)</span></a></li>
|
||||
<li><a href="#"><i class="fa-solid fa-puzzle-piece"></i> <span>模板管理 (预留)</span></a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
<div class="sidebar-bottom">
|
||||
<div class="theme-switcher">
|
||||
<i class="fa-solid fa-sun"></i>
|
||||
<label class="switch"><input type="checkbox" id="theme-toggle-input"><span class="slider"></span></label>
|
||||
<i class="fa-solid fa-moon"></i>
|
||||
</div>
|
||||
<div class="caddy-control-panel">
|
||||
<div id="caddy-status-indicator" class="caddy-status">
|
||||
<span class="status-dot checking"></span>
|
||||
<span class="status-text">检查中...</span>
|
||||
</div>
|
||||
<div id="caddy-action-button-container"></div>
|
||||
</div>
|
||||
<div class="logout-section">
|
||||
<button id="logout-btn" class="btn btn-secondary">
|
||||
<i class="fa-solid fa-right-from-bracket"></i>
|
||||
<span>退出登录</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="main-content">
|
||||
<header class="main-header">
|
||||
<button class="btn-icon" id="menu-toggle-btn"><i class="fa-solid fa-bars"></i></button>
|
||||
<h2>配置管理</h2>
|
||||
<button id="add-new-config-btn" class="btn btn-primary">
|
||||
<i class="fa-solid fa-plus"></i> <span class="btn-text">创建新配置</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div id="view-container">
|
||||
<section id="config-list-panel" class="card-panel view">
|
||||
<ul id="config-list" class="config-list-container"></ul>
|
||||
</section>
|
||||
<section id="config-form-panel" class="card-panel view hidden">
|
||||
<div class="form-panel-header">
|
||||
<button id="back-to-list-btn" class="btn-icon" title="返回列表">
|
||||
<i class="fa-solid fa-arrow-left"></i>
|
||||
</button>
|
||||
<h3 id="form-title">创建新配置</h3>
|
||||
</div>
|
||||
<form id="config-form">
|
||||
<input type="hidden" id="original-filename" value="">
|
||||
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label for="domain">域名 (将用作文件名)</label>
|
||||
<input type="text" id="domain" name="domain" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>模板类型</label>
|
||||
<div id="custom-select-tmpl" class="custom-select"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 反向代理配置 -->
|
||||
<fieldset id="upstream-fieldset" class="hidden">
|
||||
<legend>反向代理 (Upstream)</legend>
|
||||
<div class="form-group">
|
||||
<label for="upstream">上游服务地址</label>
|
||||
<input type="text" id="upstream" name="upstream" placeholder="例如: 127.0.0.1:8080">
|
||||
</div>
|
||||
|
||||
<!-- 新增: 上游请求头配置区域 -->
|
||||
<div class="sub-fieldset">
|
||||
<p class="sub-legend">上游请求头 (Upstream Headers)</p>
|
||||
<div id="upstream-headers-container"></div>
|
||||
<button type="button" id="add-upstream-header-btn" class="btn btn-secondary btn-small">
|
||||
<i class="fa-solid fa-plus"></i> 添加上游请求头
|
||||
</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- 文件服务器配置 -->
|
||||
<fieldset id="fileserver-fieldset" class="hidden">
|
||||
<legend>文件服务 (File Server)</legend>
|
||||
<div class="form-group">
|
||||
<label for="file_dir_path">根目录路径</label>
|
||||
<input type="text" id="file_dir_path" name="file_dir_path" placeholder="例如: /srv/www">
|
||||
</div>
|
||||
<label class="custom-checkbox">
|
||||
<input type="checkbox" id="enable_browser" name="enable_browser">
|
||||
<span class="checkmark"></span> 启用文件浏览器
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<!-- 全局请求头 -->
|
||||
<fieldset id="headers-fieldset">
|
||||
<legend>全局请求头 (Headers)</legend>
|
||||
<div id="headers-container"></div>
|
||||
<button type="button" id="add-header-btn" class="btn btn-secondary btn-small"><i class="fa-solid fa-plus"></i> 添加全局请求头</button>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>功能开关</legend>
|
||||
<div class="checkbox-grid">
|
||||
<label class="custom-checkbox"><input type="checkbox" id="enable_log" name="enable_log"><span class="checkmark"></span> 启用日志</label>
|
||||
<label class="custom-checkbox"><input type="checkbox" id="enable_error_page" name="enable_error_page"><span class="checkmark"></span> 启用错误页</label>
|
||||
<label class="custom-checkbox"><input type="checkbox" id="enable_encode" name="enable_encode"><span class="checkmark"></span> 启用压缩</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary"><i class="fa-solid fa-save"></i> <span>保存配置</span></button>
|
||||
<button type="button" id="cancel-edit-btn" class="btn btn-secondary">取消</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
<section id="rendered-output-panel" class="card-panel view hidden">
|
||||
<h3>渲染后的 Caddyfile</h3>
|
||||
<pre><code id="rendered-content"></code></pre>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div id="toast-container" class="toast-container"></div>
|
||||
<div id="dialog-container"></div>
|
||||
|
||||
<script type="module" src="js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
50
frontend/init.html
Normal file
50
frontend/init.html
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>首次设置 - CaddyDash</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
||||
<link rel="stylesheet" href="css/style.css">
|
||||
</head>
|
||||
|
||||
<body class="login-page-body">
|
||||
|
||||
<div class="login-container">
|
||||
<header class="login-header">
|
||||
<i class="fa-solid fa-magic-wand-sparkles"></i>
|
||||
<h1>欢迎使用 CaddyDash</h1>
|
||||
<p>请创建您的管理员账户以完成首次设置</p>
|
||||
</header>
|
||||
|
||||
<form id="init-form">
|
||||
<div class="form-group">
|
||||
<label for="username">管理员用户名</label>
|
||||
<input type="text" id="username" name="username" autocomplete="username">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">密码 (至少8位)</label>
|
||||
<input type="password" id="password" name="password" autocomplete="new-password">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="confirm_password">确认密码</label>
|
||||
<input type="password" id="confirm_password" name="confirm_password" autocomplete="new-password">
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-login">
|
||||
<span>完成设置</span>
|
||||
<i class="fa-solid fa-check"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="toast-container" class="toast-container"></div>
|
||||
|
||||
<script type="module" src="js/init.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
26
frontend/js/api.js
Normal file
26
frontend/js/api.js
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
// js/api.js - 处理所有与后端API的通信
|
||||
|
||||
const API_BASE = '/v0/api';
|
||||
|
||||
async function handleResponse(response) {
|
||||
// 如果响应是重定向(通常是session过期), 则让浏览器自动跳转
|
||||
if (response.redirected) {
|
||||
window.location.href = response.url;
|
||||
// 返回一个永远不会 resolve 的 Promise 来中断后续的 .then() 链
|
||||
return new Promise(() => {});
|
||||
}
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ error: `HTTP error! status: ${response.status}` }));
|
||||
throw new Error(errorData.error);
|
||||
}
|
||||
const text = await response.text();
|
||||
// 检查响应体是否为空, 避免解析空字符串时出错
|
||||
return text ? JSON.parse(text) : { success: true };
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: (endpoint) => fetch(`${API_BASE}${endpoint}`).then(handleResponse),
|
||||
post: (endpoint, body = {}) => fetch(`${API_BASE}${endpoint}`, { method: 'POST', body: JSON.stringify(body), headers: {'Content-Type': 'application/json'} }).then(handleResponse),
|
||||
put: (endpoint, body) => fetch(`${API_BASE}${endpoint}`, { method: 'PUT', body: JSON.stringify(body), headers: {'Content-Type': 'application/json'} }).then(handleResponse),
|
||||
delete: (endpoint) => fetch(`${API_BASE}${endpoint}`, { method: 'DELETE' }).then(handleResponse),
|
||||
};
|
||||
182
frontend/js/app.js
Normal file
182
frontend/js/app.js
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
// js/app.js - 主应用入口
|
||||
|
||||
import { state } from './state.js';
|
||||
import { api } from './api.js';
|
||||
import { initTheme } from './theme.js';
|
||||
import { notification } from './notifications.js';
|
||||
import { DOMElements, switchView, renderConfigList, createCustomSelect, addKeyValueInput, fillForm, showRenderedConfig, updateCaddyStatusView, updateFormVisibility } from './ui.js';
|
||||
|
||||
const POLLING_INTERVAL = 5000;
|
||||
let caddyStatusInterval;
|
||||
|
||||
function getFormStateAsString() {
|
||||
const formData = new FormData(DOMElements.configForm);
|
||||
const data = {};
|
||||
for (const [key, value] of formData.entries()) {
|
||||
const el = DOMElements.configForm.querySelector(`[name="${key}"]`);
|
||||
if (el?.type === 'checkbox') data[key] = el.checked;
|
||||
else if (data[key]) {
|
||||
if (!Array.isArray(data[key])) data[key] = [data[key]];
|
||||
data[key].push(value);
|
||||
} else data[key] = value;
|
||||
}
|
||||
return JSON.stringify(data);
|
||||
}
|
||||
async function attemptExitForm() {
|
||||
if (getFormStateAsString() !== state.initialFormState) {
|
||||
if (await notification.confirm('您有未保存的更改。确定要放弃吗?')) switchView(DOMElements.configListPanel);
|
||||
} else switchView(DOMElements.configListPanel);
|
||||
}
|
||||
async function checkCaddyStatus() {
|
||||
try {
|
||||
const response = await api.get('/caddy/status');
|
||||
updateCaddyStatusView(response.message === 'Caddy is running' ? 'running' : 'stopped', caddyHandlers);
|
||||
} catch (error) { console.error('Error checking Caddy status:', error); updateCaddyStatusView('error', caddyHandlers); }
|
||||
}
|
||||
async function handleStartCaddy() {
|
||||
try {
|
||||
const result = await api.post('/caddy/run');
|
||||
notification.toast(result.message || '启动命令已发送。', 'success');
|
||||
setTimeout(checkCaddyStatus, 500);
|
||||
} catch (error) { notification.toast(`启动失败: ${error.message}`, 'error'); }
|
||||
}
|
||||
async function handleStopCaddy() {
|
||||
if (!await notification.confirm('您确定要停止 Caddy 实例吗?')) return;
|
||||
try {
|
||||
const result = await api.post('/caddy/stop');
|
||||
notification.toast(result.message || '停止命令已发送。', 'info');
|
||||
setTimeout(checkCaddyStatus, 500);
|
||||
} catch(error) { notification.toast(`操作失败: ${error.message}`, 'error'); }
|
||||
}
|
||||
async function handleReloadCaddy() {
|
||||
if (!await notification.confirm('确定要重载 Caddy 配置吗?')) return;
|
||||
try {
|
||||
const result = await api.post('/caddy/restart');
|
||||
notification.toast(result.message || '重载命令已发送。', 'success');
|
||||
setTimeout(checkCaddyStatus, 500);
|
||||
} catch(error) { notification.toast(`重载失败: ${error.message}`, 'error'); }
|
||||
}
|
||||
const caddyHandlers = { handleStartCaddy, handleStopCaddy, handleReloadCaddy };
|
||||
async function handleLogout() {
|
||||
if (!await notification.confirm('您确定要退出登录吗?')) return;
|
||||
notification.toast('正在退出...', 'info');
|
||||
setTimeout(() => { window.location.href = `/v0/api/auth/logout`; }, 500);
|
||||
}
|
||||
async function loadAllConfigs() {
|
||||
try {
|
||||
const filenames = await api.get('/config/filenames');
|
||||
renderConfigList(filenames);
|
||||
} catch(error) { if (error.message) notification.toast(`加载配置列表失败: ${error.message}`, 'error'); }
|
||||
}
|
||||
async function handleEditConfig(originalFilename) {
|
||||
try {
|
||||
const [config, rendered] = await Promise.all([
|
||||
api.get(`/config/file/${originalFilename}`),
|
||||
api.get('/config/files/rendered')
|
||||
]);
|
||||
state.isEditing = true;
|
||||
switchView(DOMElements.configFormPanel);
|
||||
DOMElements.formTitle.textContent = '编辑配置';
|
||||
fillForm(config, originalFilename);
|
||||
showRenderedConfig(rendered, originalFilename);
|
||||
updateFormVisibility(config.tmpl_type, state.availableTemplates);
|
||||
state.initialFormState = getFormStateAsString();
|
||||
} catch(error) { notification.toast(`加载配置详情失败: ${error.message}`, 'error'); }
|
||||
}
|
||||
async function handleDeleteConfig(filename) {
|
||||
if (!await notification.confirm(`确定要删除配置 "${filename}" 吗?`)) return;
|
||||
try {
|
||||
await api.delete(`/config/file/${filename}`);
|
||||
notification.toast('配置已成功删除。', 'success');
|
||||
loadAllConfigs();
|
||||
} catch(error) { notification.toast(`删除失败: ${error.message}`, 'error'); }
|
||||
}
|
||||
async function handleSaveConfig(e) {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(DOMElements.configForm);
|
||||
const filename = formData.get('domain');
|
||||
if (!filename) {
|
||||
notification.toast('域名不能为空。', 'error');
|
||||
return;
|
||||
}
|
||||
const getHeadersMap = (keyName, valueName) => {
|
||||
const headers = {};
|
||||
formData.getAll(keyName).forEach((key, i) => {
|
||||
if (key) {
|
||||
if (!headers[key]) headers[key] = [];
|
||||
headers[key].push(formData.getAll(valueName)[i]);
|
||||
}
|
||||
});
|
||||
return headers;
|
||||
};
|
||||
const globalHeaders = getHeadersMap('header_key', 'header_value');
|
||||
const upstreamHeaders = getHeadersMap('upstream_header_key', 'upstream_header_value');
|
||||
const configData = {
|
||||
domain: filename,
|
||||
tmpl_type: formData.get('tmpl_type'),
|
||||
upstream: { upstream: formData.get('upstream'), muti_upstream: false, upstreams: [], upstream_headers: upstreamHeaders },
|
||||
file_server: { file_dir_path: formData.get('file_dir_path'), enable_browser: DOMElements.configForm.querySelector('#enable_browser').checked },
|
||||
headers: globalHeaders,
|
||||
log: { enable_log: DOMElements.configForm.querySelector('#enable_log').checked, log_domain: filename },
|
||||
error_page: { enable_error_page: DOMElements.configForm.querySelector('#enable_error_page').checked },
|
||||
encode: { enable_encode: DOMElements.configForm.querySelector('#enable_encode').checked }
|
||||
};
|
||||
try {
|
||||
const result = await api.put(`/config/file/${filename}`, configData);
|
||||
state.isEditing = false;
|
||||
notification.toast(result.message || '配置已成功保存。', 'success');
|
||||
setTimeout(() => {
|
||||
switchView(DOMElements.configListPanel);
|
||||
loadAllConfigs();
|
||||
}, 500);
|
||||
} catch(error) { notification.toast(`保存失败: ${error.message}`, 'error'); }
|
||||
}
|
||||
function init() {
|
||||
initTheme(DOMElements.themeToggleInput);
|
||||
notification.init(DOMElements.toastContainer, DOMElements.dialogContainer);
|
||||
loadAllConfigs();
|
||||
api.get('/config/templates')
|
||||
.then(templates => {
|
||||
state.availableTemplates = templates || [];
|
||||
const options = state.availableTemplates.length > 0 ? state.availableTemplates : ['无可用模板'];
|
||||
createCustomSelect('custom-select-tmpl', options, (selectedValue) => {
|
||||
updateFormVisibility(selectedValue, state.availableTemplates);
|
||||
});
|
||||
if (options[0] !== '无可用模板') updateFormVisibility(options[0], state.availableTemplates);
|
||||
})
|
||||
.catch(err => { if(err.message) notification.toast(`加载模板失败: ${err.message}`, 'error') });
|
||||
checkCaddyStatus();
|
||||
caddyStatusInterval = setInterval(checkCaddyStatus, POLLING_INTERVAL);
|
||||
DOMElements.menuToggleBtn.addEventListener('click', () => DOMElements.sidebar.classList.toggle('is-open'));
|
||||
DOMElements.mainContent.addEventListener('click', () => DOMElements.sidebar.classList.remove('is-open'));
|
||||
DOMElements.addNewConfigBtn.addEventListener('click', () => {
|
||||
state.isEditing = false;
|
||||
switchView(DOMElements.configFormPanel);
|
||||
DOMElements.formTitle.textContent = '创建新配置';
|
||||
DOMElements.configForm.reset();
|
||||
if (state.availableTemplates.length > 0) {
|
||||
const selectContainer = document.getElementById('custom-select-tmpl');
|
||||
selectContainer.querySelector('.select-selected').textContent = state.availableTemplates[0];
|
||||
selectContainer.querySelector('input[type="hidden"]').value = state.availableTemplates[0];
|
||||
updateFormVisibility(state.availableTemplates[0], state.availableTemplates);
|
||||
}
|
||||
state.initialFormState = getFormStateAsString();
|
||||
DOMElements.headersContainer.innerHTML = '';
|
||||
DOMElements.upstreamHeadersContainer.innerHTML = '';
|
||||
DOMElements.originalFilenameInput.value = '';
|
||||
});
|
||||
DOMElements.backToListBtn.addEventListener('click', attemptExitForm);
|
||||
DOMElements.cancelEditBtn.addEventListener('click', attemptExitForm);
|
||||
DOMElements.addHeaderBtn.addEventListener('click', () => addKeyValueInput(DOMElements.headersContainer, 'header_key', 'header_value'));
|
||||
DOMElements.addUpstreamHeaderBtn.addEventListener('click', () => addKeyValueInput(DOMElements.upstreamHeadersContainer, 'upstream_header_key', 'upstream_header_value'));
|
||||
DOMElements.configForm.addEventListener('submit', handleSaveConfig);
|
||||
DOMElements.logoutBtn.addEventListener('click', handleLogout);
|
||||
DOMElements.configListContainer.addEventListener('click', e => {
|
||||
const button = e.target.closest('button');
|
||||
if (!button) return;
|
||||
const filename = button.closest('.config-item').dataset.filename;
|
||||
if (button.classList.contains('edit-btn')) handleEditConfig(filename);
|
||||
if (button.classList.contains('delete-btn')) handleDeleteConfig(filename);
|
||||
});
|
||||
}
|
||||
init();
|
||||
150
frontend/js/init.js
Normal file
150
frontend/js/init.js
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
// js/init.js
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const DOMElements = {
|
||||
initForm: document.getElementById('init-form'),
|
||||
initButton: null,
|
||||
toastContainer: document.getElementById('toast-container'),
|
||||
usernameInput: document.getElementById('username'),
|
||||
passwordInput: document.getElementById('password'),
|
||||
confirmPasswordInput: document.getElementById('confirm_password'),
|
||||
};
|
||||
|
||||
const INIT_API_URL = '/v0/api/auth/init';
|
||||
const PASSWORD_MIN_LENGTH = 8;
|
||||
const TOAST_DEFAULT_DURATION = 3000;
|
||||
const REDIRECT_DELAY_SUCCESS = 1500;
|
||||
|
||||
const theme = {
|
||||
init: () => {
|
||||
const storedTheme = localStorage.getItem('theme');
|
||||
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
const currentTheme = storedTheme || (systemPrefersDark ? 'dark' : 'light');
|
||||
document.documentElement.dataset.theme = currentTheme;
|
||||
}
|
||||
};
|
||||
|
||||
const toast = {
|
||||
_container: null,
|
||||
_icons: { success: 'fa-check-circle', error: 'fa-times-circle', info: 'fa-info-circle' },
|
||||
|
||||
init: (containerElement) => {
|
||||
if (!containerElement) {
|
||||
console.error('Toast container element not found.');
|
||||
return;
|
||||
}
|
||||
toast._container = containerElement;
|
||||
toast._container.addEventListener('click', (e) => {
|
||||
if (e.target.dataset.toastClose !== undefined) {
|
||||
toast._hideToast(e.target.closest('.toast'));
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
show: (message, type = 'info', duration = TOAST_DEFAULT_DURATION) => {
|
||||
if (!toast._container) {
|
||||
console.error('Toast module not initialized. Container is missing.');
|
||||
return;
|
||||
}
|
||||
|
||||
const iconClass = toast._icons[type] || 'fa-info-circle';
|
||||
const toastElement = document.createElement('div');
|
||||
toastElement.className = `toast ${type}`;
|
||||
toastElement.innerHTML = `
|
||||
<i class="toast-icon fa-solid ${iconClass}"></i>
|
||||
<p class="toast-message">${message}</p>
|
||||
<button class="toast-close" data-toast-close>×</button>
|
||||
`;
|
||||
toast._container.appendChild(toastElement);
|
||||
requestAnimationFrame(() => toastElement.classList.add('show'));
|
||||
setTimeout(() => toast._hideToast(toastElement), duration);
|
||||
},
|
||||
|
||||
_hideToast: (toastElement) => {
|
||||
if (!toastElement) return;
|
||||
toastElement.classList.remove('show');
|
||||
toastElement.addEventListener('transitionend', () => toastElement.remove(), { once: true });
|
||||
}
|
||||
};
|
||||
|
||||
async function handleInitSubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
// 1. 获取并修剪输入值
|
||||
const username = DOMElements.usernameInput.value.trim(); // 获取用户名并去除前后空格
|
||||
const password = DOMElements.passwordInput.value.trim();
|
||||
const confirmPassword = DOMElements.confirmPasswordInput.value.trim();
|
||||
|
||||
// 2. 添加用户名输入框的空值验证
|
||||
if (username === '') {
|
||||
toast.show('管理员用户名不能为空', 'error');
|
||||
DOMElements.usernameInput.focus();
|
||||
return;
|
||||
}
|
||||
// 其他密码验证逻辑不变
|
||||
if (password === '') {
|
||||
toast.show('密码不能为空', 'error');
|
||||
DOMElements.passwordInput.focus();
|
||||
return;
|
||||
}
|
||||
if (confirmPassword === '') {
|
||||
toast.show('确认密码不能为空', 'error');
|
||||
DOMElements.confirmPasswordInput.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
toast.show('两次输入的密码不匹配', 'error');
|
||||
DOMElements.passwordInput.focus();
|
||||
return;
|
||||
}
|
||||
if (password.length < PASSWORD_MIN_LENGTH) {
|
||||
toast.show(`密码长度至少为 ${PASSWORD_MIN_LENGTH} 位`, 'error');
|
||||
DOMElements.passwordInput.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
DOMElements.initButton.disabled = true;
|
||||
DOMElements.initButton.querySelector('span').textContent = '设置中...';
|
||||
|
||||
try {
|
||||
const formData = new FormData(DOMElements.initForm);
|
||||
formData.set('username', username); // 确保发送的是修剪过的用户名
|
||||
formData.set('password', password); // 确保发送的是修剪过的密码
|
||||
formData.delete('confirm_password');
|
||||
|
||||
const response = await fetch(INIT_API_URL, {
|
||||
method: 'POST',
|
||||
body: new URLSearchParams(formData)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
toast.show('管理员账户创建成功!正在跳转到登录页面...', 'success');
|
||||
setTimeout(() => { window.location.href = '/login.html'; }, REDIRECT_DELAY_SUCCESS);
|
||||
} else {
|
||||
throw new Error(result.error || `初始化失败: ${response.status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.show(error.message, 'error');
|
||||
DOMElements.initButton.disabled = false;
|
||||
DOMElements.initButton.querySelector('span').textContent = '完成设置';
|
||||
}
|
||||
}
|
||||
|
||||
function initApp() {
|
||||
theme.init();
|
||||
|
||||
if (DOMElements.initForm) {
|
||||
DOMElements.initButton = DOMElements.initForm.querySelector('button[type="submit"]');
|
||||
|
||||
toast.init(DOMElements.toastContainer);
|
||||
|
||||
DOMElements.initForm.addEventListener('submit', handleInitSubmit);
|
||||
} else {
|
||||
console.error('Init form element not found. Script may not function correctly.');
|
||||
}
|
||||
}
|
||||
|
||||
initApp();
|
||||
});
|
||||
89
frontend/js/login.js
Normal file
89
frontend/js/login.js
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
const DOMElements = {
|
||||
loginForm: document.getElementById('login-form'),
|
||||
toastContainer: document.getElementById('toast-container'),
|
||||
};
|
||||
const loginButton = DOMElements.loginForm.querySelector('button[type="submit"]');
|
||||
const LOGIN_API_URL = '/v0/api/auth/login';
|
||||
|
||||
const theme = {
|
||||
init: () => {
|
||||
const storedTheme = localStorage.getItem('theme');
|
||||
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
const currentTheme = storedTheme || (systemPrefersDark ? 'dark' : 'light');
|
||||
document.documentElement.dataset.theme = currentTheme;
|
||||
}
|
||||
};
|
||||
|
||||
const toast = {
|
||||
show: (message, type = 'info', duration = 3000) => {
|
||||
const icons = { success: 'fa-check-circle', error: 'fa-times-circle', info: 'fa-info-circle' };
|
||||
const iconClass = icons[type] || 'fa-info-circle';
|
||||
const toastElement = document.createElement('div');
|
||||
toastElement.className = `toast ${type}`;
|
||||
toastElement.innerHTML = `<i class="toast-icon fa-solid ${iconClass}"></i><p class="toast-message">${message}</p><button class="toast-close" data-toast-close>×</button>`;
|
||||
DOMElements.toastContainer.appendChild(toastElement);
|
||||
requestAnimationFrame(() => toastElement.classList.add('show'));
|
||||
const timeoutId = setTimeout(() => hideToast(toastElement), duration);
|
||||
toastElement.querySelector('[data-toast-close]').addEventListener('click', () => {
|
||||
clearTimeout(timeoutId);
|
||||
hideToast(toastElement);
|
||||
});
|
||||
}
|
||||
};
|
||||
function hideToast(toastElement) {
|
||||
if (!toastElement) return;
|
||||
toastElement.classList.remove('show');
|
||||
toastElement.addEventListener('transitionend', () => toastElement.remove(), { once: true });
|
||||
}
|
||||
|
||||
async function handleLogin(e) {
|
||||
e.preventDefault();
|
||||
|
||||
// 获取并确认值
|
||||
const username = DOMElements.loginForm.username.value.trim();
|
||||
const password = DOMElements.loginForm.password.value.trim();
|
||||
|
||||
if (username === '') {
|
||||
toast.show('用户名不能为空', 'error');
|
||||
DOMElements.loginForm.username.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (password === '') {
|
||||
toast.show('密码不能为空', 'error');
|
||||
DOMElements.loginForm.password.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
loginButton.disabled = true;
|
||||
loginButton.querySelector('span').textContent = '登录中...';
|
||||
|
||||
try {
|
||||
const response = await fetch(LOGIN_API_URL, {
|
||||
method: 'POST',
|
||||
body: new URLSearchParams(new FormData(DOMElements.loginForm))
|
||||
});
|
||||
const result = await response.json();
|
||||
if (response.ok) {
|
||||
toast.show('登录成功,正在跳转...', 'success');
|
||||
setTimeout(() => { window.location.href = '/'; }, 500);
|
||||
} else {
|
||||
throw new Error(result.error || `登录失败: ${response.status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.show(error.message, 'error');
|
||||
loginButton.disabled = false;
|
||||
loginButton.querySelector('span').textContent = '登录';
|
||||
}
|
||||
}
|
||||
|
||||
function init() {
|
||||
theme.init();
|
||||
if (DOMElements.loginForm) {
|
||||
DOMElements.loginForm.addEventListener('submit', handleLogin);
|
||||
}
|
||||
}
|
||||
init();
|
||||
});
|
||||
57
frontend/js/notifications.js
Normal file
57
frontend/js/notifications.js
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
// js/notifications.js - 提供Toast和Dialog两种通知
|
||||
|
||||
// 这个模块在初始化时需要知道容器的DOM元素
|
||||
let toastContainer;
|
||||
let dialogContainer;
|
||||
|
||||
function hideToast(toastElement) {
|
||||
if (!toastElement) return;
|
||||
toastElement.classList.remove('show');
|
||||
toastElement.addEventListener('transitionend', () => toastElement.remove(), { once: true });
|
||||
}
|
||||
|
||||
export const notification = {
|
||||
init: (toastEl, dialogEl) => {
|
||||
toastContainer = toastEl;
|
||||
dialogContainer = dialogEl;
|
||||
},
|
||||
toast: (message, type = 'info', duration = 3000) => {
|
||||
const icons = { success: 'fa-check-circle', error: 'fa-times-circle', info: 'fa-info-circle', warning: 'fa-exclamation-triangle' };
|
||||
const iconClass = icons[type] || 'fa-info-circle';
|
||||
const toastElement = document.createElement('div');
|
||||
toastElement.className = `toast ${type}`;
|
||||
toastElement.innerHTML = `<i class="toast-icon fa-solid ${iconClass}"></i><p class="toast-message">${message}</p><button class="toast-close" data-toast-close>×</button>`;
|
||||
toastContainer.appendChild(toastElement);
|
||||
requestAnimationFrame(() => toastElement.classList.add('show'));
|
||||
const timeoutId = setTimeout(() => hideToast(toastElement), duration);
|
||||
toastElement.querySelector('[data-toast-close]').addEventListener('click', () => {
|
||||
clearTimeout(timeoutId);
|
||||
hideToast(toastElement);
|
||||
});
|
||||
},
|
||||
confirm: (message) => {
|
||||
return new Promise(resolve => {
|
||||
const dialogHTML = `
|
||||
<div class="dialog-box">
|
||||
<p class="dialog-message">${message}</p>
|
||||
<div class="dialog-actions">
|
||||
<button class="btn btn-secondary" data-action="cancel">取消</button>
|
||||
<button class="btn btn-primary" data-action="confirm">确定</button>
|
||||
</div>
|
||||
</div>`;
|
||||
dialogContainer.innerHTML = dialogHTML;
|
||||
dialogContainer.classList.add('active');
|
||||
const eventHandler = (e) => {
|
||||
const actionButton = e.target.closest('[data-action]');
|
||||
if (!actionButton) return;
|
||||
closeDialog(actionButton.dataset.action === 'confirm');
|
||||
};
|
||||
const closeDialog = (result) => {
|
||||
dialogContainer.removeEventListener('click', eventHandler);
|
||||
dialogContainer.classList.remove('active');
|
||||
setTimeout(() => { dialogContainer.innerHTML = ''; resolve(result); }, 200);
|
||||
};
|
||||
dialogContainer.addEventListener('click', eventHandler);
|
||||
});
|
||||
}
|
||||
};
|
||||
7
frontend/js/state.js
Normal file
7
frontend/js/state.js
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
// js/state.js - 管理应用的共享状态
|
||||
|
||||
export const state = {
|
||||
isEditing: false,
|
||||
initialFormState: '', // 用于检测表单是否有未保存的更改
|
||||
availableTemplates: [], // 存储从后端获取的可用模板名称
|
||||
};
|
||||
26
frontend/js/theme.js
Normal file
26
frontend/js/theme.js
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
// js/theme.js - 处理深色/浅色主题切换
|
||||
|
||||
// 这个模块在初始化时需要知道切换按钮的DOM元素
|
||||
let themeToggleInput;
|
||||
|
||||
function applyTheme(themeName) {
|
||||
document.documentElement.dataset.theme = themeName;
|
||||
if (themeToggleInput) {
|
||||
themeToggleInput.checked = themeName === 'dark';
|
||||
}
|
||||
localStorage.setItem('theme', themeName);
|
||||
}
|
||||
|
||||
function handleToggle(e) {
|
||||
const newTheme = e.target.checked ? 'dark' : 'light';
|
||||
applyTheme(newTheme);
|
||||
}
|
||||
|
||||
export function initTheme(toggleElement) {
|
||||
themeToggleInput = toggleElement;
|
||||
const storedTheme = localStorage.getItem('theme');
|
||||
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
const currentTheme = storedTheme || (systemPrefersDark ? 'dark' : 'light');
|
||||
applyTheme(currentTheme);
|
||||
themeToggleInput.addEventListener('change', handleToggle);
|
||||
}
|
||||
176
frontend/js/ui.js
Normal file
176
frontend/js/ui.js
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
// js/ui.js - 管理所有与UI渲染和DOM操作相关的函数
|
||||
|
||||
// 集中管理所有需要操作的DOM元素
|
||||
export const DOMElements = {
|
||||
sidebar: document.getElementById('sidebar'),
|
||||
menuToggleBtn: document.getElementById('menu-toggle-btn'),
|
||||
mainContent: document.querySelector('.main-content'),
|
||||
configListPanel: document.getElementById('config-list-panel'),
|
||||
configFormPanel: document.getElementById('config-form-panel'),
|
||||
renderedOutputPanel: document.getElementById('rendered-output-panel'),
|
||||
configForm: document.getElementById('config-form'),
|
||||
formTitle: document.getElementById('form-title'),
|
||||
backToListBtn: document.getElementById('back-to-list-btn'),
|
||||
domainInput: document.getElementById('domain'),
|
||||
originalFilenameInput: document.getElementById('original-filename'),
|
||||
headersContainer: document.getElementById('headers-container'),
|
||||
addNewConfigBtn: document.getElementById('add-new-config-btn'),
|
||||
cancelEditBtn: document.getElementById('cancel-edit-btn'),
|
||||
addHeaderBtn: document.getElementById('add-header-btn'),
|
||||
configListContainer: document.getElementById('config-list'),
|
||||
renderedContentCode: document.getElementById('rendered-content'),
|
||||
toastContainer: document.getElementById('toast-container'),
|
||||
dialogContainer: document.getElementById('dialog-container'),
|
||||
themeToggleInput: document.getElementById('theme-toggle-input'),
|
||||
caddyStatusIndicator: document.getElementById('caddy-status-indicator'),
|
||||
caddyActionButtonContainer: document.getElementById('caddy-action-button-container'),
|
||||
logoutBtn: document.getElementById('logout-btn'),
|
||||
upstreamFieldset: document.getElementById('upstream-fieldset'),
|
||||
fileserverFieldset: document.getElementById('fileserver-fieldset'),
|
||||
upstreamHeadersContainer: document.getElementById('upstream-headers-container'),
|
||||
addUpstreamHeaderBtn: document.getElementById('add-upstream-header-btn'),
|
||||
};
|
||||
|
||||
export function switchView(viewToShow) {
|
||||
[DOMElements.configListPanel, DOMElements.configFormPanel, DOMElements.renderedOutputPanel]
|
||||
.forEach(view => view.classList.add('hidden'));
|
||||
if (viewToShow) viewToShow.classList.remove('hidden');
|
||||
DOMElements.addNewConfigBtn.disabled = (viewToShow === DOMElements.configFormPanel);
|
||||
}
|
||||
|
||||
export function renderConfigList(filenames) {
|
||||
DOMElements.configListContainer.innerHTML = '';
|
||||
if (!filenames || filenames.length === 0) {
|
||||
DOMElements.configListContainer.innerHTML = '<p>还没有任何配置,请创建一个。</p>';
|
||||
return;
|
||||
}
|
||||
filenames.forEach(filename => {
|
||||
const item = document.createElement('li');
|
||||
item.className = 'config-item';
|
||||
item.dataset.filename = filename;
|
||||
item.innerHTML = `<span class="config-item-name">${filename}</span><div class="config-item-actions"><button class="btn-icon edit-btn" title="编辑"><i class="fa-solid fa-pen-to-square"></i></button><button class="btn-icon delete-btn" title="删除"><i class="fa-solid fa-trash-can"></i></button></div>`;
|
||||
DOMElements.configListContainer.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
export function createCustomSelect(containerId, options, onSelect) {
|
||||
const container = document.getElementById(containerId);
|
||||
container.innerHTML = `<div class="select-selected"></div><div class="select-items"></div><input type="hidden" name="tmpl_type">`;
|
||||
const selectedDiv = container.querySelector('.select-selected');
|
||||
const itemsDiv = container.querySelector('.select-items');
|
||||
const hiddenInput = container.querySelector('input[type="hidden"]');
|
||||
itemsDiv.innerHTML = '';
|
||||
options.forEach((option, index) => {
|
||||
const item = document.createElement('div');
|
||||
item.textContent = option;
|
||||
item.dataset.value = option;
|
||||
if (index === 0) {
|
||||
selectedDiv.textContent = option;
|
||||
hiddenInput.value = option;
|
||||
}
|
||||
item.addEventListener('click', function(e) {
|
||||
selectedDiv.textContent = this.textContent;
|
||||
hiddenInput.value = this.dataset.value;
|
||||
itemsDiv.classList.remove('select-show');
|
||||
selectedDiv.classList.remove('select-arrow-active');
|
||||
onSelect && onSelect(this.dataset.value);
|
||||
e.stopPropagation();
|
||||
});
|
||||
itemsDiv.appendChild(item);
|
||||
});
|
||||
selectedDiv.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
document.querySelectorAll('.select-items.select-show').forEach(openSelect => {
|
||||
if (openSelect !== itemsDiv) {
|
||||
openSelect.classList.remove('select-show');
|
||||
openSelect.previousElementSibling.classList.remove('select-arrow-active');
|
||||
}
|
||||
});
|
||||
itemsDiv.classList.toggle('select-show');
|
||||
selectedDiv.classList.toggle('select-arrow-active');
|
||||
});
|
||||
document.addEventListener('click', () => {
|
||||
itemsDiv.classList.remove('select-show');
|
||||
selectedDiv.classList.remove('select-arrow-active');
|
||||
});
|
||||
}
|
||||
|
||||
export function addKeyValueInput(container, keyName, valueName, key = '', value = '') {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'header-entry';
|
||||
div.innerHTML = `
|
||||
<input type="text" name="${keyName}" placeholder="Key" value="${key}">
|
||||
<input type="text" name="${valueName}" placeholder="Value" value="${value}">
|
||||
<button type="button" class="btn-icon" onclick="this.parentElement.remove()" title="移除此条目">
|
||||
<i class="fa-solid fa-xmark"></i>
|
||||
</button>`;
|
||||
container.appendChild(div);
|
||||
}
|
||||
|
||||
export function fillForm(config, originalFilename) {
|
||||
DOMElements.originalFilenameInput.value = originalFilename;
|
||||
DOMElements.domainInput.value = config.domain;
|
||||
document.getElementById('upstream').value = config.upstream?.upstream || '';
|
||||
document.getElementById('file_dir_path').value = config.file_server?.file_dir_path || '';
|
||||
document.getElementById('enable_browser').checked = config.file_server?.enable_browser || false;
|
||||
const selectContainer = document.getElementById('custom-select-tmpl');
|
||||
selectContainer.querySelector('.select-selected').textContent = config.tmpl_type;
|
||||
selectContainer.querySelector('input[type="hidden"]').value = config.tmpl_type;
|
||||
DOMElements.headersContainer.innerHTML = '';
|
||||
if (config.headers) Object.entries(config.headers).forEach(([k, v]) => v.forEach(val => addKeyValueInput(DOMElements.headersContainer, 'header_key', 'header_value', k, val)));
|
||||
DOMElements.upstreamHeadersContainer.innerHTML = '';
|
||||
if (config.upstream?.upstream_headers) Object.entries(config.upstream.upstream_headers).forEach(([k, v]) => v.forEach(val => addKeyValueInput(DOMElements.upstreamHeadersContainer, 'upstream_header_key', 'upstream_header_value', k, val)));
|
||||
document.getElementById('enable_log').checked = config.log?.enable_log || false;
|
||||
document.getElementById('enable_error_page').checked = config.error_page?.enable_error_page || false;
|
||||
document.getElementById('enable_encode').checked = config.encode?.enable_encode || false;
|
||||
}
|
||||
|
||||
export function showRenderedConfig(configs, filename) {
|
||||
const targetConfig = configs.find(c => c.filename === filename);
|
||||
if (targetConfig && targetConfig.rendered_content) {
|
||||
DOMElements.renderedContentCode.textContent = atob(targetConfig.rendered_content);
|
||||
DOMElements.renderedOutputPanel.classList.remove('hidden');
|
||||
} else {
|
||||
DOMElements.renderedOutputPanel.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function createButton(text, className, onClick) {
|
||||
const button = document.createElement('button');
|
||||
button.className = `btn ${className}`;
|
||||
button.innerHTML = `<span>${text}</span>`;
|
||||
button.addEventListener('click', onClick);
|
||||
return button;
|
||||
}
|
||||
|
||||
export function updateCaddyStatusView(status, handlers) {
|
||||
const { handleReloadCaddy, handleStopCaddy, handleStartCaddy } = handlers;
|
||||
const dot = DOMElements.caddyStatusIndicator.querySelector('.status-dot');
|
||||
const text = DOMElements.caddyStatusIndicator.querySelector('.status-text');
|
||||
const buttonContainer = DOMElements.caddyActionButtonContainer;
|
||||
dot.className = 'status-dot';
|
||||
buttonContainer.innerHTML = '';
|
||||
let statusText, dotClass;
|
||||
switch (status) {
|
||||
case 'running':
|
||||
statusText = '运行中'; dotClass = 'running';
|
||||
buttonContainer.appendChild(createButton('重载配置', 'btn-warning', handleReloadCaddy));
|
||||
buttonContainer.appendChild(createButton('停止 Caddy', 'btn-danger', handleStopCaddy));
|
||||
break;
|
||||
case 'stopped':
|
||||
statusText = '已停止'; dotClass = 'stopped';
|
||||
buttonContainer.appendChild(createButton('启动 Caddy', 'btn-success', handleStartCaddy));
|
||||
break;
|
||||
case 'checking': statusText = '检查中...'; dotClass = 'checking'; break;
|
||||
default: statusText = '状态未知'; dotClass = 'error'; break;
|
||||
}
|
||||
text.textContent = statusText;
|
||||
dot.classList.add(dotClass);
|
||||
}
|
||||
|
||||
export function updateFormVisibility(selectedTemplate, availableTemplates) {
|
||||
const showUpstream = selectedTemplate === 'reverse_proxy' && availableTemplates.includes('reverse_proxy');
|
||||
const showFileServer = selectedTemplate === 'file_server' && availableTemplates.includes('file_server');
|
||||
DOMElements.upstreamFieldset.classList.toggle('hidden', !showUpstream);
|
||||
DOMElements.fileserverFieldset.classList.toggle('hidden', !showFileServer);
|
||||
}
|
||||
44
frontend/login.html
Normal file
44
frontend/login.html
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>登录 - CaddyDash</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
||||
<link rel="stylesheet" href="css/style.css">
|
||||
</head>
|
||||
<body class="login-page-body">
|
||||
|
||||
<div class="login-container">
|
||||
<header class="login-header">
|
||||
<i class="fa-solid fa-rocket"></i>
|
||||
<h1>CaddyDash</h1>
|
||||
<p>请输入您的凭证以继续</p>
|
||||
</header>
|
||||
|
||||
<form id="login-form">
|
||||
<div class="form-group">
|
||||
<label for="username">用户名</label>
|
||||
<input type="text" id="username" name="username" autocomplete="username">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">密码</label>
|
||||
<input type="password" id="password" name="password" autocomplete="current-password">
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-login">
|
||||
<span>登录</span>
|
||||
<i class="fa-solid fa-arrow-right"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 右上角消息通知容器 -->
|
||||
<div id="toast-container" class="toast-container"></div>
|
||||
|
||||
<script src="js/login.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
152
gen/init.go
Normal file
152
gen/init.go
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
package gen
|
||||
|
||||
import (
|
||||
"caddydash/config"
|
||||
"caddydash/db"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 把指定目录下的文件作为模板读入
|
||||
func ReadTmplToDB(dir string, cdb *db.ConfigDB) error {
|
||||
// 遍历目录下的所有文件
|
||||
files, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, file := range files {
|
||||
if file.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
// 读取文件内容
|
||||
content, err := os.ReadFile(filepath.Join(dir, file.Name()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tmplEntry := db.TemplateEntry{
|
||||
Filename: file.Name(),
|
||||
TemplateType: file.Name(),
|
||||
Content: content,
|
||||
UpdatedAt: time.Now().Unix(),
|
||||
}
|
||||
|
||||
// 存储到数据库
|
||||
err = cdb.SaveTemplate(tmplEntry)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 输出tmpl名
|
||||
fmt.Printf("Read template: %s\n", file.Name())
|
||||
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func Add80SiteConfig(cfg *config.Config, cdb *db.ConfigDB) error {
|
||||
// 检查:80是否已存在于数据库中
|
||||
|
||||
_, err := cdb.GetParams(":80")
|
||||
if err == nil {
|
||||
// 如果存在,则不添加
|
||||
return nil
|
||||
}
|
||||
|
||||
siteConfig := CaddyUniConfig{
|
||||
DomainConfig: CaddyUniDomainConfig{
|
||||
Domain: ":80",
|
||||
MutiDomains: false,
|
||||
Domains: []string{":80"},
|
||||
},
|
||||
Mode: "uni",
|
||||
FileServer: CaddyUniFileServerConfig{
|
||||
EnableFileServer: true,
|
||||
FileDirPath: cfg.Server.CaddyDir + "pages/demo",
|
||||
EnableBrowser: false,
|
||||
},
|
||||
Log: CaddyUniLogConfig{
|
||||
EnableLog: true,
|
||||
LogDomain: ":80",
|
||||
},
|
||||
ErrorPage: CaddyUniErrorPageConfig{
|
||||
EnableErrorPage: true,
|
||||
},
|
||||
Encode: CaddyUniEncodeConfig{
|
||||
EnableEncode: true,
|
||||
},
|
||||
}
|
||||
|
||||
// 制作db.ParamsEntry
|
||||
gobData, err := EncodeGobConfig(siteConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
/*
|
||||
originConfig := db.CaddyfileConfig{
|
||||
Domain: ":80",
|
||||
TmplType: "file_server",
|
||||
FileServer: db.FileServerConfig{
|
||||
FileDirPath: cfg.Server.CaddyDir + "pages/demo",
|
||||
EnableBrowser: false,
|
||||
},
|
||||
Log: db.LogConfig{
|
||||
EnableLog: true,
|
||||
LogDomain: ":80",
|
||||
},
|
||||
ErrorPage: db.ErrorPageConfig{
|
||||
EnableErrorPage: true,
|
||||
},
|
||||
Encode: db.EncodeConfig{
|
||||
EnableEncode: true,
|
||||
},
|
||||
}
|
||||
*/
|
||||
|
||||
originGobData, err := EncodeGobConfig(siteConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
paramsEntry := db.ParamsEntry{
|
||||
Filename: ":80",
|
||||
TemplateType: "file_server",
|
||||
ParamsGOB: gobData,
|
||||
ParamsOrigin: originGobData,
|
||||
CreatedAt: time.Now().Unix(),
|
||||
UpdatedAt: time.Now().Unix(),
|
||||
}
|
||||
|
||||
filename := paramsEntry.Filename
|
||||
|
||||
// 保存到数据库
|
||||
err = cdb.SaveParams(paramsEntry)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 渲染配置
|
||||
err = RenderConfig(filename, cdb)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("render config error: %w", err)
|
||||
return err
|
||||
}
|
||||
// 写入文件
|
||||
renderedEntry, err := cdb.GetRenderedConfig(filename)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("get rendered config error: %w", err)
|
||||
return err
|
||||
}
|
||||
err = os.WriteFile(cfg.Server.CaddyDir+"config.d/"+filename, renderedEntry.RenderedContent, 0644)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("write rendered config file error: %w", err)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
150
gen/render.go
Normal file
150
gen/render.go
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
package gen
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"caddydash/db"
|
||||
"encoding/gob"
|
||||
"fmt"
|
||||
"text/template"
|
||||
)
|
||||
|
||||
func RenderConfig(site string, cdb *db.ConfigDB) error {
|
||||
// 检索site config
|
||||
paramsEntry, err := cdb.GetParams(site)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var caddycfg CaddyUniConfig
|
||||
err = DecodeGobConfig(paramsEntry.ParamsGOB, &caddycfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 读取模板
|
||||
//tmplEntry, err := cdb.GetTemplate("reverse_proxy")
|
||||
tmplEntry, err := cdb.GetTemplate(caddycfg.Mode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rpTmpl := string(tmplEntry.Content)
|
||||
// 使用caddycfg渲染最终产物
|
||||
parsedTmpl, parseErr := template.New(tmplEntry.Filename).Parse(rpTmpl)
|
||||
if parseErr != nil {
|
||||
return fmt.Errorf("db: failed to parse template content for '%s': %w", tmplEntry.Filename, parseErr)
|
||||
}
|
||||
var renderedContentBuilder bytes.Buffer
|
||||
if err := parsedTmpl.Execute(&renderedContentBuilder, caddycfg); err != nil {
|
||||
return fmt.Errorf("db: failed to render template '%s': %w", tmplEntry.Filename, err)
|
||||
}
|
||||
|
||||
// 保存渲染结果
|
||||
renderedEntry := db.RenderedConfigEntry{
|
||||
Filename: caddycfg.DomainConfig.Domain, // 使用域名作为文件名
|
||||
RenderedContent: renderedContentBuilder.Bytes(),
|
||||
}
|
||||
|
||||
// 保存渲染产物
|
||||
if err := cdb.SaveRenderedConfig(renderedEntry); err != nil {
|
||||
return fmt.Errorf("db: failed to save rendered config for '%s': %w", caddycfg.DomainConfig.Domain, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// 把caddycfg内容转为GOB
|
||||
func EncodeGobConfig(caddycfg any) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
enc := gob.NewEncoder(&buf)
|
||||
if err := enc.Encode(caddycfg); err != nil {
|
||||
return nil, fmt.Errorf("db: failed to Encode struct to GOB: %w", err)
|
||||
}
|
||||
return buf.Bytes(), nil // 返回编码后的字节切片.
|
||||
}
|
||||
|
||||
func DecodeGobConfig(data []byte, tmplStruct any) error {
|
||||
buf := bytes.NewBuffer(data)
|
||||
dec := gob.NewDecoder(buf)
|
||||
if err := dec.Decode(tmplStruct); err != nil {
|
||||
return fmt.Errorf("db: failed to Decode GOB to struct: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/*
|
||||
func RenderConfig(site string, cdb *db.ConfigDB) error {
|
||||
// 检索site config
|
||||
paramsEntry, err := cdb.GetParams(site)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var caddycfg any
|
||||
var specificConfigType interface{}
|
||||
|
||||
if paramsEntry.TemplateType == "reverse_proxy" {
|
||||
specificConfigType = &CaddyReverseProxyConfig{}
|
||||
} else if paramsEntry.TemplateType == "file_server" {
|
||||
specificConfigType = &CaddyFileServerConfig{}
|
||||
} else {
|
||||
return fmt.Errorf("unknown template type: %s", paramsEntry.TemplateType)
|
||||
}
|
||||
|
||||
err = DecodeGobConfig(paramsEntry.ParamsGOB, specificConfigType)
|
||||
if err != nil {
|
||||
log.Printf("decode gob config error: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
caddycfg = specificConfigType
|
||||
|
||||
// 读取模板
|
||||
//tmplEntry, err := cdb.GetTemplate("reverse_proxy")
|
||||
tmplEntry, err := cdb.GetTemplate(paramsEntry.TemplateType)
|
||||
if err != nil {
|
||||
log.Printf("get template error: %v", err)
|
||||
tmplList, err := cdb.RangeTemplates()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("template list: %v, GetName %s", tmplList, paramsEntry.TemplateType)
|
||||
return err
|
||||
}
|
||||
|
||||
rpTmpl := string(tmplEntry.Content)
|
||||
// 使用caddycfg渲染最终产物
|
||||
parsedTmpl, parseErr := template.New(tmplEntry.Filename).Parse(rpTmpl)
|
||||
if parseErr != nil {
|
||||
return fmt.Errorf("db: failed to parse template content for '%s': %w", tmplEntry.Filename, parseErr)
|
||||
}
|
||||
var renderedContentBuilder bytes.Buffer
|
||||
if err := parsedTmpl.Execute(&renderedContentBuilder, caddycfg); err != nil {
|
||||
return fmt.Errorf("db: failed to render template '%s': %w", tmplEntry.Filename, err)
|
||||
}
|
||||
|
||||
// 类型断言获得domain
|
||||
var domain string
|
||||
switch cfg := caddycfg.(type) {
|
||||
case *CaddyReverseProxyConfig:
|
||||
domain = cfg.Domain
|
||||
case *CaddyFileServerConfig:
|
||||
domain = cfg.Domain
|
||||
default:
|
||||
return fmt.Errorf("unknown config type for domain extraction")
|
||||
}
|
||||
|
||||
// 保存渲染结果
|
||||
renderedEntry := db.RenderedConfigEntry{
|
||||
Filename: domain, // 使用域名作为文件名
|
||||
RenderedContent: renderedContentBuilder.Bytes(),
|
||||
}
|
||||
|
||||
// 保存渲染产物
|
||||
if err := cdb.SaveRenderedConfig(renderedEntry); err != nil {
|
||||
return fmt.Errorf("db: failed to save rendered config for '%s': %w", domain, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
*/
|
||||
79
gen/struct.go
Normal file
79
gen/struct.go
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
package gen
|
||||
|
||||
/*
|
||||
type CaddyReverseProxyConfig struct {
|
||||
Domain string // 域名; 例如 example.com
|
||||
ReverseProxy string // 反向代理目标; 例如 127.0.0.1:8080 (这里简化为单个目标)
|
||||
Headers []string // 自定义响应Header
|
||||
HeadersUp []string // 自定义请求头列表; 例如 ["XXX0 XX", "XXX1 XXX"]
|
||||
EnableLog bool // 是否导入 log 指令
|
||||
LogDomain string // log 指令的域名参数
|
||||
EnableErrorPage bool // 是否导入 error_page 指令
|
||||
EnableEncode bool // 是否导入 encode 指令
|
||||
}
|
||||
|
||||
type CaddyFileServerConfig struct {
|
||||
Domain string // 域名; 例如 example.com
|
||||
FileDirPath string // 文件目录
|
||||
EnableBrowser bool // 是否导入 browse 指令
|
||||
Headers []string //
|
||||
EnableLog bool // 是否导入 log 指令
|
||||
LogDomain string // log 指令的域名参数
|
||||
EnableErrorPage bool // 是否导入 error_page 指令
|
||||
EnableEncode bool // 是否导入 encode 指令
|
||||
}
|
||||
*/
|
||||
|
||||
func HeadersMapToHeadersUp(headers map[string][]string) []string {
|
||||
var headersUp []string
|
||||
for key, values := range headers {
|
||||
for _, value := range values {
|
||||
headersUp = append(headersUp, key+" "+value)
|
||||
}
|
||||
}
|
||||
return headersUp
|
||||
}
|
||||
|
||||
type CaddyUniConfig struct {
|
||||
DomainConfig CaddyUniDomainConfig `json:"domain_config"`
|
||||
Mode string `json:"mode"`
|
||||
Upstream CaddyUniUpstreamConfig `json:"upstream_config"`
|
||||
FileServer CaddyUniFileServerConfig `json:"file_server_config"`
|
||||
Headers map[string][]string `json:"headers"`
|
||||
Log CaddyUniLogConfig `json:"log_config"`
|
||||
ErrorPage CaddyUniErrorPageConfig `json:"error_page_config"`
|
||||
Encode CaddyUniEncodeConfig `json:"encode_config"`
|
||||
}
|
||||
|
||||
type CaddyUniDomainConfig struct {
|
||||
Domain string `json:"domain"`
|
||||
MutiDomains bool `json:"muti_domains"`
|
||||
Domains []string `json:"domains"`
|
||||
}
|
||||
|
||||
type CaddyUniUpstreamConfig struct {
|
||||
EnableUpStream bool `json:"enable_upstream"`
|
||||
UpStream string `json:"upstream"`
|
||||
MutiUpStreams bool `json:"muti_upstream"`
|
||||
UpStreams []string `json:"upstream_servers"`
|
||||
UpStreamHeaders map[string][]string `json:"upstream_headers"`
|
||||
}
|
||||
|
||||
type CaddyUniFileServerConfig struct {
|
||||
EnableFileServer bool `json:"enable_file_server"`
|
||||
FileDirPath string `json:"file_dir_path"`
|
||||
EnableBrowser bool `json:"enable_browser"`
|
||||
}
|
||||
|
||||
type CaddyUniLogConfig struct {
|
||||
EnableLog bool `json:"enable_log"`
|
||||
LogDomain string `json:"log_domain"`
|
||||
}
|
||||
|
||||
type CaddyUniErrorPageConfig struct {
|
||||
EnableErrorPage bool `json:"enable_error_page"`
|
||||
}
|
||||
|
||||
type CaddyUniEncodeConfig struct {
|
||||
EnableEncode bool `json:"enable_encode"`
|
||||
}
|
||||
35
go.mod
Normal file
35
go.mod
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
module caddydash
|
||||
|
||||
go 1.24.4
|
||||
|
||||
require (
|
||||
github.com/BurntSushi/toml v1.5.0
|
||||
github.com/fenthope/record v0.0.3
|
||||
github.com/fenthope/sessions v0.0.1
|
||||
github.com/infinite-iroha/touka v0.2.2
|
||||
golang.org/x/crypto v0.37.0
|
||||
modernc.org/sqlite v1.38.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/WJQSERVER-STUDIO/go-utils/copyb v0.0.4 // indirect
|
||||
github.com/WJQSERVER-STUDIO/httpc v0.7.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/fenthope/reco v0.0.3 // indirect
|
||||
github.com/go-json-experiment/json v0.0.0-20250517221953-25912455fbc8 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/context v1.1.2 // indirect
|
||||
github.com/gorilla/securecookie v1.1.2 // indirect
|
||||
github.com/gorilla/sessions v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476 // indirect
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
modernc.org/libc v1.65.10 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
|
||||
replace github.com/infinite-iroha/touka => /data/github/WJQSERVER/touka
|
||||
73
go.sum
Normal file
73
go.sum
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
|
||||
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/WJQSERVER-STUDIO/go-utils/copyb v0.0.4 h1:JLtFd00AdFg/TP+dtvIzLkdHwKUGPOAijN1sMtEYoFg=
|
||||
github.com/WJQSERVER-STUDIO/go-utils/copyb v0.0.4/go.mod h1:FZ6XE+4TKy4MOfX1xWKe6Rwsg0ucYFCdNh1KLvyKTfc=
|
||||
github.com/WJQSERVER-STUDIO/httpc v0.7.0 h1:iHhqlxppJBjlmvsIjvLZKRbWXqSdbeSGGofjHGmqGJc=
|
||||
github.com/WJQSERVER-STUDIO/httpc v0.7.0/go.mod h1:M7KNUZjjhCkzzcg9lBPs9YfkImI+7vqjAyjdA19+joE=
|
||||
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/fenthope/reco v0.0.3 h1:RmnQ0D9a8PWtwOODawitTe4BztTnS9wYwrDbipISNq4=
|
||||
github.com/fenthope/reco v0.0.3/go.mod h1:mDkGLHte5udWTIcjQTxrABRcf56SSdxBOCLgrRDwI/Y=
|
||||
github.com/fenthope/record v0.0.3 h1:v5urgs5LAkLMlljAT/MjW8fWuRHXPnAraTem5ui7rm4=
|
||||
github.com/fenthope/record v0.0.3/go.mod h1:KFEkSc4TDZ3QIhP/wglD32uYVA6X1OUcripiao1DEE4=
|
||||
github.com/fenthope/sessions v0.0.1 h1:Dw4mY2yvSuyTqW+1CrojdO0gzv2gfsNsavRZcaAl7LM=
|
||||
github.com/fenthope/sessions v0.0.1/go.mod h1:4aI2BN1jb8MF1qTzXb4QX+GnAzmJWb57S3lDKxUyK8A=
|
||||
github.com/go-json-experiment/json v0.0.0-20250517221953-25912455fbc8 h1:o8UqXPI6SVwQt04RGsqKp3qqmbOfTNMqDrWsc4O47kk=
|
||||
github.com/go-json-experiment/json v0.0.0-20250517221953-25912455fbc8/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M=
|
||||
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
||||
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
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.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o=
|
||||
github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM=
|
||||
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
|
||||
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
|
||||
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
|
||||
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
|
||||
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/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
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/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
|
||||
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
|
||||
golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476 h1:bsqhLWFR6G6xiQcb+JoGqdKdRU6WzPWmK8E0jxTjzo4=
|
||||
golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
|
||||
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
|
||||
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
|
||||
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
|
||||
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
|
||||
modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s=
|
||||
modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
|
||||
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
|
||||
modernc.org/fileutil v1.3.3 h1:3qaU+7f7xxTUmvU1pJTZiDLAIoJVdUSSauJNHg9yXoA=
|
||||
modernc.org/fileutil v1.3.3/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/libc v1.65.10 h1:ZwEk8+jhW7qBjHIT+wd0d9VjitRyQef9BnzlzGwMODc=
|
||||
modernc.org/libc v1.65.10/go.mod h1:StFvYpx7i/mXtBAfVOjaU0PWZOvIRoZSgXhrwXzr8Po=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.38.0 h1:+4OrfPQ8pxHKuWG4md1JpR/EYAh3Md7TdejuuzE7EUI=
|
||||
modernc.org/sqlite v1.38.0/go.mod h1:1Bj+yES4SVvBZ4cBOpVZ6QgesMCKpJZDq0nxYzOpmNE=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
145
main.go
Normal file
145
main.go
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"caddydash/api"
|
||||
"caddydash/apic"
|
||||
"caddydash/config"
|
||||
"caddydash/db"
|
||||
"caddydash/gen"
|
||||
"caddydash/user"
|
||||
"crypto/rand"
|
||||
"encoding/gob"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/fenthope/record"
|
||||
"github.com/fenthope/sessions"
|
||||
"github.com/fenthope/sessions/cookie"
|
||||
"github.com/infinite-iroha/touka"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
var (
|
||||
cfg *config.Config
|
||||
cfgfile string
|
||||
cdb *db.ConfigDB
|
||||
sessionKey []byte
|
||||
)
|
||||
|
||||
func init() {
|
||||
parseFlags()
|
||||
loadConfig()
|
||||
loadDatabase(cfg.DB.Filepath)
|
||||
loadtmpltoDB(cfg.Tmpl.Path, cdb)
|
||||
loadAdminStatus(cdb)
|
||||
initSessionKey()
|
||||
}
|
||||
|
||||
func parseFlags() {
|
||||
//posix
|
||||
flag.StringVar(&cfgfile, "c", "./config.toml", "Path to the configuration file")
|
||||
flag.Parse()
|
||||
}
|
||||
|
||||
func loadConfig() {
|
||||
var err error
|
||||
cfg, err = config.LoadConfig(cfgfile)
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to load config: %v\n", err)
|
||||
// 如果配置文件加载失败,也显示帮助信息并退出
|
||||
flag.Usage()
|
||||
os.Exit(1)
|
||||
}
|
||||
if cfg != nil && cfg.Server.Debug { // 确保 cfg 不为 nil
|
||||
fmt.Println("Config File Path: ", cfgfile)
|
||||
fmt.Printf("Loaded config: %v\n", cfg)
|
||||
}
|
||||
fmt.Printf("Loaded config: %v\n", cfg)
|
||||
}
|
||||
|
||||
func loadDatabase(filepath string) {
|
||||
var err error
|
||||
cdb, err = db.InitDB(filepath)
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to initialize database: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func loadAdminStatus(cdb *db.ConfigDB) {
|
||||
err := user.InitAdminUserStatus(cdb)
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to initialize admin user status: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func loadtmpltoDB(path string, cdb *db.ConfigDB) {
|
||||
err := gen.ReadTmplToDB(path, cdb)
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to load templates: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
err = gen.Add80SiteConfig(cfg, cdb)
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to add :80 site config: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func initSessionKey() {
|
||||
// crypto 生成随机
|
||||
sessionKey = make([]byte, 32)
|
||||
_, err := rand.Read(sessionKey)
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to generate session key: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
defer cdb.CloseDB()
|
||||
|
||||
r := touka.Default()
|
||||
r.Use(record.Middleware())
|
||||
|
||||
store := cookie.NewStore(sessionKey)
|
||||
store.Options(sessions.Options{
|
||||
Path: "/",
|
||||
MaxAge: 10800, // 3 hours
|
||||
HttpOnly: true,
|
||||
})
|
||||
r.Use(sessions.Sessions("mysession", store))
|
||||
// 应用 session 中间件
|
||||
r.Use(api.SessionMiddleware(cdb))
|
||||
|
||||
v0 := r.Group("/v0")
|
||||
api.ApiGroup(v0, cdb, cfg)
|
||||
|
||||
gob.Register(map[string]interface{}{})
|
||||
gob.Register(time.Time{})
|
||||
gob.Register(gen.CaddyUniConfig{})
|
||||
|
||||
frontendFS := os.DirFS("frontend")
|
||||
r.SetUnMatchFS(http.FS(frontendFS))
|
||||
|
||||
// 打印定义的路由
|
||||
fmt.Println("Registered Routes:")
|
||||
for _, info := range r.GetRouterInfo() {
|
||||
fmt.Printf(" Method: %-7s Path: %-25s Handler: %-40s Group: %s\n", info.Method, info.Path, info.Handler, info.Group)
|
||||
}
|
||||
|
||||
go func() {
|
||||
err := apic.RunCaddy(cfg)
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to start caddy: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
|
||||
addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port)
|
||||
r.Run(addr)
|
||||
}
|
||||
16
tmpl/file_server
Normal file
16
tmpl/file_server
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{{.Domain}} {
|
||||
root * {{.FileDirPath}}
|
||||
file_server{{if .EnableBrowser}} browse{{end}}
|
||||
{{- range .Headers}}
|
||||
header {{.}}
|
||||
{{- end}}
|
||||
{{- if .EnableLog}}
|
||||
import log {{.LogDomain}}
|
||||
{{- end}}
|
||||
{{- if .EnableErrorPage}}
|
||||
import error_page
|
||||
{{- end}}
|
||||
{{- if .EnableEncode}}
|
||||
import encode
|
||||
{{- end}}
|
||||
}
|
||||
20
tmpl/reverse_proxy
Normal file
20
tmpl/reverse_proxy
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{{.Domain}} {
|
||||
reverse_proxy {
|
||||
to {{.ReverseProxy}}
|
||||
{{- range .HeadersUp}}
|
||||
header_up {{.}}
|
||||
{{- end}}
|
||||
}
|
||||
{{- range .HeadersUp}}
|
||||
header {{.}}
|
||||
{{- end}}
|
||||
{{- if .EnableLog}}
|
||||
import log {{.LogDomain}}
|
||||
{{- end}}
|
||||
{{- if .EnableErrorPage}}
|
||||
import error_page
|
||||
{{- end}}
|
||||
{{- if .EnableEncode}}
|
||||
import encode
|
||||
{{- end}}
|
||||
}
|
||||
40
tmpl/uni
Normal file
40
tmpl/uni
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
{{- if .DomainConfig.MutiDomains -}}
|
||||
{{- range .DomainConfig.Domains}}{{.}} {{end -}}
|
||||
{{- else -}}
|
||||
{{- .DomainConfig.Domain -}}
|
||||
{{- end -}} {
|
||||
|
||||
{{- if .Upsteam.EnableUpStream}}
|
||||
reverse_proxy {
|
||||
to{{if .Upsteam.MutiUpStreams}}{{range .Upsteam.UpStreams}} {{.}}{{end}}{{else}} {{.Upsteam.UpStream}}{{end}}
|
||||
|
||||
{{- range $key, $values := .Upsteam.UpStreamHeaders}}
|
||||
{{- range $values}}
|
||||
header_up {{$key}} "{{.}}"
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
}
|
||||
{{- else if .FileServer.EnableFileServer}}
|
||||
root * {{.FileServer.FileDirPath}}
|
||||
|
||||
file_server{{if .FileServer.EnableBrowser}} browse{{end}}
|
||||
{{- end}}
|
||||
|
||||
{{- range $key, $values := .Headers}}
|
||||
{{- range $values}}
|
||||
header {{$key}} "{{.}}"
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
|
||||
{{- if .Log.EnableLog}}
|
||||
import log {{.Log.LogDomain}}
|
||||
{{- end}}
|
||||
|
||||
{{- if .ErrorPage.EnableErrorPage}}
|
||||
import error_page
|
||||
{{- end}}
|
||||
|
||||
{{- if .Encode.EnableEncode}}
|
||||
import encode
|
||||
{{- end}}
|
||||
}
|
||||
42
user/check.go
Normal file
42
user/check.go
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
package user
|
||||
|
||||
import (
|
||||
"caddydash/db"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// 判断是否可以登陆
|
||||
func CheckLogin(username, password string, cdb *db.ConfigDB) (bool, error) {
|
||||
// 判断数据库内是否存在username
|
||||
userExist, err := cdb.IsUserExists(username)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !userExist {
|
||||
return false, nil
|
||||
}
|
||||
passwordb, err := cdb.GetPasswordByUsername(username)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
// 校验密码
|
||||
check, err := checkPasswordHash(password, passwordb)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return check, nil
|
||||
}
|
||||
|
||||
func IsAdminInit() bool {
|
||||
return userStatus.IsUserInitialized()
|
||||
}
|
||||
|
||||
// 校验密码, 避免时序攻击问题
|
||||
func checkPasswordHash(password, hash string) (bool, error) {
|
||||
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
52
user/init.go
Normal file
52
user/init.go
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
package user
|
||||
|
||||
import (
|
||||
"caddydash/db"
|
||||
"fmt"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func InitAdminUser(username string, password string, cdb *db.ConfigDB) error {
|
||||
hasUser, err := cdb.HasAnyUser()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check if any user exists: %w", err)
|
||||
}
|
||||
if hasUser {
|
||||
userStatus.SetInitialized(true)
|
||||
return nil
|
||||
}
|
||||
hashedPassword, err := hashPassword(password)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to hash password: %w", err)
|
||||
}
|
||||
err = cdb.AddUser(username, hashedPassword)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to add admin user: %w", err)
|
||||
}
|
||||
userStatus.SetInitialized(true)
|
||||
return nil
|
||||
}
|
||||
|
||||
// bcrypt加密password串
|
||||
func hashPassword(password string) (string, error) {
|
||||
bytes, err := bcrypt.GenerateFromPassword([]byte(password), 11)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(bytes), err
|
||||
}
|
||||
|
||||
func InitAdminUserStatus(cdb *db.ConfigDB) error {
|
||||
hasUser, err := cdb.HasAnyUser()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check if any user exists: %w", err)
|
||||
}
|
||||
if hasUser {
|
||||
userStatus.SetInitialized(true)
|
||||
return nil
|
||||
} else {
|
||||
userStatus.SetInitialized(false)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
22
user/status.go
Normal file
22
user/status.go
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
package user
|
||||
|
||||
import "sync"
|
||||
|
||||
type UserStatus struct {
|
||||
IsInitialized bool
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (s *UserStatus) SetInitialized(initialized bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.IsInitialized = initialized
|
||||
}
|
||||
|
||||
func (s *UserStatus) IsUserInitialized() bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.IsInitialized
|
||||
}
|
||||
|
||||
var userStatus UserStatus
|
||||
Loading…
Add table
Add a link
Reference in a new issue