mirror of
https://github.com/Pacerino/CaddyProxyManager.git
synced 2026-03-31 06:34:15 -04:00
Initial Upload
This commit is contained in:
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
frontend/node_modules
|
||||
frontend/.pnp
|
||||
frontend/.pnp.js
|
||||
|
||||
# testing
|
||||
frontend/coverage
|
||||
|
||||
# production
|
||||
frontend/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
frontend/npm-debug.log*
|
||||
frontend/yarn-debug.log*
|
||||
frontend/yarn-error.log*
|
||||
backend/embed/assets
|
||||
38
README.md
Normal file
38
README.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# Caddy Proxy Manager - CPM
|
||||
|
||||
## Shoutout
|
||||
Much was copied from the [original Nginx Proxy Manager](https://github.com/NginxProxyManager/nginx-proxy-manager) and implemented for Caddy. The complete basic idea therefore goes back to the repo of [jc21](https://github.com/jc21) and so the honor goes to him! So please have a look at his repo and his Nginx Proxy Manager too!
|
||||
|
||||
<br>
|
||||
|
||||
## Idea
|
||||
This project tries to implement the basic idea of the Nginx Proxy Manager for Caddy and thus provide a web interface for Caddy.
|
||||
|
||||
Currently the version is completely unstable and untidy.
|
||||
|
||||
Caddy is installed normally on the system and integrates further Caddyfiles via Caddyfile. So a hotreload and caddyfiles per host is possible.
|
||||
|
||||
|
||||
|
||||
## Current features
|
||||
- Adding hosts with multiple domains/upstreams
|
||||
- Delete hosts
|
||||
|
||||
## Planned features
|
||||
- Login with third-party Services like Authelia, Keycloak etc.
|
||||
- Editing hosts
|
||||
- Logview
|
||||
- Manage Plugins
|
||||
|
||||
## FAQ
|
||||
|
||||
> Why don't you use the API from Caddy itself?
|
||||
|
||||
The API of Caddy is not documented and quite complicated for a simple web interface. Many features like HSTS, HTTP/2, SSL etc. are already included in Caddy and don't need to be specially configured.
|
||||
|
||||
> How can I use CPM?
|
||||
|
||||
You have to compile CPM yourself. The frontend is based on ReactJS with Typescript, the compiled frontend must then be added under ``backend/assets``. The backend is written in GoLang and can be easily compiled using ``go build cmd/main.go``.
|
||||
|
||||
## Contribution
|
||||
If you want to help with the development pull requests etc. are welcome!
|
||||
38
backend/cmd/main.go
Normal file
38
backend/cmd/main.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"github.com/Pacerino/cpm/internal/api"
|
||||
"github.com/Pacerino/cpm/internal/config"
|
||||
"github.com/Pacerino/cpm/internal/database"
|
||||
"github.com/Pacerino/cpm/internal/jobqueue"
|
||||
"github.com/Pacerino/cpm/internal/logger"
|
||||
)
|
||||
|
||||
var (
|
||||
version = "3.0.0"
|
||||
commit = "abcdefgh"
|
||||
)
|
||||
|
||||
func main() {
|
||||
config.Init(&version, &commit)
|
||||
database.NewDB()
|
||||
jobqueue.Start()
|
||||
|
||||
// HTTP Server
|
||||
api.StartServer()
|
||||
|
||||
// Clean Quit
|
||||
irqchan := make(chan os.Signal, 1)
|
||||
signal.Notify(irqchan, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
for irq := range irqchan {
|
||||
if irq == syscall.SIGINT || irq == syscall.SIGTERM {
|
||||
logger.Info("Got ", irq, " shutting server down ...")
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
15
backend/embed/caddy/host.hbs
Normal file
15
backend/embed/caddy/host.hbs
Normal file
@@ -0,0 +1,15 @@
|
||||
{{host.domains}} {
|
||||
{{#if host.upstreams}}
|
||||
{{#if host.matcher}}
|
||||
reverse_proxy {{host.matcher}} {{#each host.upstreams}} {{backend}} {{/each}}
|
||||
{{else}}
|
||||
reverse_proxy {{#each host.upstreams}} {{backend}} {{/each}}
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
|
||||
{{#if LogPath}}
|
||||
log {
|
||||
output file {{LogPath}}
|
||||
}
|
||||
{{/if}}
|
||||
}
|
||||
13
backend/embed/main.go
Normal file
13
backend/embed/main.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package embed
|
||||
|
||||
import "embed"
|
||||
|
||||
// Frontend Files served by this app
|
||||
//
|
||||
//go:embed all:assets/**
|
||||
var Assets embed.FS
|
||||
|
||||
// CaddyFiles hold all template for caddy
|
||||
//
|
||||
//go:embed caddy
|
||||
var CaddyFiles embed.FS
|
||||
34
backend/go.mod
Normal file
34
backend/go.mod
Normal file
@@ -0,0 +1,34 @@
|
||||
module github.com/Pacerino/cpm
|
||||
|
||||
go 1.19
|
||||
|
||||
require (
|
||||
github.com/aymerick/raymond v2.0.2+incompatible
|
||||
github.com/go-playground/validator/v10 v10.11.1
|
||||
gorm.io/driver/sqlite v1.3.6
|
||||
gorm.io/gorm v1.23.9
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/go-playground/locales v0.14.0 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.0 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/leodido/go-urn v1.2.1 // indirect
|
||||
github.com/mattn/go-colorable v0.1.9 // indirect
|
||||
github.com/mattn/go-isatty v0.0.14 // indirect
|
||||
github.com/qri-io/jsonpointer v0.1.1 // indirect
|
||||
golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3 // indirect
|
||||
golang.org/x/text v0.3.7 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/fatih/color v1.13.0
|
||||
github.com/go-chi/chi/v5 v5.0.7
|
||||
github.com/go-chi/cors v1.2.1
|
||||
github.com/mattn/go-sqlite3 v1.14.15 // indirect
|
||||
github.com/qri-io/jsonschema v0.2.1
|
||||
github.com/vrischmann/envconfig v1.3.0
|
||||
golang.org/x/sys v0.0.0-20220915200043-7b5979e65e41 // indirect
|
||||
)
|
||||
93
backend/go.sum
Normal file
93
backend/go.sum
Normal file
@@ -0,0 +1,93 @@
|
||||
github.com/aymerick/raymond v2.0.2+incompatible h1:VEp3GpgdAnv9B2GFyTvqgcKvY+mfKMjPOA3SbKLtnU0=
|
||||
github.com/aymerick/raymond v2.0.2+incompatible/go.mod h1:osfaiScAUVup+UC9Nfq76eWqDhXlp+4UYaA8uhTBO6g=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w=
|
||||
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
|
||||
github.com/go-chi/chi/v5 v5.0.7 h1:rDTPXLDHGATaeHvVlLcR4Qe0zftYethFucbjVQ1PxU8=
|
||||
github.com/go-chi/chi/v5 v5.0.7/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
||||
github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
|
||||
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
||||
github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A=
|
||||
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.0 h1:u50s323jtVGugKlcYeyzC0etD1HifMjqmJqb8WugfUU=
|
||||
github.com/go-playground/locales v0.14.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa7l++s0DH+LDiLs=
|
||||
github.com/go-playground/universal-translator v0.18.0 h1:82dyy6p4OuJq4/CByFNOn/jYrnRPArHwAcmLoJZxyho=
|
||||
github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl+lu/H90nyDXpg0fqeB/AQUGNTVA=
|
||||
github.com/go-playground/validator/v10 v10.11.1 h1:prmOlTVv+YjZjmRmNSF3VmspqJIxJWXmqUsHwfTRRkQ=
|
||||
github.com/go-playground/validator/v10 v10.11.1/go.mod h1:i+3WkQ1FvaUjjxh1kSvIA4dMGDBiPU55YFDl0WbKdWU=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w=
|
||||
github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY=
|
||||
github.com/mattn/go-colorable v0.1.9 h1:sqDoxXbdeALODt0DAeJCVp38ps9ZogZEAXjus69YV3U=
|
||||
github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
|
||||
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
|
||||
github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
|
||||
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
|
||||
github.com/mattn/go-sqlite3 v1.14.12/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
|
||||
github.com/mattn/go-sqlite3 v1.14.15 h1:vfoHhTN1af61xCRSWzFIWzx2YskyMTwHLrExkBOjvxI=
|
||||
github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/qri-io/jsonpointer v0.1.1 h1:prVZBZLL6TW5vsSB9fFHFAMBLI4b0ri5vribQlTJiBA=
|
||||
github.com/qri-io/jsonpointer v0.1.1/go.mod h1:DnJPaYgiKu56EuDp8TU5wFLdZIcAnb/uH9v37ZaMV64=
|
||||
github.com/qri-io/jsonschema v0.2.1 h1:NNFoKms+kut6ABPf6xiKNM5214jzxAhDBrPHCJ97Wg0=
|
||||
github.com/qri-io/jsonschema v0.2.1/go.mod h1:g7DPkiOsK1xv6T/Ao5scXRkd+yTFygcANPBaaqW+VrI=
|
||||
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
||||
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
|
||||
github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ=
|
||||
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/vrischmann/envconfig v1.3.0 h1:4XIvQTXznxmWMnjouj0ST5lFo/WAYf5Exgl3x82crEk=
|
||||
github.com/vrischmann/envconfig v1.3.0/go.mod h1:bbvxFYJdRSpXrhS63mBFtKJzkDiNkyArOLXtY6q0kuI=
|
||||
golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3 h1:0es+/5331RGQPcXlMfP+WrnIIS6dNnNRe0WB02W0F4M=
|
||||
golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220915200043-7b5979e65e41 h1:ohgcoMbSofXygzo6AD2I1kz3BFmW1QArPYTtwEM3UXc=
|
||||
golang.org/x/sys v0.0.0-20220915200043-7b5979e65e41/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/sqlite v1.3.6 h1:Fi8xNYCUplOqWiPa3/GuCeowRNBRGTf62DEmhMDHeQQ=
|
||||
gorm.io/driver/sqlite v1.3.6/go.mod h1:Sg1/pvnKtbQ7jLXxfZa+jSHvoX8hoZA8cn4xllOMTgE=
|
||||
gorm.io/gorm v1.23.4/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk=
|
||||
gorm.io/gorm v1.23.9 h1:NSHG021i+MCznokeXR3udGaNyFyBQJW8MbjrJMVCfGw=
|
||||
gorm.io/gorm v1.23.9/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA=
|
||||
19
backend/internal/api/context/context.go
Normal file
19
backend/internal/api/context/context.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package context
|
||||
|
||||
var (
|
||||
// BodyCtxKey is the name of the Body value on the context
|
||||
BodyCtxKey = &contextKey{"Body"}
|
||||
// PrettyPrintCtxKey is the name of the pretty print context
|
||||
PrettyPrintCtxKey = &contextKey{"Pretty"}
|
||||
)
|
||||
|
||||
// contextKey is a value for use with context.WithValue. It's used as
|
||||
// a pointer so it fits in an interface{} without allocation. This technique
|
||||
// for defining context keys was copied from Go 1.7's new use of context in net/http.
|
||||
type contextKey struct {
|
||||
name string
|
||||
}
|
||||
|
||||
func (k *contextKey) String() string {
|
||||
return "context value: " + k.name
|
||||
}
|
||||
19
backend/internal/api/handler/handler.go
Normal file
19
backend/internal/api/handler/handler.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/Pacerino/cpm/internal/database"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
DB *gorm.DB
|
||||
}
|
||||
|
||||
func NewHandler() *Handler {
|
||||
db := database.GetInstance()
|
||||
handler := &Handler{
|
||||
DB: db,
|
||||
}
|
||||
return handler
|
||||
}
|
||||
29
backend/internal/api/handler/helpers.go
Normal file
29
backend/internal/api/handler/helpers.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func getURLParamInt(r *http.Request, varName string) (int, error) {
|
||||
required := true
|
||||
defaultValue := 0
|
||||
paramStr := chi.URLParam(r, varName)
|
||||
var err error
|
||||
var paramInt int
|
||||
|
||||
if paramStr == "" && required {
|
||||
return 0, fmt.Errorf("%v was not supplied in the request", varName)
|
||||
} else if paramStr == "" {
|
||||
return defaultValue, nil
|
||||
}
|
||||
|
||||
if paramInt, err = strconv.Atoi(paramStr); err != nil {
|
||||
return 0, fmt.Errorf("%v is not a valid number", varName)
|
||||
}
|
||||
|
||||
return paramInt, nil
|
||||
}
|
||||
152
backend/internal/api/handler/hosts.go
Normal file
152
backend/internal/api/handler/hosts.go
Normal file
@@ -0,0 +1,152 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
h "github.com/Pacerino/cpm/internal/api/http"
|
||||
"github.com/Pacerino/cpm/internal/caddy"
|
||||
"github.com/Pacerino/cpm/internal/database"
|
||||
"github.com/Pacerino/cpm/internal/jobqueue"
|
||||
|
||||
"github.com/go-playground/validator/v10"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// GetHosts will return a list of Hosts
|
||||
// Route: GET /hosts
|
||||
func (s Handler) GetHosts() func(http.ResponseWriter, *http.Request) {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var hosts []database.Host
|
||||
if err := s.DB.Preload("Upstreams").Find(&hosts).Error; err != nil {
|
||||
h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil)
|
||||
}
|
||||
h.ResultResponseJSON(w, r, http.StatusOK, hosts)
|
||||
}
|
||||
}
|
||||
|
||||
// GetHost will return a single Host
|
||||
// Route: GET /hosts/{hostID}
|
||||
func (s Handler) GetHost() func(http.ResponseWriter, *http.Request) {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var err error
|
||||
var hostID int
|
||||
var host database.Host
|
||||
if hostID, err = getURLParamInt(r, "hostID"); err != nil {
|
||||
h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
|
||||
if err = s.DB.Where("id = ?", hostID).Preload("Upstreams").First(&host).Error; err != nil {
|
||||
h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil)
|
||||
} else {
|
||||
h.ResultResponseJSON(w, r, http.StatusOK, host)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CreateHost will create a Host
|
||||
// Route: POST /hosts
|
||||
func (s Handler) CreateHost() func(http.ResponseWriter, *http.Request) {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var newHost database.Host
|
||||
err := json.NewDecoder(r.Body).Decode(&newHost)
|
||||
if err != nil {
|
||||
h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
validate := validator.New()
|
||||
if err := validate.Struct(newHost); err != nil {
|
||||
h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
|
||||
result := s.DB.Create(&newHost)
|
||||
if result.Error != nil {
|
||||
h.ResultErrorJSON(w, r, http.StatusBadRequest, result.Error.Error(), nil)
|
||||
return
|
||||
}
|
||||
|
||||
if err := jobqueue.AddJob(jobqueue.Job{
|
||||
Name: "CaddyConfigureHost",
|
||||
Action: func() error {
|
||||
return caddy.WriteHost(newHost)
|
||||
},
|
||||
}); err != nil {
|
||||
h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
|
||||
h.ResultResponseJSON(w, r, http.StatusOK, newHost)
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateHost updates a host
|
||||
// Route: PUT /hosts
|
||||
func (s Handler) UpdateHost() func(http.ResponseWriter, *http.Request) {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var newHost database.Host
|
||||
err := json.NewDecoder(r.Body).Decode(&newHost)
|
||||
if err != nil {
|
||||
h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
validate := validator.New()
|
||||
if err := validate.Struct(newHost); err != nil {
|
||||
h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
|
||||
if err := jobqueue.AddJob(jobqueue.Job{
|
||||
Name: "CaddyConfigureHost",
|
||||
Action: func() error {
|
||||
return caddy.WriteHost(newHost)
|
||||
},
|
||||
}); err != nil {
|
||||
h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
|
||||
result := s.DB.Session(&gorm.Session{FullSaveAssociations: true}).Save(&newHost)
|
||||
if result.Error != nil {
|
||||
h.ResultErrorJSON(w, r, http.StatusBadRequest, result.Error.Error(), nil)
|
||||
return
|
||||
}
|
||||
|
||||
h.ResultResponseJSON(w, r, http.StatusOK, newHost)
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteHost removes a host
|
||||
// Route: DELETE /hosts/{hostID}
|
||||
func (s Handler) DeleteHost() func(http.ResponseWriter, *http.Request) {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var err error
|
||||
var hostID int
|
||||
if hostID, err = getURLParamInt(r, "hostID"); err != nil {
|
||||
h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
|
||||
if err := jobqueue.AddJob(jobqueue.Job{
|
||||
Name: "CaddyConfigureHost",
|
||||
Action: func() error {
|
||||
return caddy.RemoveHost(hostID)
|
||||
},
|
||||
}); err != nil {
|
||||
h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
|
||||
result := s.DB.Delete(&database.Host{}, hostID)
|
||||
if result.Error != nil {
|
||||
h.ResultErrorJSON(w, r, http.StatusBadRequest, result.Error.Error(), nil)
|
||||
return
|
||||
}
|
||||
if result.RowsAffected > 0 {
|
||||
h.ResultResponseJSON(w, r, http.StatusOK, true)
|
||||
} else {
|
||||
h.ResultErrorJSON(w, r, http.StatusBadRequest, h.ErrIDNotFound.Error(), nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
48
backend/internal/api/http/requests.go
Normal file
48
backend/internal/api/http/requests.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
"github.com/qri-io/jsonschema"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrInvalidJSON is an error for invalid json
|
||||
ErrInvalidJSON = errors.New("json is invalid")
|
||||
// ErrInvalidPayload is an error for invalid incoming data
|
||||
ErrInvalidPayload = errors.New("payload is invalid")
|
||||
// ErrIDNotFound is an error for invalid or missing ID in database
|
||||
ErrIDNotFound = errors.New("id is missing in db")
|
||||
)
|
||||
|
||||
// ValidateRequestSchema takes a Schema and the Content to validate against it
|
||||
func ValidateRequestSchema(schema string, requestBody []byte) ([]jsonschema.KeyError, error) {
|
||||
var jsonErrors []jsonschema.KeyError
|
||||
var schemaBytes = []byte(schema)
|
||||
|
||||
// Make sure the body is valid JSON
|
||||
if !isJSON(requestBody) {
|
||||
return jsonErrors, ErrInvalidJSON
|
||||
}
|
||||
|
||||
rs := &jsonschema.Schema{}
|
||||
if err := json.Unmarshal(schemaBytes, rs); err != nil {
|
||||
return jsonErrors, err
|
||||
}
|
||||
|
||||
var validationErr error
|
||||
ctx := context.TODO()
|
||||
if jsonErrors, validationErr = rs.ValidateBytes(ctx, requestBody); len(jsonErrors) > 0 {
|
||||
return jsonErrors, validationErr
|
||||
}
|
||||
|
||||
// Valid
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func isJSON(bytes []byte) bool {
|
||||
var js map[string]interface{}
|
||||
return json.Unmarshal(bytes, &js) == nil
|
||||
}
|
||||
91
backend/internal/api/http/responses.go
Normal file
91
backend/internal/api/http/responses.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"reflect"
|
||||
|
||||
c "github.com/Pacerino/cpm/internal/api/context"
|
||||
"github.com/Pacerino/cpm/internal/errors"
|
||||
"github.com/Pacerino/cpm/internal/logger"
|
||||
|
||||
"github.com/qri-io/jsonschema"
|
||||
)
|
||||
|
||||
// Response interface for standard API results
|
||||
type Response struct {
|
||||
Result interface{} `json:"result"`
|
||||
Error interface{} `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// ErrorResponse interface for errors returned via the API
|
||||
type ErrorResponse struct {
|
||||
Code interface{} `json:"code"`
|
||||
Message interface{} `json:"message"`
|
||||
Invalid interface{} `json:"invalid,omitempty"`
|
||||
}
|
||||
|
||||
// ResultResponseJSON will write the result as json to the http output
|
||||
func ResultResponseJSON(w http.ResponseWriter, r *http.Request, status int, result interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
|
||||
var response Response
|
||||
resultClass := fmt.Sprintf("%v", reflect.TypeOf(result))
|
||||
|
||||
if resultClass == "http.ErrorResponse" {
|
||||
response = Response{
|
||||
Error: result,
|
||||
}
|
||||
} else {
|
||||
response = Response{
|
||||
Result: result,
|
||||
}
|
||||
}
|
||||
|
||||
var payload []byte
|
||||
var err error
|
||||
if getPrettyPrintFromContext(r) {
|
||||
payload, err = json.MarshalIndent(response, "", " ")
|
||||
} else {
|
||||
payload, err = json.Marshal(response)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
logger.Error("ResponseMarshalError", err)
|
||||
}
|
||||
|
||||
fmt.Fprint(w, string(payload))
|
||||
}
|
||||
|
||||
// ResultSchemaErrorJSON will format the result as a standard error object and send it for output
|
||||
func ResultSchemaErrorJSON(w http.ResponseWriter, r *http.Request, errs []jsonschema.KeyError) {
|
||||
errorResponse := ErrorResponse{
|
||||
Code: http.StatusBadRequest,
|
||||
Message: errors.ErrValidationFailed,
|
||||
Invalid: errs,
|
||||
}
|
||||
|
||||
ResultResponseJSON(w, r, http.StatusBadRequest, errorResponse)
|
||||
}
|
||||
|
||||
// ResultErrorJSON will format the result as a standard error object and send it for output
|
||||
func ResultErrorJSON(w http.ResponseWriter, r *http.Request, status int, message string, extended interface{}) {
|
||||
errorResponse := ErrorResponse{
|
||||
Code: status,
|
||||
Message: message,
|
||||
Invalid: extended,
|
||||
}
|
||||
|
||||
ResultResponseJSON(w, r, status, errorResponse)
|
||||
}
|
||||
|
||||
// getPrettyPrintFromContext returns the PrettyPrint setting
|
||||
func getPrettyPrintFromContext(r *http.Request) bool {
|
||||
pretty, ok := r.Context().Value(c.PrettyPrintCtxKey).(bool)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return pretty
|
||||
}
|
||||
61
backend/internal/api/router.go
Normal file
61
backend/internal/api/router.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/Pacerino/cpm/embed"
|
||||
"github.com/Pacerino/cpm/internal/api/handler"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/cors"
|
||||
)
|
||||
|
||||
func NewRouter() http.Handler {
|
||||
cors := cors.Handler(cors.Options{
|
||||
AllowedOrigins: []string{"*"},
|
||||
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-Requested-With"},
|
||||
AllowCredentials: true,
|
||||
MaxAge: 300,
|
||||
})
|
||||
|
||||
r := chi.NewRouter()
|
||||
h := handler.NewHandler()
|
||||
|
||||
r.Use(
|
||||
cors,
|
||||
)
|
||||
|
||||
return generateRoutes(r, h)
|
||||
}
|
||||
|
||||
func generateRoutes(r chi.Router, h *handler.Handler) chi.Router {
|
||||
r.Route("/api", func(r chi.Router) {
|
||||
|
||||
//Hosts
|
||||
r.Route("/hosts", func(r chi.Router) {
|
||||
r.Get("/", h.GetHosts()) // Get List of Hosts
|
||||
r.Post("/", h.CreateHost()) // Create Host & save to the DB
|
||||
r.Get("/{hostID:[0-9]+}", h.GetHost()) // Get specific Host by ID
|
||||
r.Delete("/{hostID:[0-9]+}", h.DeleteHost()) // Delete Host by ID
|
||||
r.Put("/", h.UpdateHost()) // Update Host by ID
|
||||
})
|
||||
})
|
||||
fileServer(r)
|
||||
return r
|
||||
}
|
||||
|
||||
func fileServer(r chi.Router) {
|
||||
r.Get("/*", func(w http.ResponseWriter, r *http.Request) {
|
||||
rctx := chi.RouteContext(r.Context())
|
||||
pathPrefix := strings.TrimSuffix(rctx.RoutePattern(), "/*")
|
||||
fSys, err := fs.Sub(embed.Assets, "assets")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
fs := http.StripPrefix(pathPrefix, http.FileServer(http.FS(fSys)))
|
||||
fs.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
19
backend/internal/api/server.go
Normal file
19
backend/internal/api/server.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/Pacerino/cpm/internal/logger"
|
||||
)
|
||||
|
||||
const httpPort = 3001
|
||||
|
||||
// StartServer creates a http server
|
||||
func StartServer() {
|
||||
logger.Info("Server starting on port %v", httpPort)
|
||||
err := http.ListenAndServe(fmt.Sprintf("0.0.0.0:%v", httpPort), NewRouter())
|
||||
if err != nil {
|
||||
logger.Error("HttpListenError", err)
|
||||
}
|
||||
}
|
||||
53
backend/internal/caddy/caddy.go
Normal file
53
backend/internal/caddy/caddy.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package caddy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/Pacerino/cpm/embed"
|
||||
"github.com/Pacerino/cpm/internal/config"
|
||||
"github.com/Pacerino/cpm/internal/database"
|
||||
"github.com/Pacerino/cpm/internal/logger"
|
||||
|
||||
"github.com/aymerick/raymond"
|
||||
)
|
||||
|
||||
type hostEntity struct {
|
||||
Host database.Host
|
||||
LogPath string
|
||||
}
|
||||
|
||||
func WriteHost(h database.Host) error {
|
||||
data := &hostEntity{
|
||||
Host: h,
|
||||
LogPath: fmt.Sprintf("%s/host_%d.log", config.Configuration.LogFolder, h.ID),
|
||||
}
|
||||
filename := fmt.Sprintf("%s/host_%d.conf", config.Configuration.DataFolder, h.ID)
|
||||
// Read Template from Embed FS
|
||||
template, err := embed.CaddyFiles.ReadFile("caddy/host.hbs")
|
||||
if err != nil {
|
||||
logger.Error(err.Error(), err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Parse Data into Template
|
||||
tmplOutput, err := raymond.Render(string(template), data)
|
||||
if err != nil {
|
||||
logger.Error(err.Error(), err)
|
||||
return err
|
||||
}
|
||||
// Write filled out template to the config folder
|
||||
if err := os.WriteFile(filename, []byte(tmplOutput), 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return ReloadCaddy()
|
||||
}
|
||||
|
||||
func RemoveHost(hostID int) error {
|
||||
filename := fmt.Sprintf("%s/host_%d.conf", config.Configuration.DataFolder, hostID)
|
||||
if err := os.Remove(filename); err != nil {
|
||||
return err
|
||||
}
|
||||
return ReloadCaddy()
|
||||
}
|
||||
44
backend/internal/caddy/exec.go
Normal file
44
backend/internal/caddy/exec.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package caddy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
|
||||
"github.com/Pacerino/cpm/internal/config"
|
||||
"github.com/Pacerino/cpm/internal/logger"
|
||||
)
|
||||
|
||||
func ReloadCaddy() error {
|
||||
_, err := shExec([]string{"reload", "--config", config.Configuration.CaddyFile})
|
||||
return err
|
||||
}
|
||||
|
||||
func getCaddyFilePath() (string, error) {
|
||||
path, err := exec.LookPath("caddy")
|
||||
if err != nil {
|
||||
return path, fmt.Errorf("cannot find caddy execuatable script in PATH")
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// shExec executes caddy with arguments
|
||||
func shExec(args []string) (string, error) {
|
||||
ng, err := getCaddyFilePath()
|
||||
if err != nil {
|
||||
logger.Error("CaddyError", err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
logger.Debug("CMD: %s %v", ng, args)
|
||||
// nolint: gosec
|
||||
c := exec.Command(ng, args...)
|
||||
|
||||
b, e := c.Output()
|
||||
|
||||
if e != nil {
|
||||
logger.Error("CaddyError", fmt.Errorf("command error: %s -- %v\n%+v", ng, args, e))
|
||||
logger.Warn(string(b))
|
||||
}
|
||||
|
||||
return string(b), e
|
||||
}
|
||||
70
backend/internal/config/config.go
Normal file
70
backend/internal/config/config.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
golog "log"
|
||||
|
||||
"github.com/Pacerino/cpm/internal/logger"
|
||||
|
||||
"github.com/vrischmann/envconfig"
|
||||
)
|
||||
|
||||
// Version is the version set by ldflags
|
||||
var Version string
|
||||
|
||||
// Commit is the git commit set by ldflags
|
||||
var Commit string
|
||||
|
||||
// Init will parse environment variables into the Env struct
|
||||
func Init(version, commit *string) {
|
||||
|
||||
Version = *version
|
||||
Commit = *commit
|
||||
|
||||
if err := envconfig.InitWithPrefix(&Configuration, "CPM"); err != nil {
|
||||
fmt.Printf("%+v\n", err)
|
||||
}
|
||||
initLogger()
|
||||
logger.Info("Build Version: %s (%s)", Version, Commit)
|
||||
createDataFolders()
|
||||
}
|
||||
|
||||
// Init initialises the Log object and return it
|
||||
func initLogger() {
|
||||
// this removes timestamp prefixes from logs
|
||||
golog.SetFlags(0)
|
||||
|
||||
switch Configuration.Log.Level {
|
||||
case "debug":
|
||||
logLevel = logger.DebugLevel
|
||||
case "warn":
|
||||
logLevel = logger.WarnLevel
|
||||
case "error":
|
||||
logLevel = logger.ErrorLevel
|
||||
default:
|
||||
logLevel = logger.InfoLevel
|
||||
}
|
||||
|
||||
err := logger.Configure(&logger.Config{
|
||||
LogThreshold: logLevel,
|
||||
Formatter: Configuration.Log.Format,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
logger.Error("LoggerConfigurationError", err)
|
||||
}
|
||||
}
|
||||
|
||||
// GetLogLevel returns the logger const level
|
||||
func GetLogLevel() logger.Level {
|
||||
return logLevel
|
||||
}
|
||||
|
||||
/* func isError(errorClass string, err error) bool {
|
||||
if err != nil {
|
||||
logger.Error(errorClass, err)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
*/
|
||||
28
backend/internal/config/folders.go
Normal file
28
backend/internal/config/folders.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/Pacerino/cpm/internal/logger"
|
||||
)
|
||||
|
||||
func createDataFolders() {
|
||||
folders := []string{
|
||||
"hosts",
|
||||
}
|
||||
|
||||
for _, folder := range folders {
|
||||
path := folder
|
||||
if path[0:1] != "/" {
|
||||
path = fmt.Sprintf("%s/%s", Configuration.DataFolder, folder)
|
||||
}
|
||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||
// folder does not exist
|
||||
logger.Debug("Creating folder: %s", path)
|
||||
if err := os.MkdirAll(path, os.ModePerm); err != nil {
|
||||
logger.Error("CreateDataFolderError", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
21
backend/internal/config/vars.go
Normal file
21
backend/internal/config/vars.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package config
|
||||
|
||||
import "github.com/Pacerino/cpm/internal/logger"
|
||||
|
||||
// IsSetup defines whether we have an admin user or not
|
||||
var IsSetup bool
|
||||
|
||||
var logLevel logger.Level
|
||||
|
||||
type log struct {
|
||||
Level string `json:"level" envconfig:"optional,default=debug"`
|
||||
Format string `json:"format" envconfig:"optional,default=nice"`
|
||||
}
|
||||
|
||||
// Configuration is the main configuration object
|
||||
var Configuration struct {
|
||||
DataFolder string `json:"data_folder" envconfig:"optional,default=/etc/caddy/"`
|
||||
LogFolder string `json:"log_folder" envconfig:"optional,default=/var/log/caddy"`
|
||||
CaddyFile string `json:"caddy_file" envconfig:"optional,default=/etc/caddy/Caddyfile"`
|
||||
Log log `json:"log"`
|
||||
}
|
||||
16
backend/internal/database/models.go
Normal file
16
backend/internal/database/models.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package database
|
||||
|
||||
import "gorm.io/gorm"
|
||||
|
||||
type Host struct {
|
||||
gorm.Model
|
||||
Domains string `json:"domains" validate:"required,fqdn|hostname_port"`
|
||||
Matcher string `json:"matcher"`
|
||||
Upstreams []Upstream
|
||||
}
|
||||
|
||||
type Upstream struct {
|
||||
gorm.Model
|
||||
HostID uint `json:"hostId"`
|
||||
Backend string `json:"backend" validate:"required,hostname_port"`
|
||||
}
|
||||
40
backend/internal/database/sqlite.go
Normal file
40
backend/internal/database/sqlite.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/Pacerino/cpm/internal/config"
|
||||
"github.com/Pacerino/cpm/internal/logger"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var dbInstance *gorm.DB
|
||||
|
||||
// NewDB creates a new connection
|
||||
func NewDB() {
|
||||
logger.Info("Creating new DB instance")
|
||||
dbInstance = SqliteDB()
|
||||
dbInstance.AutoMigrate(&Host{}, &Upstream{})
|
||||
}
|
||||
|
||||
// GetInstance returns an existing or new instance
|
||||
func GetInstance() *gorm.DB {
|
||||
if dbInstance == nil {
|
||||
NewDB()
|
||||
}
|
||||
return dbInstance
|
||||
}
|
||||
|
||||
// SqliteDB Create sqlite client
|
||||
func SqliteDB() *gorm.DB {
|
||||
dbFile := fmt.Sprintf("%s/caddyproxymanager.db", config.Configuration.DataFolder)
|
||||
db, err := gorm.Open(sqlite.Open(dbFile), &gorm.Config{})
|
||||
if err != nil {
|
||||
logger.Error("SqliteError", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
return db
|
||||
}
|
||||
16
backend/internal/errors/errors.go
Normal file
16
backend/internal/errors/errors.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package errors
|
||||
|
||||
import "errors"
|
||||
|
||||
// All error messages used by the service package to report
|
||||
// problems back to calling clients
|
||||
var (
|
||||
ErrDatabaseUnavailable = errors.New("database-unavailable")
|
||||
ErrDuplicateEmailUser = errors.New("email-already-exists")
|
||||
ErrInvalidLogin = errors.New("invalid-login-credentials")
|
||||
ErrUserDisabled = errors.New("user-disabled")
|
||||
ErrSystemUserReadonly = errors.New("cannot-save-system-users")
|
||||
ErrValidationFailed = errors.New("request-failed-validation")
|
||||
ErrCurrentPasswordInvalid = errors.New("current-password-invalid")
|
||||
ErrCABundleDoesNotExist = errors.New("ca-bundle-does-not-exist")
|
||||
)
|
||||
46
backend/internal/jobqueue/main.go
Normal file
46
backend/internal/jobqueue/main.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package jobqueue
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
)
|
||||
|
||||
var (
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
worker *Worker
|
||||
)
|
||||
|
||||
// Start will intantiate the queue and start doing work
|
||||
func Start() {
|
||||
ctx, cancel = context.WithCancel(context.Background())
|
||||
q := &Queue{
|
||||
jobs: make(chan Job),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
|
||||
// Defines a queue worker, which will execute our queue.
|
||||
worker = newWorker(q)
|
||||
|
||||
// Execute jobs in queue.
|
||||
go worker.doWork()
|
||||
}
|
||||
|
||||
// Shutdown will gracefully stop the queue
|
||||
func Shutdown() error {
|
||||
if cancel == nil {
|
||||
return errors.New("unable to shutdown, jobqueue has not been started")
|
||||
}
|
||||
cancel()
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddJob adds a job to the queue for processing
|
||||
func AddJob(j Job) error {
|
||||
if worker == nil {
|
||||
return errors.New("unable to add job, jobqueue has not been started")
|
||||
}
|
||||
worker.Queue.AddJob(j)
|
||||
return nil
|
||||
}
|
||||
54
backend/internal/jobqueue/models.go
Normal file
54
backend/internal/jobqueue/models.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package jobqueue
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Queue holds name, list of jobs and context with cancel.
|
||||
type Queue struct {
|
||||
jobs chan Job
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
// Job - holds logic to perform some operations during queue execution.
|
||||
type Job struct {
|
||||
Name string
|
||||
Action func() error // A function that should be executed when the job is running.
|
||||
}
|
||||
|
||||
// AddJobs adds jobs to the queue and cancels channel.
|
||||
func (q *Queue) AddJobs(jobs []Job) {
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(len(jobs))
|
||||
|
||||
for _, job := range jobs {
|
||||
// Goroutine which adds job to the queue.
|
||||
go func(job Job) {
|
||||
q.AddJob(job)
|
||||
wg.Done()
|
||||
}(job)
|
||||
}
|
||||
|
||||
go func() {
|
||||
wg.Wait()
|
||||
// Cancel queue channel, when all goroutines were done.
|
||||
q.cancel()
|
||||
}()
|
||||
}
|
||||
|
||||
// AddJob sends job to the channel.
|
||||
func (q *Queue) AddJob(job Job) {
|
||||
q.jobs <- job
|
||||
}
|
||||
|
||||
// Run performs job execution.
|
||||
func (j Job) Run() error {
|
||||
err := j.Action()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
37
backend/internal/jobqueue/worker.go
Normal file
37
backend/internal/jobqueue/worker.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package jobqueue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/Pacerino/cpm/internal/logger"
|
||||
)
|
||||
|
||||
// Worker responsible for queue serving.
|
||||
type Worker struct {
|
||||
Queue *Queue
|
||||
}
|
||||
|
||||
func newWorker(queue *Queue) *Worker {
|
||||
return &Worker{
|
||||
Queue: queue,
|
||||
}
|
||||
}
|
||||
|
||||
// doWork processes jobs from the queue (jobs channel).
|
||||
func (w *Worker) doWork() bool {
|
||||
for {
|
||||
select {
|
||||
// if context was canceled.
|
||||
case <-w.Queue.ctx.Done():
|
||||
logger.Info("JobQueue worker graceful shutdown")
|
||||
return true
|
||||
// if job received.
|
||||
case job := <-w.Queue.jobs:
|
||||
err := job.Run()
|
||||
if err != nil {
|
||||
logger.Error(fmt.Sprintf("%sError", job.Name), err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
37
backend/internal/logger/config.go
Normal file
37
backend/internal/logger/config.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package logger
|
||||
|
||||
// Level type
|
||||
type Level int
|
||||
|
||||
// Log level definitions
|
||||
const (
|
||||
// DebugLevel usually only enabled when debugging. Very verbose logging.
|
||||
DebugLevel Level = 10
|
||||
// InfoLevel general operational entries about what's going on inside the application.
|
||||
InfoLevel Level = 20
|
||||
// WarnLevel non-critical entries that deserve eyes.
|
||||
WarnLevel Level = 30
|
||||
// ErrorLevel used for errors that should definitely be noted.
|
||||
ErrorLevel Level = 40
|
||||
)
|
||||
|
||||
// Config options for the logger.
|
||||
type Config struct {
|
||||
LogThreshold Level
|
||||
Formatter string
|
||||
}
|
||||
|
||||
// Interface for a logger
|
||||
type Interface interface {
|
||||
GetLogLevel() Level
|
||||
Debug(format string, args ...interface{})
|
||||
Info(format string, args ...interface{})
|
||||
Warn(format string, args ...interface{})
|
||||
Error(errorClass string, err error, args ...interface{})
|
||||
Errorf(errorClass, format string, err error, args ...interface{})
|
||||
}
|
||||
|
||||
// ConfigurableLogger is an interface for a logger that can be configured
|
||||
type ConfigurableLogger interface {
|
||||
Configure(c *Config) error
|
||||
}
|
||||
243
backend/internal/logger/logger.go
Normal file
243
backend/internal/logger/logger.go
Normal file
@@ -0,0 +1,243 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
stdlog "log"
|
||||
"os"
|
||||
"runtime/debug"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/fatih/color"
|
||||
/* "github.com/getsentry/sentry-go" */)
|
||||
|
||||
var colorReset, colorGray, colorYellow, colorBlue, colorRed, colorMagenta, colorBlack, colorWhite *color.Color
|
||||
|
||||
// Log message structure.
|
||||
type Log struct {
|
||||
Timestamp string `json:"timestamp"`
|
||||
Level string `json:"level"`
|
||||
Message string `json:"message"`
|
||||
Pid int `json:"pid"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
Caller string `json:"caller,omitempty"`
|
||||
StackTrace []string `json:"stack_trace,omitempty"`
|
||||
}
|
||||
|
||||
// Logger instance
|
||||
type Logger struct {
|
||||
Config
|
||||
mux sync.Mutex
|
||||
}
|
||||
|
||||
// global logging configuration.
|
||||
var logger = NewLogger()
|
||||
|
||||
// NewLogger creates a new logger instance
|
||||
func NewLogger() *Logger {
|
||||
color.NoColor = false
|
||||
colorReset = color.New(color.Reset)
|
||||
colorGray = color.New(color.FgWhite)
|
||||
colorYellow = color.New(color.Bold, color.FgYellow)
|
||||
colorBlue = color.New(color.Bold, color.FgBlue)
|
||||
colorRed = color.New(color.Bold, color.FgRed)
|
||||
colorMagenta = color.New(color.Bold, color.FgMagenta)
|
||||
colorBlack = color.New(color.Bold, color.FgBlack)
|
||||
colorWhite = color.New(color.Bold, color.FgWhite)
|
||||
|
||||
return &Logger{
|
||||
Config: NewConfig(),
|
||||
}
|
||||
}
|
||||
|
||||
// NewConfig returns the default config
|
||||
func NewConfig() Config {
|
||||
return Config{
|
||||
LogThreshold: InfoLevel,
|
||||
Formatter: "json",
|
||||
}
|
||||
}
|
||||
|
||||
// Configure logger and will return error if missing required fields.
|
||||
func Configure(c *Config) error {
|
||||
return logger.Configure(c)
|
||||
}
|
||||
|
||||
// GetLogLevel currently configured
|
||||
func GetLogLevel() Level {
|
||||
return logger.GetLogLevel()
|
||||
}
|
||||
|
||||
// Debug logs if the log level is set to DebugLevel or below. Arguments are handled in the manner of fmt.Printf.
|
||||
func Debug(format string, args ...interface{}) {
|
||||
logger.Debug(format, args...)
|
||||
}
|
||||
|
||||
// Info logs if the log level is set to InfoLevel or below. Arguments are handled in the manner of fmt.Printf.
|
||||
func Info(format string, args ...interface{}) {
|
||||
logger.Info(format, args...)
|
||||
}
|
||||
|
||||
// Warn logs if the log level is set to WarnLevel or below. Arguments are handled in the manner of fmt.Printf.
|
||||
func Warn(format string, args ...interface{}) {
|
||||
logger.Warn(format, args...)
|
||||
}
|
||||
|
||||
// Error logs error given if the log level is set to ErrorLevel or below. Arguments are not logged.
|
||||
// Attempts to log to bugsang.
|
||||
func Error(errorClass string, err error) {
|
||||
logger.Error(errorClass, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Configure logger and will return error if missing required fields.
|
||||
func (l *Logger) Configure(c *Config) error {
|
||||
// ensure updates to the config are atomic
|
||||
l.mux.Lock()
|
||||
defer l.mux.Unlock()
|
||||
|
||||
if c == nil {
|
||||
return fmt.Errorf("a non nil Config is mandatory")
|
||||
}
|
||||
|
||||
if err := c.LogThreshold.validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
l.LogThreshold = c.LogThreshold
|
||||
l.Formatter = c.Formatter
|
||||
/* l.SentryConfig = c.SentryConfig */
|
||||
|
||||
/* if c.SentryConfig.Dsn != "" {
|
||||
if sentryErr := sentry.Init(c.SentryConfig); sentryErr != nil {
|
||||
fmt.Printf("Sentry initialization failed: %v\n", sentryErr)
|
||||
}
|
||||
} */
|
||||
|
||||
stdlog.SetFlags(0) // this removes timestamp prefixes from logs
|
||||
return nil
|
||||
}
|
||||
|
||||
// validate the log level is in the accepted list.
|
||||
func (l Level) validate() error {
|
||||
switch l {
|
||||
case DebugLevel, InfoLevel, WarnLevel, ErrorLevel:
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("invalid \"Level\" %d", l)
|
||||
}
|
||||
}
|
||||
|
||||
var logLevels = map[Level]string{
|
||||
DebugLevel: "DEBUG",
|
||||
InfoLevel: "INFO",
|
||||
WarnLevel: "WARN",
|
||||
ErrorLevel: "ERROR",
|
||||
}
|
||||
|
||||
func (l *Logger) logLevel(logLevel Level, format string, args ...interface{}) {
|
||||
if logLevel < l.LogThreshold {
|
||||
return
|
||||
}
|
||||
|
||||
errorClass := ""
|
||||
if logLevel == ErrorLevel {
|
||||
// First arg is the errorClass
|
||||
errorClass = args[0].(string)
|
||||
if len(args) > 1 {
|
||||
args = args[1:]
|
||||
} else {
|
||||
args = []interface{}{}
|
||||
}
|
||||
}
|
||||
|
||||
stringMessage := fmt.Sprintf(format, args...)
|
||||
|
||||
if l.Formatter == "json" {
|
||||
// JSON Log Format
|
||||
jsonLog, _ := json.Marshal(
|
||||
Log{
|
||||
Timestamp: time.Now().Format(time.RFC3339Nano),
|
||||
Level: logLevels[logLevel],
|
||||
Message: stringMessage,
|
||||
Pid: os.Getpid(),
|
||||
},
|
||||
)
|
||||
|
||||
stdlog.Println(string(jsonLog))
|
||||
} else {
|
||||
// Nice Log Format
|
||||
var colorLevel *color.Color
|
||||
switch logLevel {
|
||||
case DebugLevel:
|
||||
colorLevel = colorMagenta
|
||||
case InfoLevel:
|
||||
colorLevel = colorBlue
|
||||
case WarnLevel:
|
||||
colorLevel = colorYellow
|
||||
case ErrorLevel:
|
||||
colorLevel = colorRed
|
||||
stringMessage = fmt.Sprintf("%s: %s", errorClass, stringMessage)
|
||||
}
|
||||
|
||||
t := time.Now()
|
||||
stdlog.Println(
|
||||
colorBlack.Sprint("["),
|
||||
colorWhite.Sprint(t.Format("2006-01-02 15:04:05")),
|
||||
colorBlack.Sprint("] "),
|
||||
colorLevel.Sprintf("%-8v", logLevels[logLevel]),
|
||||
colorGray.Sprint(stringMessage),
|
||||
colorReset.Sprint(""),
|
||||
)
|
||||
|
||||
if logLevel == ErrorLevel && l.LogThreshold == DebugLevel {
|
||||
// Print a stack trace too
|
||||
debug.PrintStack()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetLogLevel currently configured
|
||||
func (l *Logger) GetLogLevel() Level {
|
||||
return l.LogThreshold
|
||||
}
|
||||
|
||||
// Debug logs if the log level is set to DebugLevel or below. Arguments are handled in the manner of fmt.Printf.
|
||||
func (l *Logger) Debug(format string, args ...interface{}) {
|
||||
l.logLevel(DebugLevel, format, args...)
|
||||
}
|
||||
|
||||
// Info logs if the log level is set to InfoLevel or below. Arguments are handled in the manner of fmt.Printf.
|
||||
func (l *Logger) Info(format string, args ...interface{}) {
|
||||
l.logLevel(InfoLevel, format, args...)
|
||||
}
|
||||
|
||||
// Warn logs if the log level is set to WarnLevel or below. Arguments are handled in the manner of fmt.Printf.
|
||||
func (l *Logger) Warn(format string, args ...interface{}) {
|
||||
l.logLevel(WarnLevel, format, args...)
|
||||
}
|
||||
|
||||
// Error logs error given if the log level is set to ErrorLevel or below. Arguments are not logged.
|
||||
// Attempts to log to bugsang.
|
||||
func (l *Logger) Error(errorClass string, err error) {
|
||||
l.logLevel(ErrorLevel, err.Error(), errorClass)
|
||||
/* l.notifySentry(errorClass, err) */
|
||||
}
|
||||
|
||||
/* func (l *Logger) notifySentry(errorClass string, err error) {
|
||||
if l.SentryConfig.Dsn != "" && l.SentryConfig.Dsn != "-" {
|
||||
|
||||
sentry.ConfigureScope(func(scope *sentry.Scope) {
|
||||
scope.SetLevel(sentry.LevelError)
|
||||
scope.SetTag("service", "backend")
|
||||
scope.SetTag("error_class", errorClass)
|
||||
})
|
||||
|
||||
sentry.CaptureException(err)
|
||||
// Since sentry emits events in the background we need to make sure
|
||||
// they are sent before we shut down
|
||||
sentry.Flush(time.Second * 5)
|
||||
}
|
||||
}
|
||||
*/
|
||||
36
backend/internal/util/interfaces.go
Normal file
36
backend/internal/util/interfaces.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package util
|
||||
|
||||
// FindItemInInterface Find key in interface (recursively) and return value as interface
|
||||
func FindItemInInterface(key string, obj interface{}) (interface{}, bool) {
|
||||
// if the argument is not a map, ignore it
|
||||
mobj, ok := obj.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
for k, v := range mobj {
|
||||
// key match, return value
|
||||
if k == key {
|
||||
return v, true
|
||||
}
|
||||
|
||||
// if the value is a map, search recursively
|
||||
if m, ok := v.(map[string]interface{}); ok {
|
||||
if res, ok := FindItemInInterface(key, m); ok {
|
||||
return res, true
|
||||
}
|
||||
}
|
||||
// if the value is an array, search recursively
|
||||
// from each element
|
||||
if va, ok := v.([]interface{}); ok {
|
||||
for _, a := range va {
|
||||
if res, ok := FindItemInInterface(key, a); ok {
|
||||
return res, true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// element not found
|
||||
return nil, false
|
||||
}
|
||||
44
backend/internal/util/slices.go
Normal file
44
backend/internal/util/slices.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SliceContainsItem returns whether the slice given contains the item given
|
||||
func SliceContainsItem(slice []string, item string) bool {
|
||||
for _, a := range slice {
|
||||
if a == item {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// SliceContainsInt returns whether the slice given contains the item given
|
||||
func SliceContainsInt(slice []int, item int) bool {
|
||||
for _, a := range slice {
|
||||
if a == item {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ConvertIntSliceToString returns a comma separated string of all items in the slice
|
||||
func ConvertIntSliceToString(slice []int) string {
|
||||
strs := []string{}
|
||||
for _, item := range slice {
|
||||
strs = append(strs, strconv.Itoa(item))
|
||||
}
|
||||
return strings.Join(strs, ",")
|
||||
}
|
||||
|
||||
// ConvertStringSliceToInterface is required in some special cases
|
||||
func ConvertStringSliceToInterface(slice []string) []interface{} {
|
||||
res := make([]interface{}, len(slice))
|
||||
for i := range slice {
|
||||
res[i] = slice[i]
|
||||
}
|
||||
return res
|
||||
}
|
||||
46
frontend/README.md
Normal file
46
frontend/README.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# Getting Started with Create React App
|
||||
|
||||
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
|
||||
|
||||
## Available Scripts
|
||||
|
||||
In the project directory, you can run:
|
||||
|
||||
### `npm start`
|
||||
|
||||
Runs the app in the development mode.\
|
||||
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
|
||||
|
||||
The page will reload if you make edits.\
|
||||
You will also see any lint errors in the console.
|
||||
|
||||
### `npm test`
|
||||
|
||||
Launches the test runner in the interactive watch mode.\
|
||||
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
|
||||
|
||||
### `npm run build`
|
||||
|
||||
Builds the app for production to the `build` folder.\
|
||||
It correctly bundles React in production mode and optimizes the build for the best performance.
|
||||
|
||||
The build is minified and the filenames include the hashes.\
|
||||
Your app is ready to be deployed!
|
||||
|
||||
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
|
||||
|
||||
### `npm run eject`
|
||||
|
||||
**Note: this is a one-way operation. Once you `eject`, you can’t go back!**
|
||||
|
||||
If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
|
||||
|
||||
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own.
|
||||
|
||||
You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it.
|
||||
|
||||
## Learn More
|
||||
|
||||
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
|
||||
|
||||
To learn React, check out the [React documentation](https://reactjs.org/).
|
||||
28308
frontend/package-lock.json
generated
Normal file
28308
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
55
frontend/package.json
Normal file
55
frontend/package.json
Normal file
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^2.9.8",
|
||||
"@testing-library/jest-dom": "^5.16.5",
|
||||
"@testing-library/react": "^13.4.0",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"@types/jest": "^27.5.2",
|
||||
"@types/node": "^16.11.59",
|
||||
"@types/react": "^18.0.20",
|
||||
"@types/react-dom": "^18.0.6",
|
||||
"flowbite": "^1.5.3",
|
||||
"flowbite-react": "^0.1.11",
|
||||
"joi": "^17.6.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.36.0",
|
||||
"react-router-dom": "^6.4.0",
|
||||
"react-scripts": "5.0.1",
|
||||
"typescript": "^4.8.3",
|
||||
"web-vitals": "^2.1.4"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/joi": "^17.2.3",
|
||||
"autoprefixer": "^10.4.11",
|
||||
"postcss": "^8.4.16",
|
||||
"tailwindcss": "^3.1.8"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
BIN
frontend/public/favicon.ico
Normal file
BIN
frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
43
frontend/public/index.html
Normal file
43
frontend/public/index.html
Normal file
@@ -0,0 +1,43 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Web site created using create-react-app"
|
||||
/>
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||
<!--
|
||||
manifest.json provides metadata used when your web app is installed on a
|
||||
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||
-->
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<!--
|
||||
Notice the use of %PUBLIC_URL% in the tags above.
|
||||
It will be replaced with the URL of the `public` folder during the build.
|
||||
Only files inside the `public` folder can be referenced from the HTML.
|
||||
|
||||
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
||||
work correctly both with client-side routing and a non-root public URL.
|
||||
Learn how to configure a non-root public URL by running `npm run build`.
|
||||
-->
|
||||
<title>React App</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<!--
|
||||
This HTML file is a template.
|
||||
If you open it directly in the browser, you will see an empty page.
|
||||
|
||||
You can add webfonts, meta tags, or analytics to this file.
|
||||
The build step will place the bundled scripts into the <body> tag.
|
||||
|
||||
To begin the development, run `npm start` or `yarn start`.
|
||||
To create a production bundle, use `npm run build` or `yarn build`.
|
||||
-->
|
||||
</body>
|
||||
</html>
|
||||
BIN
frontend/public/logo192.png
Normal file
BIN
frontend/public/logo192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.2 KiB |
BIN
frontend/public/logo512.png
Normal file
BIN
frontend/public/logo512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.4 KiB |
25
frontend/public/manifest.json
Normal file
25
frontend/public/manifest.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"short_name": "React App",
|
||||
"name": "Create React App Sample",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
},
|
||||
{
|
||||
"src": "logo192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "logo512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
}
|
||||
3
frontend/public/robots.txt
Normal file
3
frontend/public/robots.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
||||
21
frontend/src/App.tsx
Normal file
21
frontend/src/App.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import React from "react";
|
||||
import Layout from "./components/Layout"
|
||||
import { Routes, Route } from "react-router-dom";
|
||||
|
||||
// Pages
|
||||
import HostsPage from "./pages/Hosts"
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<>
|
||||
|
||||
<Routes>
|
||||
<Route path="/" element={<Layout />}>
|
||||
<Route index element={<HostsPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
29
frontend/src/components/Layout.tsx
Normal file
29
frontend/src/components/Layout.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Navbar } from "flowbite-react";
|
||||
import { Outlet } from "react-router-dom";
|
||||
function Layout() {
|
||||
return (
|
||||
<div>
|
||||
<Navbar fluid={true} rounded={false}>
|
||||
<Navbar.Brand href="https://flowbite.com/">
|
||||
<img
|
||||
src="https://flowbite.com/docs/images/logo.svg"
|
||||
className="mr-3 h-6 sm:h-9"
|
||||
alt="Flowbite Logo"
|
||||
/>
|
||||
<span className="self-center whitespace-nowrap text-xl font-semibold dark:text-white">
|
||||
Caddy Proxy Manager
|
||||
</span>
|
||||
</Navbar.Brand>
|
||||
<Navbar.Collapse>
|
||||
{/* <Navbar.Link href="/home">Home</Navbar.Link>
|
||||
<Navbar.Link href="/hosts">Hosts</Navbar.Link> */}
|
||||
</Navbar.Collapse>
|
||||
</Navbar>
|
||||
<div className="container mx-auto py-4">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Layout;
|
||||
3
frontend/src/index.css
Normal file
3
frontend/src/index.css
Normal file
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
25
frontend/src/index.tsx
Normal file
25
frontend/src/index.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import "./index.css";
|
||||
import App from "./App";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import reportWebVitals from "./reportWebVitals";
|
||||
|
||||
const root = ReactDOM.createRoot(
|
||||
document.getElementById("root") as HTMLElement
|
||||
);
|
||||
|
||||
document.body.classList.add('bg-slate-900');
|
||||
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
// If you want to start measuring performance in your app, pass a function
|
||||
// to log results (for example: reportWebVitals(console.log))
|
||||
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
|
||||
reportWebVitals();
|
||||
308
frontend/src/pages/Hosts.tsx
Normal file
308
frontend/src/pages/Hosts.tsx
Normal file
@@ -0,0 +1,308 @@
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
Modal,
|
||||
TextInput,
|
||||
Label,
|
||||
Spinner,
|
||||
} from "flowbite-react";
|
||||
import { useForm, useFieldArray, SubmitHandler } from "react-hook-form";
|
||||
import { joiResolver } from "@hookform/resolvers/joi";
|
||||
import Joi from "joi";
|
||||
import { HiAdjustments, HiTrash, HiDocumentAdd } from "react-icons/hi";
|
||||
import React from "react";
|
||||
|
||||
type Domain = {
|
||||
fqdn: string;
|
||||
};
|
||||
|
||||
type UpstreamForm = {
|
||||
backend: string;
|
||||
};
|
||||
|
||||
type FormValues = {
|
||||
domains: Domain[];
|
||||
upstreams: UpstreamForm[];
|
||||
matcher: string | undefined;
|
||||
};
|
||||
|
||||
interface Upstream {
|
||||
ID: number;
|
||||
CreatedAt: string;
|
||||
UpdatedAt: string;
|
||||
DeletedAt?: any;
|
||||
hostId: number;
|
||||
backend: string;
|
||||
}
|
||||
|
||||
interface Hosts {
|
||||
ID: number;
|
||||
CreatedAt: string;
|
||||
UpdatedAt: string;
|
||||
DeletedAt?: any;
|
||||
domains: string;
|
||||
matcher: string;
|
||||
Upstreams: Upstream[];
|
||||
}
|
||||
|
||||
interface RootObject {
|
||||
result: Hosts[];
|
||||
}
|
||||
|
||||
function HostsPage() {
|
||||
const [modal, setModal] = React.useState(false);
|
||||
const [hostData, setHostData] = React.useState<RootObject>();
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
|
||||
const schema = Joi.object<FormValues>({
|
||||
matcher: Joi.any().optional(),
|
||||
upstreams: Joi.array().items(
|
||||
Joi.object().keys({
|
||||
backend: Joi.string().required(),
|
||||
})
|
||||
),
|
||||
domains: Joi.array().items(
|
||||
Joi.object().keys({
|
||||
fqdn: Joi.string().required(),
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
control,
|
||||
formState: { errors },
|
||||
} = useForm({
|
||||
resolver: joiResolver(schema),
|
||||
defaultValues: {
|
||||
matcher: "",
|
||||
domains: [{ fqdn: "" }],
|
||||
upstreams: [{ backend: "" }],
|
||||
},
|
||||
});
|
||||
const {
|
||||
fields: upstreamFields,
|
||||
append: upstreamAppend,
|
||||
remove: upstreamRemove,
|
||||
} = useFieldArray({
|
||||
control,
|
||||
name: "upstreams",
|
||||
});
|
||||
|
||||
const {
|
||||
fields: domainFields,
|
||||
append: domainAppend,
|
||||
remove: domainRemove,
|
||||
} = useFieldArray({
|
||||
control,
|
||||
name: "domains",
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
getHosts();
|
||||
}, []);
|
||||
|
||||
const getHosts = async () => {
|
||||
return await fetch(`http://${window.location.hostname}:3001/api/hosts`)
|
||||
.then((res) => res.json())
|
||||
.then((json) => {
|
||||
setHostData(json);
|
||||
});
|
||||
};
|
||||
|
||||
const createHost: SubmitHandler<FormValues> = async (data) => {
|
||||
const jsonBody = {
|
||||
matcher: data.matcher,
|
||||
domains: data.domains
|
||||
.map((e) => {
|
||||
return e.fqdn;
|
||||
})
|
||||
.join(","),
|
||||
upstreams: data.upstreams,
|
||||
};
|
||||
setLoading(true);
|
||||
const res = await fetch(`http://${window.location.hostname}:3001/api/hosts`, {
|
||||
body: JSON.stringify(jsonBody),
|
||||
method: "POST",
|
||||
});
|
||||
if (res.status !== 200) {
|
||||
// handle Error
|
||||
}
|
||||
setLoading(false);
|
||||
setModal(false);
|
||||
getHosts();
|
||||
};
|
||||
|
||||
const deleteHost = async (hostID: number) => {
|
||||
await fetch(`http://${window.location.hostname}:3001/api/hosts/${hostID}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
getHosts();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="pb-4 bg-white dark:bg-gray-900">
|
||||
<Button size="xs" color="gray" onClick={() => setModal(true)}>
|
||||
<HiDocumentAdd className="mr-3 h-4 w-4" /> Add Host
|
||||
</Button>
|
||||
</div>
|
||||
<Table>
|
||||
<Table.Head>
|
||||
<Table.HeadCell>ID</Table.HeadCell>
|
||||
<Table.HeadCell>Created</Table.HeadCell>
|
||||
<Table.HeadCell>Updated</Table.HeadCell>
|
||||
<Table.HeadCell>Domains</Table.HeadCell>
|
||||
<Table.HeadCell>Matcher</Table.HeadCell>
|
||||
<Table.HeadCell>Upstreams</Table.HeadCell>
|
||||
<Table.HeadCell>
|
||||
<span className="sr-only">Edit</span>
|
||||
</Table.HeadCell>
|
||||
</Table.Head>
|
||||
<Table.Body className="divide-y">
|
||||
{hostData?.result.map((entry, i) => {
|
||||
return (
|
||||
<Table.Row
|
||||
key={entry.ID}
|
||||
className="bg-white dark:border-gray-700 dark:bg-gray-800"
|
||||
>
|
||||
<Table.Cell>{entry.ID}</Table.Cell>
|
||||
<Table.Cell>{entry.CreatedAt}</Table.Cell>
|
||||
<Table.Cell>{entry.UpdatedAt}</Table.Cell>
|
||||
<Table.Cell>{entry.domains}</Table.Cell>
|
||||
<Table.Cell>{entry.matcher}</Table.Cell>
|
||||
<Table.Cell>{entry.Upstreams[0].backend}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Button.Group>
|
||||
<Button size="xs" color="gray">
|
||||
<HiAdjustments className="mr-3 h-4 w-4" /> Edit
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
color="gray"
|
||||
onClick={() => deleteHost(entry.ID)}
|
||||
>
|
||||
<HiTrash className="mr-3 h-4 w-4" /> Delete
|
||||
</Button>
|
||||
</Button.Group>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
);
|
||||
})}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
|
||||
<Modal show={modal} size="xl" onClose={() => setModal(false)}>
|
||||
<Modal.Header>Add a new Host</Modal.Header>
|
||||
<Modal.Body>
|
||||
<form
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={handleSubmit(createHost)}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<div className="mb-2 block">
|
||||
<Label htmlFor="domain" value="Domain" />
|
||||
</div>
|
||||
<ul className="space-y-2">
|
||||
{domainFields.map((field, index) => {
|
||||
return (
|
||||
<li key={field.id}>
|
||||
<TextInput
|
||||
type="text"
|
||||
placeholder="example.com"
|
||||
id={`domains.${index}.fqdn`}
|
||||
key={field.id}
|
||||
{...register(`domains.${index}.fqdn` as const)}
|
||||
addon={
|
||||
index > 0 && (
|
||||
<Button size="xs" color="gray" onClick={() => domainRemove(index)}>
|
||||
Delete
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
helperText={
|
||||
errors.domains?.[index] && (
|
||||
<span>Please enter a valid FQDN</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<div className="mb-2 block">
|
||||
<Label value="Matcher" />
|
||||
</div>
|
||||
<TextInput
|
||||
type="text"
|
||||
id="matcher"
|
||||
placeholder="/api/*"
|
||||
{...register("matcher", { required: false })}
|
||||
/>
|
||||
<div className="mb-2 block">
|
||||
<Label value="Upstreams" />
|
||||
</div>
|
||||
<ul className="space-y-2">
|
||||
{upstreamFields.map((field, index) => {
|
||||
return (
|
||||
<li key={field.id}>
|
||||
<TextInput
|
||||
type="text"
|
||||
placeholder="127.0.0.1:8080"
|
||||
id={`upstreams.${index}.backend`}
|
||||
key={field.id}
|
||||
{...register(`upstreams.${index}.backend` as const)}
|
||||
addon={
|
||||
index > 0 && (
|
||||
<Button size="xs" color="gray" onClick={() => upstreamRemove(index)}>
|
||||
Delete
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
helperText={
|
||||
errors.upstreams?.[index] && (
|
||||
<span>Please enter a valid IP Address</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
<Button.Group>
|
||||
{loading ? (
|
||||
<Button disabled={true}>
|
||||
<div className="mr-3">
|
||||
<Spinner size="sm" light={true} />
|
||||
</div>
|
||||
Loading ...
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="submit">Save</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
disabled={loading}
|
||||
type="button"
|
||||
onClick={() => domainAppend({ fqdn: "" })}
|
||||
>
|
||||
Add Domain
|
||||
</Button>
|
||||
<Button
|
||||
disabled={loading}
|
||||
type="button"
|
||||
onClick={() => upstreamAppend({ backend: "" })}
|
||||
>
|
||||
Add Upstream
|
||||
</Button>
|
||||
</Button.Group>
|
||||
</form>
|
||||
</Modal.Body>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default HostsPage;
|
||||
1
frontend/src/react-app-env.d.ts
vendored
Normal file
1
frontend/src/react-app-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="react-scripts" />
|
||||
15
frontend/src/reportWebVitals.ts
Normal file
15
frontend/src/reportWebVitals.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { ReportHandler } from 'web-vitals';
|
||||
|
||||
const reportWebVitals = (onPerfEntry?: ReportHandler) => {
|
||||
if (onPerfEntry && onPerfEntry instanceof Function) {
|
||||
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
|
||||
getCLS(onPerfEntry);
|
||||
getFID(onPerfEntry);
|
||||
getFCP(onPerfEntry);
|
||||
getLCP(onPerfEntry);
|
||||
getTTFB(onPerfEntry);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export default reportWebVitals;
|
||||
5
frontend/src/setupTests.ts
Normal file
5
frontend/src/setupTests.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// jest-dom adds custom jest matchers for asserting on DOM nodes.
|
||||
// allows you to do things like:
|
||||
// expect(element).toHaveTextContent(/react/i)
|
||||
// learn more: https://github.com/testing-library/jest-dom
|
||||
import '@testing-library/jest-dom';
|
||||
15
frontend/tailwind.config.js
Normal file
15
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,15 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
"./src/**/*.{js,jsx,ts,tsx}",
|
||||
'node_modules/flowbite-react/**/*.{js,jsx,ts,tsx}',
|
||||
"./components/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
darkMode: 'media',
|
||||
plugins: [
|
||||
require('flowbite/plugin')
|
||||
]
|
||||
}
|
||||
26
frontend/tsconfig.json
Normal file
26
frontend/tsconfig.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
9712
frontend/yarn.lock
Normal file
9712
frontend/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user