From b10790c21242810796dbbec4d4a3b4a4719ed4d7 Mon Sep 17 00:00:00 2001 From: wjqserver <114663932+WJQSERVER@users.noreply.github.com> Date: Fri, 20 Jun 2025 16:33:27 +0800 Subject: [PATCH] init --- .gitignore | 6 + Caddyfile | 90 +++++++ LICENSE | 373 ++++++++++++++++++++++++++ README.md | 1 + api/api.go | 92 +++++++ api/auth.go | 127 +++++++++ api/config.go | 225 ++++++++++++++++ api/files.go | 40 +++ apic/run.go | 170 ++++++++++++ config.toml | 11 + config/config.go | 110 ++++++++ config/config.toml | 11 + db/db.go | 157 +++++++++++ db/operation.go | 504 +++++++++++++++++++++++++++++++++++ db/struct.go | 40 +++ deploy/install.sh | 0 frontend/css/style.css | 411 ++++++++++++++++++++++++++++ frontend/index.html | 148 ++++++++++ frontend/init.html | 50 ++++ frontend/js/api.js | 26 ++ frontend/js/app.js | 182 +++++++++++++ frontend/js/init.js | 150 +++++++++++ frontend/js/login.js | 89 +++++++ frontend/js/notifications.js | 57 ++++ frontend/js/state.js | 7 + frontend/js/theme.js | 26 ++ frontend/js/ui.js | 176 ++++++++++++ frontend/login.html | 44 +++ gen/init.go | 152 +++++++++++ gen/render.go | 150 +++++++++++ gen/struct.go | 79 ++++++ go.mod | 35 +++ go.sum | 73 +++++ main.go | 145 ++++++++++ tmpl/file_server | 16 ++ tmpl/reverse_proxy | 20 ++ tmpl/uni | 40 +++ user/check.go | 42 +++ user/init.go | 52 ++++ user/status.go | 22 ++ 40 files changed, 4149 insertions(+) create mode 100644 .gitignore create mode 100644 Caddyfile create mode 100644 LICENSE create mode 100644 README.md create mode 100644 api/api.go create mode 100644 api/auth.go create mode 100644 api/config.go create mode 100644 api/files.go create mode 100644 apic/run.go create mode 100644 config.toml create mode 100644 config/config.go create mode 100644 config/config.toml create mode 100644 db/db.go create mode 100644 db/operation.go create mode 100644 db/struct.go create mode 100644 deploy/install.sh create mode 100644 frontend/css/style.css create mode 100644 frontend/index.html create mode 100644 frontend/init.html create mode 100644 frontend/js/api.js create mode 100644 frontend/js/app.js create mode 100644 frontend/js/init.js create mode 100644 frontend/js/login.js create mode 100644 frontend/js/notifications.js create mode 100644 frontend/js/state.js create mode 100644 frontend/js/theme.js create mode 100644 frontend/js/ui.js create mode 100644 frontend/login.html create mode 100644 gen/init.go create mode 100644 gen/render.go create mode 100644 gen/struct.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 tmpl/file_server create mode 100644 tmpl/reverse_proxy create mode 100644 tmpl/uni create mode 100644 user/check.go create mode 100644 user/init.go create mode 100644 user/status.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5cc1568 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +pages +log +*.db +caddy +caddydash +config.d \ No newline at end of file diff --git a/Caddyfile b/Caddyfile new file mode 100644 index 0000000..490851a --- /dev/null +++ b/Caddyfile @@ -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/* \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..dbdb0fa --- /dev/null +++ b/LICENSE @@ -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. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..c5e0998 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# caddydash \ No newline at end of file diff --git a/api/api.go b/api/api.go new file mode 100644 index 0000000..b9b7d7f --- /dev/null +++ b/api/api.go @@ -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) + } +} diff --git a/api/auth.go b/api/auth.go new file mode 100644 index 0000000..8ffc8b6 --- /dev/null +++ b/api/auth.go @@ -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") +} diff --git a/api/config.go b/api/config.go new file mode 100644 index 0000000..03d5e6f --- /dev/null +++ b/api/config.go @@ -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 + } + } +} +*/ diff --git a/api/files.go b/api/files.go new file mode 100644 index 0000000..e62e531 --- /dev/null +++ b/api/files.go @@ -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) + } +} diff --git a/apic/run.go b/apic/run.go new file mode 100644 index 0000000..4541a38 --- /dev/null +++ b/apic/run.go @@ -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"}) + + } +} diff --git a/config.toml b/config.toml new file mode 100644 index 0000000..0bb7059 --- /dev/null +++ b/config.toml @@ -0,0 +1,11 @@ +[Server] + port = 81 + host = "0.0.0.0" + debug = false + caddyDir = "./" + +[Tmpl] + path = "./tmpl" + +[database] + filepath = "caddydash.db" diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..844e294 --- /dev/null +++ b/config/config.go @@ -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", + }, + } +} diff --git a/config/config.toml b/config/config.toml new file mode 100644 index 0000000..86f84b9 --- /dev/null +++ b/config/config.toml @@ -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" \ No newline at end of file diff --git a/db/db.go b/db/db.go new file mode 100644 index 0000000..6015c89 --- /dev/null +++ b/db/db.go @@ -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 +} diff --git a/db/operation.go b/db/operation.go new file mode 100644 index 0000000..a148b2f --- /dev/null +++ b/db/operation.go @@ -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 +} diff --git a/db/struct.go b/db/struct.go new file mode 100644 index 0000000..15c63bb --- /dev/null +++ b/db/struct.go @@ -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"` +} +*/ diff --git a/deploy/install.sh b/deploy/install.sh new file mode 100644 index 0000000..e69de29 diff --git a/frontend/css/style.css b/frontend/css/style.css new file mode 100644 index 0000000..52276c7 --- /dev/null +++ b/frontend/css/style.css @@ -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; } +} \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..34e9fe6 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,148 @@ + + +
+ + +请创建您的管理员账户以完成首次设置
+还没有任何配置,请创建一个。
'; + return; + } + filenames.forEach(filename => { + const item = document.createElement('li'); + item.className = 'config-item'; + item.dataset.filename = filename; + item.innerHTML = `${filename}`; + DOMElements.configListContainer.appendChild(item); + }); +} + +export function createCustomSelect(containerId, options, onSelect) { + const container = document.getElementById(containerId); + container.innerHTML = ``; + 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 = ` + + + `; + 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 = `${text}`; + 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); +} \ No newline at end of file diff --git a/frontend/login.html b/frontend/login.html new file mode 100644 index 0000000..436dc83 --- /dev/null +++ b/frontend/login.html @@ -0,0 +1,44 @@ + + + + + +请输入您的凭证以继续
+