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