Squashed commit of the following:
commita394259f0a
Author: Sense T <me@sense-t.eu.org> Date: Fri Apr 19 15:10:08 2024 +0800 done commitaf9d966376
Author: Sense T <me@sense-t.eu.org> Date: Fri Apr 19 13:52:33 2024 +0800 debug done. commit47335ca5e9
Author: Sense T <me@sense-t.eu.org> Date: Fri Apr 19 12:47:00 2024 +0800 swagger done commit34fb2a478b
Author: Sense T <me@sense-t.eu.org> Date: Fri Apr 19 10:05:19 2024 +0800 stage 2, not completed commit88b2255f8b
Author: Sense T <me@sense-t.eu.org> Date: Fri Apr 19 09:44:37 2024 +0800 test stage 1 commitb583720223
Author: Sense T <me@sense-t.eu.org> Date: Mon Apr 15 21:53:09 2024 +0800 Squashed commit of the following: commit 1e92328a0fc570fe9419ad5dbaaef77f7dc9ad2e Author: Sense T <me@sense-t.eu.org> Date: Mon Apr 15 21:52:44 2024 +0800 yes, react it! commit 09fffff6139b4cecb81cb1444139f225e95e8917 Author: Sense T <me@sense-t.eu.org> Date: Mon Apr 15 17:33:26 2024 +0800 actions to be done commit 1611b0b338cfd965d15f43fb10308bc56015895f Author: Sense T <me@sense-t.eu.org> Date: Mon Apr 15 15:22:08 2024 +0800 modal needed. commit 88453e7382618fb6774ff1cc4c0f7045d4dfcf46 Author: Sense T <me@sense-t.eu.org> Date: Mon Apr 15 10:52:13 2024 +0800 Domain View done commit 8cedca27c79ca2ba69c8777dfcb6019799875e31 Author: Sense T <me@sense-t.eu.org> Date: Sun Apr 14 21:24:14 2024 +0800 domain delete modal done commit 60cd00c0cad0774bae5b57bcfc4723a29d28d221 Author: Sense T <me@sense-t.eu.org> Date: Sun Apr 14 07:55:11 2024 +0800 1 commit 285853e988db6e6a6371135869da0129fd73afd7 Author: Sense T <me@sense-t.eu.org> Date: Sat Apr 13 17:29:43 2024 +0800 eslint commit 8f0ffbf744fd85a612daacd7bd6cbc45d58907d3 Author: Sense T <me@sense-t.eu.org> Date: Sat Apr 13 17:20:50 2024 +0800 f commit 9762b632225f185d83388e58d93ed49f62fe6b3f Author: Sense T <me@sense-t.eu.org> Date: Sat Apr 13 17:08:37 2024 +0800 views, components to be done commit 321e5255f2b1e705844179dd910d5f5a1ae58298 Author: Sense T <me@sense-t.eu.org> Date: Sat Apr 13 14:29:04 2024 +0800 prepare for react commit3305d8d618
Author: Sense T <me@sense-t.eu.org> Date: Sat Apr 13 10:30:02 2024 +0800 swagger to be done commit2c754e7eec
Author: Sense T <me@sense-t.eu.org> Date: Sat Apr 13 10:14:45 2024 +0800 validate 'em ! commit7b529ad8f6
Author: Sense T <me@sense-t.eu.org> Date: Sat Apr 13 09:22:27 2024 +0800 try to avoid nil point panic commit0012a697cb
Author: Sense T <me@sense-t.eu.org> Date: Fri Apr 12 22:26:35 2024 +0800 fix some bug commita098d3056c
Author: Sense T <me@sense-t.eu.org> Date: Fri Apr 12 20:03:34 2024 +0800 web debug done commit01765c4e7f
Author: Sense T <me@sense-t.eu.org> Date: Fri Apr 12 15:16:52 2024 +0800 all tsx used, no vue SFC commit731504ae82
Author: Sense T <me@sense-t.eu.org> Date: Thu Apr 11 22:05:58 2024 +0800 tsx used - stage 2 commitb669a3e68e
Author: Sense T <me@sense-t.eu.org> Date: Thu Apr 11 16:18:11 2024 +0800 use tsx for compoents stage 1 commit2ab1b0bf1b
Author: Sense T <me@sense-t.eu.org> Date: Thu Apr 11 12:10:57 2024 +0800 rr validation commit58c66fc3a8
Author: Sense T <me@sense-t.eu.org> Date: Thu Apr 11 11:41:33 2024 +0800 stage 1 commit7a5fcf1972
Author: Sense T <me@sense-t.eu.org> Date: Thu Apr 11 10:51:50 2024 +0800 long options supported commitc3b80093d2
Author: Sense T <me@sense-t.eu.org> Date: Thu Apr 11 10:51:33 2024 +0800 for develop use commit7f52707323
Author: Sense T <me@sense-t.eu.org> Date: Thu Apr 11 10:51:24 2024 +0800 fix typo commit9cc2696bbe
Author: Sense T <me@sense-t.eu.org> Date: Wed Apr 10 16:53:03 2024 +0800 record data validate done commit5e2ae637a0
Author: Sense T <me@sense-t.eu.org> Date: Wed Apr 10 14:56:15 2024 +0800 end with dot. commited4fee935d
Author: Sense T <me@sense-t.eu.org> Date: Wed Apr 10 13:41:32 2024 +0800 content safety commit29f75938bb
Author: Sense T <me@sense-t.eu.org> Date: Wed Apr 10 13:24:01 2024 +0800 cmd is ok commit9465bb885d
Author: Sense T <me@sense-t.eu.org> Date: Wed Apr 10 11:00:47 2024 +0800 web done commit65bf461d44
Author: Sense T <me@sense-t.eu.org> Date: Wed Apr 10 11:00:38 2024 +0800 use tokei for stat commit61395ab61b
Author: Sense T <me@sense-t.eu.org> Date: Tue Apr 9 21:53:12 2024 +0800 errors handler commit9752e7d9ae
Author: Sense T <me@sense-t.eu.org> Date: Tue Apr 9 21:16:19 2024 +0800 model with generics done commit7dd3af3707
Author: Sense T <me@sense-t.eu.org> Date: Tue Apr 9 16:28:18 2024 +0800 use DAO commit2369734230
Author: Sense T <me@sense-t.eu.org> Date: Tue Apr 9 13:06:31 2024 +0800 dao for future commite18781ba25
Author: Sense T <me@sense-t.eu.org> Date: Tue Apr 9 11:36:34 2024 +0800 DotEnd commit613ef7fdd9
Author: Sense T <me@sense-t.eu.org> Date: Tue Apr 9 10:16:06 2024 +0800 record should endwith . commitc93e8107dc
Author: Sense T <me@sense-t.eu.org> Date: Tue Apr 9 10:06:47 2024 +0800 update regexp commit84e9961f4b
Author: Sense T <me@sense-t.eu.org> Date: Tue Apr 9 08:30:32 2024 +0800 error log commitdb77b0fdb2
Author: Sense T <me@sense-t.eu.org> Date: Tue Apr 9 08:25:25 2024 +0800 no console log commit0c197820a0
Author: Sense T <me@sense-t.eu.org> Date: Tue Apr 9 08:25:01 2024 +0800 use flags for validate commit33c9050653
Author: Sense T <me@sense-t.eu.org> Date: Tue Apr 9 07:58:27 2024 +0800 SOA Email Format commitfb9c78efed
Author: Sense T <me@sense-t.eu.org> Date: Tue Apr 9 00:19:03 2024 +0800 no debug commit1a7bf83cb9
Author: Sense T <me@sense-t.eu.org> Date: Tue Apr 9 00:18:18 2024 +0800 1 commite72de14797
Author: Sense T <me@sense-t.eu.org> Date: Mon Apr 8 17:30:25 2024 +0800 last modal commite884840b7d
Author: Sense T <me@sense-t.eu.org> Date: Mon Apr 8 15:56:03 2024 +0800 add commit36b0384319
Author: Sense T <me@sense-t.eu.org> Date: Mon Apr 8 15:02:55 2024 +0800 delete domain modal done commit753e950fae
Author: Sense T <me@sense-t.eu.org> Date: Mon Apr 8 13:49:11 2024 +0800 add domainRemovemodal commit69613f9b6e
Author: Sense T <me@sense-t.eu.org> Date: Mon Apr 8 13:32:01 2024 +0800 modal needed for edit commit8c0b79066f
Author: Sense T <me@sense-t.eu.org> Date: Mon Apr 8 09:37:32 2024 +0800 base UI commita67b2d7724
Author: Sense T <me@sense-t.eu.org> Date: Sun Apr 7 21:07:20 2024 +0800 route update commit5a266e9e6c
Author: Sense T <me@sense-t.eu.org> Date: Sun Apr 7 14:36:55 2024 +0800 ui base data struct commit3449df913c
Author: Sense T <me@sense-t.eu.org> Date: Sun Apr 7 13:08:45 2024 +0800 web store for dev commit156bf651dd
Author: Sense T <me@sense-t.eu.org> Date: Sun Apr 7 13:08:30 2024 +0800 store friendly commitd90e949472
Author: Sense T <me@sense-t.eu.org> Date: Sun Apr 7 10:08:02 2024 +0800 base code update commit0a20b5a670
Author: Sense T <me@sense-t.eu.org> Date: Sun Apr 7 10:07:26 2024 +0800 metrics commitbdd4866c10
Author: Sense T <me@sense-t.eu.org> Date: Wed Apr 3 22:37:15 2024 +0800 all api done commit8a8ea59b71
Author: Sense T <me@sense-t.eu.org> Date: Wed Apr 3 17:05:12 2024 +0800 1
This commit is contained in:
parent
94a126086e
commit
021ec9c8f6
BIN
.assets/ui.webp
Normal file
BIN
.assets/ui.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 513 KiB |
3
.gitignore
vendored
3
.gitignore
vendored
@ -32,4 +32,7 @@ docs/_book
|
||||
# TODO: where does this rule come from?
|
||||
test/
|
||||
|
||||
node_modules/
|
||||
dist/
|
||||
|
||||
.direnv
|
13
.nixd.json
Normal file
13
.nixd.json
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"formatting": {
|
||||
"command": "nixpkgs-fmt"
|
||||
},
|
||||
"eval": {
|
||||
"target": {
|
||||
"args": [
|
||||
"--expr",
|
||||
"with import <nixpkgs> { };"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
17
.vscode/settings.json
vendored
Normal file
17
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"sqltools.connections": [
|
||||
{
|
||||
"mysqlOptions": {
|
||||
"authProtocol": "default",
|
||||
"enableSsl": "Disabled"
|
||||
},
|
||||
"previewLimit": 50,
|
||||
"server": "mysql.dev",
|
||||
"port": 3306,
|
||||
"driver": "MySQL",
|
||||
"name": "recored-ui",
|
||||
"database": "recoredui",
|
||||
"username": "recoredui"
|
||||
}
|
||||
]
|
||||
}
|
14
Dockerfile
Normal file
14
Dockerfile
Normal file
@ -0,0 +1,14 @@
|
||||
FROM node AS web
|
||||
|
||||
WORKDIR /src
|
||||
COPY . .
|
||||
RUN cd web && npm i && npm run build
|
||||
|
||||
FROM golang as server
|
||||
WORKDIR /src
|
||||
COPY --stage web /src .
|
||||
RUN go get . && go generate ./... && go build .
|
||||
|
||||
FROM scratch
|
||||
COPY --stage server /src/reCoreD-UI .
|
||||
ENTRYPOINT [ '/reCoreD-UI' ]
|
55
README.md
55
README.md
@ -1,3 +1,58 @@
|
||||
# reCoreD-UI
|
||||
|
||||
Web UI for CoreDNS
|
||||
|
||||
## UI
|
||||
|
||||
![ui](.assets/ui.webp)
|
||||
|
||||
## Build
|
||||
|
||||
Install `go` and `npm` first.
|
||||
|
||||
```bash
|
||||
# Build web first
|
||||
(cd web && npm run build)
|
||||
|
||||
# Build server
|
||||
go get .
|
||||
go generate ./...
|
||||
go build .
|
||||
```
|
||||
|
||||
## Running
|
||||
|
||||
Build [coredns](https://coredns.io/) with [mysql](coredns.io/explugins/mysql/) plugin first.
|
||||
|
||||
A mysql server is needed.
|
||||
|
||||
```bash
|
||||
# example
|
||||
export RECORED_MYSQL_DSN="recoredui:A123456a-@tcp(mysql.dev:3306)/recoredui?charset=utf8mb4"
|
||||
./reCoreD-UI config db migrate
|
||||
|
||||
# setup admin user
|
||||
./reCoreD-UI config user -u user -p password
|
||||
|
||||
# setup DNS
|
||||
./reCoreD-UI config dns -s 1.1.1.1 -s 1.2.3.4
|
||||
|
||||
# run server and open http://localhost:3000
|
||||
./reCoreD-UI server
|
||||
```
|
||||
|
||||
```ini
|
||||
# systemd service
|
||||
[Unit]
|
||||
Description=reCoreD-UI
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
# RECORED_MYSQL_DSN="dsn"
|
||||
EnvironmentFile=-/etc/default/recored-ui
|
||||
EnvironmentFile=-/etc/sysconfig/recored-ui
|
||||
ExecStart=/usr/local/bin/reCoreD-UI server
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
12
TODO
12
TODO
@ -1,2 +1,12 @@
|
||||
- [x] metrics
|
||||
- [x] Web UI
|
||||
- [x] i18n
|
||||
- [x] modals
|
||||
- [x] debug
|
||||
- [x] swagger
|
||||
- [x] comments
|
||||
- [] Nix Module
|
||||
- [] RBAC
|
||||
|
||||
v2.0
|
||||
- [] RBAC
|
||||
- [] Audit
|
||||
|
13
cmd/config/config.go
Normal file
13
cmd/config/config.go
Normal file
@ -0,0 +1,13 @@
|
||||
package config
|
||||
|
||||
import "github.com/urfave/cli/v2"
|
||||
|
||||
var Command = &cli.Command{
|
||||
Name: "config",
|
||||
Usage: "config some settings",
|
||||
Subcommands: []*cli.Command{
|
||||
UserCommand,
|
||||
DatabaseCommand,
|
||||
DNSCommand,
|
||||
},
|
||||
}
|
33
cmd/config/database.go
Normal file
33
cmd/config/database.go
Normal file
@ -0,0 +1,33 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"reCoreD-UI/controllers"
|
||||
"reCoreD-UI/database"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
var migrationCommand = &cli.Command{
|
||||
Name: "migrate",
|
||||
Usage: "migrate database",
|
||||
Action: migrateDatabase,
|
||||
}
|
||||
|
||||
var DatabaseCommand = &cli.Command{
|
||||
Name: "database",
|
||||
Usage: "database administration",
|
||||
Aliases: []string{"db"},
|
||||
Subcommands: []*cli.Command{
|
||||
migrationCommand,
|
||||
},
|
||||
}
|
||||
|
||||
func migrateDatabase(c *cli.Context) error {
|
||||
if err := database.Connect(c.String("mysql-dsn")); err != nil {
|
||||
return err
|
||||
}
|
||||
if c.Bool("debug") {
|
||||
database.Client = database.Client.Debug()
|
||||
}
|
||||
return controllers.Migrate()
|
||||
}
|
33
cmd/config/dns.go
Normal file
33
cmd/config/dns.go
Normal file
@ -0,0 +1,33 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"reCoreD-UI/controllers"
|
||||
"reCoreD-UI/database"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/urfave/cli/v2/altsrc"
|
||||
)
|
||||
|
||||
var DNSCommand = &cli.Command{
|
||||
Name: "dns",
|
||||
Usage: "Config DNS Settings",
|
||||
Flags: []cli.Flag{
|
||||
altsrc.NewStringSliceFlag(&cli.StringSliceFlag{
|
||||
Name: "servers",
|
||||
Usage: "dns servers",
|
||||
Aliases: []string{"s"},
|
||||
Required: true,
|
||||
}),
|
||||
},
|
||||
Action: setDNS,
|
||||
}
|
||||
|
||||
func setDNS(c *cli.Context) error {
|
||||
if err := database.Connect(c.String("mysql-dsn")); err != nil {
|
||||
return err
|
||||
}
|
||||
if c.Bool("debug") {
|
||||
database.Client = database.Client.Debug()
|
||||
}
|
||||
return controllers.SetupDNS(c.StringSlice("servers")...)
|
||||
}
|
41
cmd/config/user.go
Normal file
41
cmd/config/user.go
Normal file
@ -0,0 +1,41 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"reCoreD-UI/controllers"
|
||||
"reCoreD-UI/database"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/urfave/cli/v2/altsrc"
|
||||
)
|
||||
|
||||
var UserCommand = &cli.Command{
|
||||
Name: "user",
|
||||
Usage: "set admin username and password",
|
||||
Flags: []cli.Flag{
|
||||
altsrc.NewStringFlag(&cli.StringFlag{
|
||||
Name: "username",
|
||||
Aliases: []string{"u"},
|
||||
Value: "amdin",
|
||||
Usage: "admin username",
|
||||
EnvVars: []string{"RECORED_ADMIN_USERNAME"},
|
||||
}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{
|
||||
Name: "password",
|
||||
Aliases: []string{"p"},
|
||||
Required: true,
|
||||
Usage: "admin password",
|
||||
EnvVars: []string{"RECORED_ADMIN_PASSWORD"},
|
||||
}),
|
||||
},
|
||||
Action: setUser,
|
||||
}
|
||||
|
||||
func setUser(c *cli.Context) error {
|
||||
if err := database.Connect(c.String("mysql-dsn")); err != nil {
|
||||
return err
|
||||
}
|
||||
if c.Bool("debug") {
|
||||
database.Client = database.Client.Debug()
|
||||
}
|
||||
return controllers.SetupAdmin(c.String("username"), c.String("password"))
|
||||
}
|
39
cmd/server/server.go
Normal file
39
cmd/server/server.go
Normal file
@ -0,0 +1,39 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
webserver "reCoreD-UI/server"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/urfave/cli/v2/altsrc"
|
||||
)
|
||||
|
||||
var Command = &cli.Command{
|
||||
Name: "server",
|
||||
Usage: "run server",
|
||||
Flags: []cli.Flag{
|
||||
altsrc.NewStringFlag(&cli.StringFlag{
|
||||
Name: "prefix",
|
||||
Value: "/",
|
||||
Usage: "web prefix",
|
||||
}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{
|
||||
Name: "listen",
|
||||
Value: "::",
|
||||
Usage: "IP for listen at",
|
||||
}),
|
||||
altsrc.NewIntFlag(&cli.IntFlag{
|
||||
Name: "port",
|
||||
Value: 3000,
|
||||
Usage: "Port for listen at",
|
||||
}),
|
||||
},
|
||||
Action: runServer,
|
||||
}
|
||||
|
||||
func runServer(c *cli.Context) error {
|
||||
server, err := webserver.NewServer(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return server.Run()
|
||||
}
|
156
controllers/domain.go
Normal file
156
controllers/domain.go
Normal file
@ -0,0 +1,156 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reCoreD-UI/database"
|
||||
"reCoreD-UI/models"
|
||||
"strconv"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type domainsDAO struct {
|
||||
database.BaseDAO[models.IDomain]
|
||||
}
|
||||
|
||||
func CreateDomain(d *models.Domain) (*models.Domain, error) {
|
||||
nss, err := GetDNS()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tx := database.Client.Begin()
|
||||
if _, err := (domainsDAO{}).Create(tx, d); err != nil {
|
||||
tx.Rollback()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
r := &models.Record[models.SOARecord]{}
|
||||
r.Zone = d.WithDotEnd()
|
||||
r.Name = "@"
|
||||
r.RecordType = models.RecordTypeSOA
|
||||
r.Content = d.GenerateSOA()
|
||||
logrus.Debug(r)
|
||||
if err := r.CheckZone(); err != nil {
|
||||
tx.Rollback()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if _, err := (recordsDAO{}).Create(tx, r); err != nil {
|
||||
tx.Rollback()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for i, ns := range nss {
|
||||
record := &models.Record[models.NSRecord]{
|
||||
Zone: d.WithDotEnd(),
|
||||
RecordType: models.RecordTypeNS,
|
||||
Name: fmt.Sprintf("ns%d", i+1),
|
||||
}
|
||||
record.Content.Host = ns
|
||||
|
||||
if _, err := (recordsDAO{}).Create(tx, record); err != nil {
|
||||
tx.Rollback()
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return d, tx.Commit().Error
|
||||
}
|
||||
|
||||
func GetDomains(domain string) ([]models.Domain, error) {
|
||||
if domain != "" {
|
||||
r, err := (domainsDAO{}).GetAll(database.Client, &models.Domain{DomainName: domain})
|
||||
n := make([]models.Domain, 0)
|
||||
for _, e := range r {
|
||||
i, ok := e.(*models.Domain)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
n = append(n, *i)
|
||||
}
|
||||
return n, err
|
||||
} else {
|
||||
r, err := (domainsDAO{}).GetAll(database.Client, &models.Domain{})
|
||||
n := make([]models.Domain, 0)
|
||||
for _, e := range r {
|
||||
i, ok := e.(*models.Domain)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
n = append(n, *i)
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
}
|
||||
|
||||
func UpdateDomain(d *models.Domain) error {
|
||||
tx := database.Client.Begin()
|
||||
logrus.Debug(d)
|
||||
if _, err := (domainsDAO{}).Update(tx, d); err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
soa, err := (recordsDAO{}).GetOne(tx, &models.Record[models.RecordContentDefault]{
|
||||
RecordType: models.RecordTypeSOA, Zone: d.WithDotEnd(),
|
||||
})
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
r := &models.Record[models.SOARecord]{}
|
||||
if err := r.FromEntity(soa); err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
r.Content = d.GenerateSOA()
|
||||
if err := r.CheckZone(); err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := (recordsDAO{}).Update(tx, r); err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Commit().Error
|
||||
}
|
||||
|
||||
func DeleteDomain(id string) error {
|
||||
ID, err := strconv.Atoi(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tx := database.Client.Begin()
|
||||
domain, err := (domainsDAO{}).GetOne(tx, &models.Domain{ID: uint(ID)})
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
if err := (domainsDAO{}).Delete(tx, &models.Domain{ID: uint(ID)}); err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
if err := (recordsDAO{}).Delete(tx, &models.Record[models.RecordContentDefault]{}, &models.Record[models.RecordContentDefault]{Zone: domain.WithDotEnd()}); err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Commit().Error
|
||||
}
|
||||
|
||||
// for metrics
|
||||
func getDomainCounts() (float64, error) {
|
||||
c, err := (domainsDAO{}).GetAll(database.Client, &models.Domain{})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return float64(len(c)), nil
|
||||
}
|
61
controllers/metrics.go
Normal file
61
controllers/metrics.go
Normal file
@ -0,0 +1,61 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
ginprometheus "github.com/zsais/go-gin-prometheus"
|
||||
ormMetric "gorm.io/plugin/prometheus"
|
||||
)
|
||||
|
||||
var (
|
||||
GaugeDomainCounts = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Namespace: "recoredui",
|
||||
Subsystem: "domains",
|
||||
Name: "count",
|
||||
Help: "domains managed in reCoreD-UI",
|
||||
})
|
||||
|
||||
GaugeRecordCounts = prometheus.NewGaugeVec(prometheus.GaugeOpts{
|
||||
Namespace: "recoredui",
|
||||
Subsystem: "records",
|
||||
Name: "count",
|
||||
Help: "records managed in reCoreD-UI, by domain",
|
||||
}, []string{"domain"})
|
||||
)
|
||||
|
||||
func RegisterMetrics() {
|
||||
prometheus.MustRegister(GaugeDomainCounts, GaugeRecordCounts)
|
||||
|
||||
GormMetrics := ormMetric.New(ormMetric.Config{
|
||||
DBName: "recored-ui",
|
||||
MetricsCollector: []ormMetric.MetricsCollector{
|
||||
&ormMetric.MySQL{
|
||||
VariableNames: []string{"Threads_running"},
|
||||
},
|
||||
},
|
||||
}).Collectors
|
||||
prometheus.MustRegister(GormMetrics...)
|
||||
|
||||
GinMetrics := ginprometheus.NewPrometheus("recoredui")
|
||||
for _, v := range GinMetrics.MetricsList {
|
||||
prometheus.Register(v.MetricCollector)
|
||||
}
|
||||
}
|
||||
|
||||
func RefreshMetrics() error {
|
||||
domainCounts, err := getDomainCounts()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
GaugeDomainCounts.Set(domainCounts)
|
||||
|
||||
recordCounts, err := getRecordCounts()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for domain, counts := range recordCounts {
|
||||
GaugeRecordCounts.WithLabelValues(domain).Set(counts)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
22
controllers/migrate.go
Normal file
22
controllers/migrate.go
Normal file
@ -0,0 +1,22 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"reCoreD-UI/database"
|
||||
"reCoreD-UI/models"
|
||||
)
|
||||
|
||||
func Migrate() error {
|
||||
if err := (domainsDAO{}).Migrate(database.Client, &models.Domain{}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := (recordsDAO{}).Migrate(database.Client, &models.Record[models.RecordContentDefault]{Content: make(models.RecordContentDefault)}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := (settingsDAO{}).Migrate(database.Client, &models.Settings{}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
106
controllers/record.go
Normal file
106
controllers/record.go
Normal file
@ -0,0 +1,106 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reCoreD-UI/database"
|
||||
"reCoreD-UI/models"
|
||||
"strconv"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type recordsDAO struct {
|
||||
database.BaseDAO[models.IRecord]
|
||||
}
|
||||
|
||||
func CreateRecord(r models.IRecord) (models.IRecord, error) {
|
||||
if r.GetType() != models.RecordTypeSOA {
|
||||
_, err := GetDomains(r.WithOutDotTail())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if err := r.CheckZone(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res, err := (recordsDAO{}).Create(database.Client, r)
|
||||
return res, err
|
||||
}
|
||||
|
||||
func CreateRecords(rs []models.IRecord) error {
|
||||
tx := database.Client.Begin()
|
||||
for _, r := range rs {
|
||||
if err := r.CheckZone(); err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := (recordsDAO{}).Create(tx, r); err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit().Error
|
||||
}
|
||||
|
||||
func GetRecords(cond models.IRecord) ([]models.IRecord, error) {
|
||||
return (recordsDAO{}).GetAll(database.Client, cond)
|
||||
}
|
||||
|
||||
func UpdateRecord(r models.IRecord) error {
|
||||
if err := r.CheckZone(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := (recordsDAO{}).Update(database.Client, r); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func DeleteRecord(domain, id string) error {
|
||||
ID, err := strconv.Atoi(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tx := database.Client.Begin()
|
||||
record, err := (recordsDAO{}).GetOne(tx, &models.Record[models.RecordContentDefault]{ID: uint(ID), Zone: fmt.Sprintf("%s.", domain)})
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
if record.GetType() == models.RecordTypeSOA {
|
||||
tx.Rollback()
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
|
||||
if err := (recordsDAO{}).Delete(tx, record); err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Commit().Error
|
||||
}
|
||||
|
||||
// for metrics
|
||||
func getRecordCounts() (map[string]float64, error) {
|
||||
rows, err := (recordsDAO{}).GetAll(database.Client, &models.Record[models.RecordContentDefault]{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make(map[string]float64)
|
||||
for _, row := range rows {
|
||||
record := &models.Record[models.RecordContentDefault]{}
|
||||
if err := record.FromEntity(row); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[record.Zone] += 1
|
||||
}
|
||||
return result, nil
|
||||
}
|
89
controllers/settings.go
Normal file
89
controllers/settings.go
Normal file
@ -0,0 +1,89 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reCoreD-UI/database"
|
||||
"reCoreD-UI/models"
|
||||
"strings"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const dnsSep = ","
|
||||
|
||||
type settingsDAO struct {
|
||||
database.BaseDAO[models.ISettings]
|
||||
}
|
||||
|
||||
func SetupDNS(dns ...string) error {
|
||||
settings := models.Settings{Key: models.SettingsKeyDNSServer, Value: strings.Join(dns, dnsSep)}
|
||||
|
||||
if _, err := (settingsDAO{}).UpdateOrCreate(database.Client, &settings, &models.Settings{Key: models.SettingsKeyDNSServer}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetDNS() ([]string, error) {
|
||||
settings, err := (settingsDAO{}).GetOne(database.Client, &models.Settings{Key: models.SettingsKeyDNSServer})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d, ok := settings.(*models.Settings)
|
||||
if !ok {
|
||||
return nil, errors.New("cannot get dns config")
|
||||
}
|
||||
|
||||
return strings.Split(d.Value, dnsSep), nil
|
||||
}
|
||||
|
||||
func SetupAdmin(username, password string) error {
|
||||
logrus.Debugf("got %s: %s", username, password)
|
||||
|
||||
settingUsername := models.Settings{
|
||||
Key: models.SettingsKeyAdminUsername,
|
||||
Value: username,
|
||||
}
|
||||
settingPassword := models.Settings{
|
||||
Key: models.SettingsKeyAdminPassword,
|
||||
Value: password,
|
||||
}
|
||||
|
||||
tx := database.Client.Begin()
|
||||
if _, err := (settingsDAO{}).UpdateOrCreate(tx, &settingUsername, &models.Settings{Key: models.SettingsKeyAdminUsername}); err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := (settingsDAO{}).UpdateOrCreate(tx, &settingPassword, &models.Settings{Key: models.SettingsKeyAdminPassword}); err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Commit().Error
|
||||
}
|
||||
|
||||
func GetAdmin() (string, string, error) {
|
||||
settings, err := (settingsDAO{}).GetOne(database.Client, &models.Settings{Key: models.SettingsKeyAdminUsername})
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
u, ok := settings.(*models.Settings)
|
||||
if !ok {
|
||||
return "", "", errors.New("cannot get admin username")
|
||||
}
|
||||
username := u.Value
|
||||
|
||||
settings, err = (settingsDAO{}).GetOne(database.Client, &models.Settings{Key: models.SettingsKeyAdminPassword})
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
p, ok := settings.(*models.Settings)
|
||||
password := p.Value
|
||||
|
||||
if !ok {
|
||||
return "", "", errors.New("cannot get admin password")
|
||||
}
|
||||
return username, password, nil
|
||||
}
|
119
database/basedao.go
Normal file
119
database/basedao.go
Normal file
@ -0,0 +1,119 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
clone "github.com/huandu/go-clone/generic"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type BaseDAO[T any] struct{}
|
||||
|
||||
func (b BaseDAO[T]) Migrate(db *gorm.DB, e T) error {
|
||||
return db.Set("gorm:table_options", "CHARSET=utf8mb4").AutoMigrate(e)
|
||||
}
|
||||
|
||||
func (BaseDAO[T]) GetAll(db *gorm.DB, e T, cond ...T) ([]T, error) {
|
||||
var r []T
|
||||
tx := db.Model(e)
|
||||
for _, c := range cond {
|
||||
tx = tx.Where(c)
|
||||
}
|
||||
|
||||
rows, err := tx.Rows()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
if err := db.ScanRows(rows, e); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
i := clone.Clone(e)
|
||||
|
||||
r = append(r, i)
|
||||
}
|
||||
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (BaseDAO[T]) GetOne(db *gorm.DB, e T, cond ...T) (T, error) {
|
||||
tx := db
|
||||
for _, c := range cond {
|
||||
tx = tx.Where(c)
|
||||
}
|
||||
|
||||
if err := tx.First(e).Error; err != nil {
|
||||
return e, err
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
|
||||
func (BaseDAO[T]) Create(db *gorm.DB, e T) (T, error) {
|
||||
if err := db.Create(e).Error; err != nil {
|
||||
return e, err
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
|
||||
func (BaseDAO[T]) FirstOrCreate(db *gorm.DB, e T) (T, error) {
|
||||
if err := db.FirstOrCreate(&e).Error; err != nil {
|
||||
return e, err
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
|
||||
func (BaseDAO[T]) Update(db *gorm.DB, e T, cond ...T) (T, error) {
|
||||
tx := db.Model(e)
|
||||
for _, c := range cond {
|
||||
tx = tx.Where(c)
|
||||
}
|
||||
|
||||
result := tx.Updates(e)
|
||||
|
||||
if err := result.Error; err != nil {
|
||||
return e, err
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return e, gorm.ErrRecordNotFound
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
|
||||
func (b BaseDAO[T]) UpdateOrCreate(db *gorm.DB, e T, cond ...T) (T, error) {
|
||||
_, err := b.Update(db, e, cond...)
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
_, err = b.Create(db, e)
|
||||
}
|
||||
return e, err
|
||||
}
|
||||
|
||||
func (BaseDAO[T]) Delete(db *gorm.DB, e T, cond ...T) error {
|
||||
tx := db
|
||||
for _, c := range cond {
|
||||
tx = tx.Where(c)
|
||||
}
|
||||
|
||||
if err := tx.Delete(e).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type IBaseDAO interface {
|
||||
Migrate()
|
||||
|
||||
GetAll()
|
||||
GetOne()
|
||||
GetSome()
|
||||
|
||||
Create()
|
||||
FirstOrCreate()
|
||||
|
||||
Update()
|
||||
UpdateOrCreate()
|
||||
|
||||
Delete()
|
||||
}
|
19
database/database.go
Normal file
19
database/database.go
Normal file
@ -0,0 +1,19 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var Client *gorm.DB
|
||||
|
||||
func Connect(DSN string) error {
|
||||
var err error
|
||||
Client, err = gorm.Open(mysql.Open(DSN), &gorm.Config{
|
||||
SkipDefaultTransaction: true,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
1069
docs/docs.go
Normal file
1069
docs/docs.go
Normal file
File diff suppressed because it is too large
Load Diff
1044
docs/swagger.json
Normal file
1044
docs/swagger.json
Normal file
File diff suppressed because it is too large
Load Diff
573
docs/swagger.yaml
Normal file
573
docs/swagger.yaml
Normal file
@ -0,0 +1,573 @@
|
||||
basePath: /api/v1
|
||||
definitions:
|
||||
models.Domain:
|
||||
properties:
|
||||
admin_email:
|
||||
type: string
|
||||
domain_name:
|
||||
type: string
|
||||
expiry_period:
|
||||
type: integer
|
||||
id:
|
||||
type: integer
|
||||
main_dns:
|
||||
type: string
|
||||
negative_ttl:
|
||||
type: integer
|
||||
refresh_interval:
|
||||
type: integer
|
||||
retry_interval:
|
||||
type: integer
|
||||
serial_number:
|
||||
type: integer
|
||||
type: object
|
||||
models.Record-models_RecordContentDefault:
|
||||
properties:
|
||||
content:
|
||||
allOf:
|
||||
- $ref: '#/definitions/models.RecordContentDefault'
|
||||
description: see https://github.com/cloud66-oss/coredns_mysql/blob/main/types.go
|
||||
for content
|
||||
id:
|
||||
type: integer
|
||||
name:
|
||||
type: string
|
||||
record_type:
|
||||
type: string
|
||||
ttl:
|
||||
type: integer
|
||||
zone:
|
||||
type: string
|
||||
type: object
|
||||
models.RecordContentDefault:
|
||||
additionalProperties: {}
|
||||
type: object
|
||||
server.Response:
|
||||
properties:
|
||||
data:
|
||||
description: payload here
|
||||
message:
|
||||
description: error message
|
||||
type: string
|
||||
succeed:
|
||||
description: '`true` for 2xx, else `false`'
|
||||
type: boolean
|
||||
type: object
|
||||
info:
|
||||
contact: {}
|
||||
description: APIs for reCoreD-UI
|
||||
title: reCoreD-UI API
|
||||
version: "1.0"
|
||||
paths:
|
||||
/domains/:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
description: List all domains
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/definitions/server.Response'
|
||||
- properties:
|
||||
data:
|
||||
items:
|
||||
$ref: '#/definitions/models.Domain'
|
||||
type: array
|
||||
type: object
|
||||
"401":
|
||||
description: Unauthorized
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/definitions/server.Response'
|
||||
- properties:
|
||||
data:
|
||||
type: object
|
||||
type: object
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/definitions/server.Response'
|
||||
- properties:
|
||||
data:
|
||||
type: object
|
||||
type: object
|
||||
summary: List all domains
|
||||
tags:
|
||||
- domains
|
||||
post:
|
||||
description: Create a domain
|
||||
parameters:
|
||||
- description: content
|
||||
in: body
|
||||
name: object
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/models.Domain'
|
||||
responses:
|
||||
"201":
|
||||
description: Created
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/definitions/server.Response'
|
||||
- properties:
|
||||
data:
|
||||
$ref: '#/definitions/models.Domain'
|
||||
type: object
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/definitions/server.Response'
|
||||
- properties:
|
||||
data:
|
||||
type: object
|
||||
type: object
|
||||
"401":
|
||||
description: Unauthorized
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/definitions/server.Response'
|
||||
- properties:
|
||||
data:
|
||||
type: object
|
||||
type: object
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/definitions/server.Response'
|
||||
- properties:
|
||||
data:
|
||||
type: object
|
||||
type: object
|
||||
summary: Create a domain
|
||||
tags:
|
||||
- domains
|
||||
put:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Update a domain
|
||||
parameters:
|
||||
- description: content
|
||||
in: body
|
||||
name: object
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/models.Domain'
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/definitions/server.Response'
|
||||
- properties:
|
||||
data:
|
||||
$ref: '#/definitions/models.Domain'
|
||||
type: object
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/definitions/server.Response'
|
||||
- properties:
|
||||
data:
|
||||
type: object
|
||||
type: object
|
||||
"401":
|
||||
description: Unauthorized
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/definitions/server.Response'
|
||||
- properties:
|
||||
data:
|
||||
type: object
|
||||
type: object
|
||||
"404":
|
||||
description: Not Found
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/definitions/server.Response'
|
||||
- properties:
|
||||
data:
|
||||
type: object
|
||||
type: object
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/definitions/server.Response'
|
||||
- properties:
|
||||
data:
|
||||
type: object
|
||||
type: object
|
||||
summary: Update a domain
|
||||
tags:
|
||||
- domains
|
||||
/domains/{id}:
|
||||
delete:
|
||||
description: Delete a domain
|
||||
parameters:
|
||||
- description: Domain ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: integer
|
||||
responses:
|
||||
"204":
|
||||
description: No Content
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/definitions/server.Response'
|
||||
- properties:
|
||||
data:
|
||||
type: object
|
||||
type: object
|
||||
"401":
|
||||
description: Unauthorized
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/definitions/server.Response'
|
||||
- properties:
|
||||
data:
|
||||
type: object
|
||||
type: object
|
||||
"404":
|
||||
description: Not Found
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/definitions/server.Response'
|
||||
- properties:
|
||||
data:
|
||||
type: object
|
||||
type: object
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/definitions/server.Response'
|
||||
- properties:
|
||||
data:
|
||||
type: object
|
||||
type: object
|
||||
summary: Delete a domain
|
||||
tags:
|
||||
- domains
|
||||
/records/{domain}:
|
||||
get:
|
||||
description: List all records of a domain
|
||||
parameters:
|
||||
- description: domain
|
||||
in: path
|
||||
name: domain
|
||||
required: true
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/definitions/server.Response'
|
||||
- properties:
|
||||
data:
|
||||
items:
|
||||
$ref: '#/definitions/models.Record-models_RecordContentDefault'
|
||||
type: array
|
||||
type: object
|
||||
"401":
|
||||
description: Unauthorized
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/definitions/server.Response'
|
||||
- properties:
|
||||
data:
|
||||
type: object
|
||||
type: object
|
||||
"404":
|
||||
description: Not Found
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/definitions/server.Response'
|
||||
- properties:
|
||||
data:
|
||||
type: object
|
||||
type: object
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/definitions/server.Response'
|
||||
- properties:
|
||||
data:
|
||||
type: object
|
||||
type: object
|
||||
summary: List all records of a domain
|
||||
tags:
|
||||
- records
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Create a record of a domain
|
||||
parameters:
|
||||
- description: domain
|
||||
in: path
|
||||
name: domain
|
||||
required: true
|
||||
type: string
|
||||
- description: content
|
||||
in: body
|
||||
name: object
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/models.Record-models_RecordContentDefault'
|
||||
responses:
|
||||
"201":
|
||||
description: Created
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/definitions/server.Response'
|
||||
- properties:
|
||||
data:
|
||||
$ref: '#/definitions/models.Record-models_RecordContentDefault'
|
||||
type: object
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/definitions/server.Response'
|
||||
- properties:
|
||||
data:
|
||||
type: object
|
||||
type: object
|
||||
"401":
|
||||
description: Unauthorized
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/definitions/server.Response'
|
||||
- properties:
|
||||
data:
|
||||
type: object
|
||||
type: object
|
||||
"404":
|
||||
description: Not Found
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/definitions/server.Response'
|
||||
- properties:
|
||||
data:
|
||||
type: object
|
||||
type: object
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/definitions/server.Response'
|
||||
- properties:
|
||||
data:
|
||||
type: object
|
||||
type: object
|
||||
summary: Create a record of a domain
|
||||
tags:
|
||||
- records
|
||||
put:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Update a record of a domain
|
||||
parameters:
|
||||
- description: domain
|
||||
in: path
|
||||
name: domain
|
||||
required: true
|
||||
type: string
|
||||
- description: content
|
||||
in: body
|
||||
name: object
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/models.Record-models_RecordContentDefault'
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/definitions/server.Response'
|
||||
- properties:
|
||||
data:
|
||||
$ref: '#/definitions/models.Record-models_RecordContentDefault'
|
||||
type: object
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/definitions/server.Response'
|
||||
- properties:
|
||||
data:
|
||||
type: object
|
||||
type: object
|
||||
"401":
|
||||
description: Unauthorized
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/definitions/server.Response'
|
||||
- properties:
|
||||
data:
|
||||
type: object
|
||||
type: object
|
||||
"404":
|
||||
description: Not Found
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/definitions/server.Response'
|
||||
- properties:
|
||||
data:
|
||||
type: object
|
||||
type: object
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/definitions/server.Response'
|
||||
- properties:
|
||||
data:
|
||||
type: object
|
||||
type: object
|
||||
summary: Update a record of a domain
|
||||
tags:
|
||||
- records
|
||||
/records/{domain}/{id}:
|
||||
delete:
|
||||
description: Delete a record of a domain, except SOA record.
|
||||
parameters:
|
||||
- description: domain
|
||||
in: path
|
||||
name: domain
|
||||
required: true
|
||||
type: string
|
||||
- description: Record ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: integer
|
||||
responses:
|
||||
"204":
|
||||
description: No Content
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/definitions/server.Response'
|
||||
- properties:
|
||||
data:
|
||||
type: object
|
||||
type: object
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/definitions/server.Response'
|
||||
- properties:
|
||||
data:
|
||||
type: object
|
||||
type: object
|
||||
"401":
|
||||
description: Unauthorized
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/definitions/server.Response'
|
||||
- properties:
|
||||
data:
|
||||
type: object
|
||||
type: object
|
||||
"404":
|
||||
description: Not Found
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/definitions/server.Response'
|
||||
- properties:
|
||||
data:
|
||||
type: object
|
||||
type: object
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/definitions/server.Response'
|
||||
- properties:
|
||||
data:
|
||||
type: object
|
||||
type: object
|
||||
summary: Delete a record of a domain
|
||||
tags:
|
||||
- records
|
||||
/records/{domain}/bulk:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Create some records of a domain
|
||||
parameters:
|
||||
- description: domain
|
||||
in: path
|
||||
name: domain
|
||||
required: true
|
||||
type: string
|
||||
- description: content
|
||||
in: body
|
||||
name: object
|
||||
required: true
|
||||
schema:
|
||||
items:
|
||||
$ref: '#/definitions/models.Record-models_RecordContentDefault'
|
||||
type: array
|
||||
responses:
|
||||
"201":
|
||||
description: Created
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/definitions/server.Response'
|
||||
- properties:
|
||||
data:
|
||||
$ref: '#/definitions/models.Record-models_RecordContentDefault'
|
||||
type: object
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/definitions/server.Response'
|
||||
- properties:
|
||||
data:
|
||||
type: object
|
||||
type: object
|
||||
"401":
|
||||
description: Unauthorized
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/definitions/server.Response'
|
||||
- properties:
|
||||
data:
|
||||
type: object
|
||||
type: object
|
||||
"404":
|
||||
description: Not Found
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/definitions/server.Response'
|
||||
- properties:
|
||||
data:
|
||||
type: object
|
||||
type: object
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/definitions/server.Response'
|
||||
- properties:
|
||||
data:
|
||||
type: object
|
||||
type: object
|
||||
summary: Create some records of a domain
|
||||
tags:
|
||||
- records
|
||||
securityDefinitions:
|
||||
BasicAuth:
|
||||
type: basic
|
||||
swagger: "2.0"
|
36
flake.lock
36
flake.lock
@ -1,39 +1,6 @@
|
||||
{
|
||||
"nodes": {
|
||||
"naersk": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1698420672,
|
||||
"narHash": "sha256-/TdeHMPRjjdJub7p7+w55vyABrsJlt5QkznPYy55vKA=",
|
||||
"owner": "nix-community",
|
||||
"repo": "naersk",
|
||||
"rev": "aeb58d5e8faead8980a807c840232697982d47b9",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-community",
|
||||
"ref": "master",
|
||||
"repo": "naersk",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1711715736,
|
||||
"narHash": "sha256-9slQ609YqT9bT/MNX9+5k5jltL9zgpn36DpFB7TkttM=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "807c549feabce7eddbf259dbdcec9e0600a0660d",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"id": "nixpkgs",
|
||||
"type": "indirect"
|
||||
}
|
||||
},
|
||||
"nixpkgs_2": {
|
||||
"locked": {
|
||||
"lastModified": 1711715736,
|
||||
"narHash": "sha256-9slQ609YqT9bT/MNX9+5k5jltL9zgpn36DpFB7TkttM=",
|
||||
@ -51,8 +18,7 @@
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"naersk": "naersk",
|
||||
"nixpkgs": "nixpkgs_2",
|
||||
"nixpkgs": "nixpkgs",
|
||||
"utils": "utils"
|
||||
}
|
||||
},
|
||||
|
87
flake.nix
87
flake.nix
@ -1,49 +1,44 @@
|
||||
{
|
||||
nixConfig = rec {
|
||||
experimental-features = [ "nix-command" "flakes" ];
|
||||
|
||||
substituters = [
|
||||
# Replace official cache with a mirror located in China
|
||||
#
|
||||
# Feel free to remove this line if you are not in China
|
||||
"https://mirrors.ustc.edu.cn/nix-channels/store"
|
||||
"https://mirrors.ustc.edu.cn/nix-channels/store" # 中科大
|
||||
"https://mirrors.tuna.tsinghua.edu.cn/nix-channels/store" #清华
|
||||
"https://mirrors.bfsu.edu.cn/nix-channels/store" # 北外
|
||||
"https://mirror.sjtu.edu.cn/nix-channels/store" #交大
|
||||
#"https://cache.nixos.org"
|
||||
];
|
||||
trusted-substituters = substituters;
|
||||
trusted-users = [
|
||||
"coder"
|
||||
];
|
||||
};
|
||||
|
||||
inputs = {
|
||||
naersk.url = "github:nix-community/naersk/master";
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
|
||||
utils.url = "github:numtide/flake-utils";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, utils, naersk }:
|
||||
outputs = { self, nixpkgs, utils }:
|
||||
utils.lib.eachDefaultSystem (system:
|
||||
let
|
||||
pkgs = import nixpkgs { inherit system; };
|
||||
naersk-lib = pkgs.callPackage naersk { };
|
||||
in
|
||||
{
|
||||
defaultPackage = naersk-lib.buildPackage {
|
||||
src = ./.;
|
||||
buildInputs = with pkgs; [
|
||||
|
||||
];
|
||||
packages = rec {
|
||||
recored-ui = with pkgs; stdenv.mkDerivation rec {
|
||||
name = "recored-ui";
|
||||
src = self;
|
||||
buildInputs = [
|
||||
go
|
||||
nodejs
|
||||
];
|
||||
buildPhase = ''
|
||||
cd web && npm i && npm run build && cd ..
|
||||
go get . && go generate ./... && go build . -o recored-ui -ldflags "-s -w"
|
||||
'';
|
||||
installPhase = ''
|
||||
mkdir -p $out/bin
|
||||
cp recored-ui $out/bin
|
||||
'';
|
||||
};
|
||||
default = recored-ui;
|
||||
};
|
||||
|
||||
devShell = with pkgs; mkShell {
|
||||
buildInputs = [
|
||||
go
|
||||
nodejs
|
||||
dig
|
||||
tokei
|
||||
];
|
||||
GOPATH = "/home/coder/.cache/go";
|
||||
RECORED_MYSQL_DSN = "recoredui:A123456a-@tcp(mysql.dev:3306)/recoredui?charset=utf8mb4";
|
||||
};
|
||||
|
||||
nixosModule = { config, pkgs, lib, ... }: with lib;
|
||||
@ -54,42 +49,28 @@
|
||||
options.services.hangitbot = {
|
||||
enable = mkEnableOption "reCoreD-UI service";
|
||||
|
||||
token = mkOption {
|
||||
mysql-dsn = mkOption {
|
||||
type = types.str;
|
||||
example = "12345678:AAAAAAAAAAAAAAAAAAAAAAAAATOKEN";
|
||||
description = lib.mdDoc "Telegram bot token";
|
||||
};
|
||||
|
||||
tgUri = mkOption {
|
||||
type = types.str;
|
||||
default = "https://api.telegram.org";
|
||||
example = "https://api.telegram.org";
|
||||
description = lib.mdDoc "Custom telegram api URI";
|
||||
};
|
||||
|
||||
groupBanned = mkOption {
|
||||
type = types.listOf types.int;
|
||||
default = [ ];
|
||||
description = lib.mdDoc "GroupID blacklisted";
|
||||
example = "recoredui:A123456a-@tcp(mysql.dev:3306)/recoredui?charset=utf8mb4";
|
||||
description = lib.mdDoc "mysql connection DSN";
|
||||
};
|
||||
|
||||
extraOptions = mkOption {
|
||||
type = types.str;
|
||||
description = lib.mdDoc "Extra option for bot.";
|
||||
description = lib.mdDoc "Extra options";
|
||||
default = "";
|
||||
};
|
||||
};
|
||||
|
||||
config =
|
||||
let
|
||||
args = "${cfg.extraOptions} ${if cfg?tgUri then "--api-uri ${escapeShellArg cfg.tgUri}" else ""} ${if cfg?groupBanned then concatStringsSep " " (lists.concatMap (group: ["-b ${group}"]) cfg.groupBanned) else ""}";
|
||||
in
|
||||
mkIf cfg.enable {
|
||||
systemd.services.hangitbot = {
|
||||
wantedBy = [ "multi-uesr.target" ];
|
||||
serviceconfig.ExecStart = "${pkgs.hangitbot}/bin/hangitbot ${args} ${escapeShellArg cfg.token}";
|
||||
config = mkIf cfg.enable {
|
||||
systemd.services.recored-ui = {
|
||||
wantedBy = [ "multi-uesr.target" ];
|
||||
environment = {
|
||||
RECORED_MYSQL_DSN = cfg.mysql-dsn;
|
||||
};
|
||||
serviceconfig.ExecStart = "${pkgs.recored-ui}/bin/recored-ui server";
|
||||
};
|
||||
};
|
||||
};
|
||||
});
|
||||
}
|
||||
|
81
go.mod
Normal file
81
go.mod
Normal file
@ -0,0 +1,81 @@
|
||||
module reCoreD-UI
|
||||
|
||||
go 1.22.1
|
||||
|
||||
require (
|
||||
github.com/prometheus/client_golang v1.19.0
|
||||
github.com/swaggo/gin-swagger v1.6.0
|
||||
github.com/swaggo/swag v1.16.3
|
||||
gorm.io/gorm v1.25.7
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/BurntSushi/toml v1.3.2 // indirect
|
||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||
github.com/apparentlymart/go-cidr v1.1.0 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/bytedance/sonic v1.11.3 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect
|
||||
github.com/chenzhuoyu/iasm v0.9.1 // indirect
|
||||
github.com/coredns/caddy v1.1.0 // indirect
|
||||
github.com/coredns/coredns v1.8.4 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
|
||||
github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.21.0 // indirect
|
||||
github.com/go-openapi/jsonreference v0.21.0 // indirect
|
||||
github.com/go-openapi/spec v0.21.0 // indirect
|
||||
github.com/go-openapi/swag v0.23.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.19.0 // indirect
|
||||
github.com/go-sql-driver/mysql v1.7.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/golang/protobuf v1.5.3 // indirect
|
||||
github.com/grpc-ecosystem/grpc-opentracing v0.0.0-20180507213350-8e809c8a8645 // indirect
|
||||
github.com/huandu/go-clone v1.7.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/miekg/dns v1.1.42 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/opentracing/opentracing-go v1.2.0 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.1 // indirect
|
||||
github.com/prometheus/client_model v0.5.0 // indirect
|
||||
github.com/prometheus/common v0.48.0 // indirect
|
||||
github.com/prometheus/procfs v0.12.0 // indirect
|
||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
golang.org/x/arch v0.7.0 // indirect
|
||||
golang.org/x/crypto v0.22.0 // indirect
|
||||
golang.org/x/net v0.24.0 // indirect
|
||||
golang.org/x/sys v0.19.0 // indirect
|
||||
golang.org/x/text v0.14.0 // indirect
|
||||
golang.org/x/tools v0.20.0 // indirect
|
||||
google.golang.org/genproto v0.0.0-20210513213006-bf773b8c8384 // indirect
|
||||
google.golang.org/grpc v1.38.0 // indirect
|
||||
google.golang.org/protobuf v1.33.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/cloud66-oss/coredns_mysql v0.0.0-20231116193749-de52e2924a6f
|
||||
github.com/gin-gonic/gin v1.9.1
|
||||
github.com/huandu/go-clone/generic v1.7.2
|
||||
github.com/sirupsen/logrus v1.9.3
|
||||
github.com/swaggo/files v1.0.1
|
||||
github.com/urfave/cli/v2 v2.27.1
|
||||
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
|
||||
github.com/zsais/go-gin-prometheus v0.1.0
|
||||
gorm.io/driver/mysql v1.5.6
|
||||
gorm.io/plugin/prometheus v0.1.0
|
||||
)
|
65
main.go
Normal file
65
main.go
Normal file
@ -0,0 +1,65 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"reCoreD-UI/cmd/config"
|
||||
"reCoreD-UI/cmd/server"
|
||||
_ "reCoreD-UI/docs"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/urfave/cli/v2/altsrc"
|
||||
)
|
||||
|
||||
func init() {
|
||||
logrus.SetReportCaller(true)
|
||||
}
|
||||
|
||||
// @title reCoreD-UI API
|
||||
// @version 1.0
|
||||
// @description APIs for reCoreD-UI
|
||||
// @BasePath /api/v1
|
||||
// @securityDefinitions.basic BasicAuth
|
||||
func main() {
|
||||
flags := []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "config",
|
||||
Usage: "config yaml file",
|
||||
Aliases: []string{"c"},
|
||||
EnvVars: []string{"RECORED_CONFIG_FILE"},
|
||||
},
|
||||
altsrc.NewStringFlag(&cli.StringFlag{
|
||||
Name: "mysql-dsn",
|
||||
Usage: "mysql dsn",
|
||||
EnvVars: []string{"RECORED_MYSQL_DSN"},
|
||||
}),
|
||||
altsrc.NewBoolFlag(&cli.BoolFlag{
|
||||
Name: "debug",
|
||||
Usage: "enable debug mode",
|
||||
Value: false,
|
||||
Action: func(ctx *cli.Context, b bool) error {
|
||||
if b {
|
||||
logrus.SetLevel(logrus.DebugLevel)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
app := &cli.App{
|
||||
Name: "reCoreD-UI",
|
||||
Usage: "Web UI for CoreDNS",
|
||||
Before: altsrc.InitInputSourceWithContext(
|
||||
flags, altsrc.NewYamlSourceFromFlagFunc("config"),
|
||||
),
|
||||
Flags: flags,
|
||||
Commands: []*cli.Command{
|
||||
server.Command,
|
||||
config.Command,
|
||||
},
|
||||
}
|
||||
|
||||
if err := app.Run(os.Args); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
}
|
65
models/domain.go
Normal file
65
models/domain.go
Normal file
@ -0,0 +1,65 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Domain domain data structure
|
||||
type Domain struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
DomainName string `gorm:"unique,not null,size:255" json:"domain_name"`
|
||||
|
||||
MainDNS string `gorm:"not null;size:255" json:"main_dns"`
|
||||
AdminEmail string `gorm:"not null;size:255" json:"admin_email"`
|
||||
SerialNumber int64 `gorm:"not null;default:1" json:"serial_number"`
|
||||
RefreshInterval uint32 `gorm:"type:uint;not null;default:86400" json:"refresh_interval"`
|
||||
RetryInterval uint32 `gorm:"type:uint;not null;default:7200" json:"retry_interval"`
|
||||
ExpiryPeriod uint32 `gorm:"type:uint;not null;default:3600000" json:"expiry_period"`
|
||||
NegativeTtl uint32 `gorm:"type:uint;not null;default:86400" json:"negative_ttl"`
|
||||
}
|
||||
|
||||
func (d *Domain) EmailSOAForamt() string {
|
||||
s := strings.Split(d.AdminEmail, "@")
|
||||
s[0] = strings.Replace(s[0], ".", "\\", -1)
|
||||
if !strings.HasSuffix(s[1], ".") {
|
||||
s[1] = fmt.Sprintf("%s.", s[1])
|
||||
}
|
||||
return strings.Join(s, ".")
|
||||
}
|
||||
|
||||
func (d *Domain) WithDotEnd() string {
|
||||
if strings.HasSuffix(d.DomainName, ".") {
|
||||
return d.DomainName
|
||||
} else {
|
||||
return fmt.Sprintf("%s.", d.DomainName)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Domain) GenerateSOA() SOARecord {
|
||||
var ns string
|
||||
if !strings.HasSuffix(d.MainDNS, ".") {
|
||||
ns = fmt.Sprintf("%s.", d.MainDNS)
|
||||
} else {
|
||||
ns = d.MainDNS
|
||||
}
|
||||
r := SOARecord{}
|
||||
r.Ns = ns
|
||||
r.MBox = d.EmailSOAForamt()
|
||||
r.Refresh = d.RefreshInterval
|
||||
r.Retry = d.RetryInterval
|
||||
r.Expire = d.ExpiryPeriod
|
||||
r.MinTtl = d.NegativeTtl
|
||||
return r
|
||||
}
|
||||
|
||||
func (d *Domain) GetValue() Domain {
|
||||
return *d
|
||||
}
|
||||
|
||||
type IDomain interface {
|
||||
EmailSOAForamt() string
|
||||
WithDotEnd() string
|
||||
GenerateSOA() SOARecord
|
||||
GetValue() Domain
|
||||
}
|
82
models/record.go
Normal file
82
models/record.go
Normal file
@ -0,0 +1,82 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var ErrorZoneNotEndWithDot = errors.New("zone should end with '.'")
|
||||
|
||||
const (
|
||||
RecordTypeA = "A"
|
||||
RecordTypeAAAA = "AAAA"
|
||||
RecordTypeCNAME = "CNAME"
|
||||
RecordTypeSOA = "SOA"
|
||||
RecordTypeTXT = "TXT"
|
||||
RecordTypeNS = "NS"
|
||||
RecordTypeMX = "MX"
|
||||
RecordTypeCAA = "CAA"
|
||||
RecordTypeSRV = "SRV"
|
||||
)
|
||||
|
||||
type recordContentTypes interface {
|
||||
ARecord | AAAARecord | CNAMERecord | CAARecord | NSRecord | MXRecord | SOARecord | SRVRecord | TXTRecord | RecordContentDefault
|
||||
}
|
||||
|
||||
// Record dns records for coredns mysql plugin
|
||||
type Record[T recordContentTypes] struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Zone string `gorm:"not null;size:255" json:"zone"`
|
||||
Name string `gorm:"not null;size:255" json:"name"`
|
||||
Ttl int `json:"ttl"`
|
||||
// see https://github.com/cloud66-oss/coredns_mysql/blob/main/types.go for content
|
||||
Content T `gorm:"serializer:json;type:text" json:"content"`
|
||||
RecordType string `gorm:"not null;size:255" json:"record_type"`
|
||||
}
|
||||
|
||||
func (*Record[T]) TableName() string {
|
||||
return "coredns_records"
|
||||
}
|
||||
|
||||
func (r *Record[T]) CheckZone() error {
|
||||
if !strings.HasSuffix(r.Zone, ".") {
|
||||
return ErrorZoneNotEndWithDot
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Record[T]) WithOutDotTail() string {
|
||||
return strings.TrimRight(r.Zone, ".")
|
||||
}
|
||||
|
||||
func (r *Record[T]) ToEntity() IRecord {
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *Record[T]) FromEntity(entity any) error {
|
||||
b, err := json.Marshal(entity)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return json.Unmarshal(b, r)
|
||||
}
|
||||
|
||||
func (r *Record[T]) GetType() string {
|
||||
return r.RecordType
|
||||
}
|
||||
|
||||
func (r *Record[T]) GetValue() IRecord {
|
||||
return r.ToEntity()
|
||||
}
|
||||
|
||||
type IRecord interface {
|
||||
TableName() string
|
||||
CheckZone() error
|
||||
WithOutDotTail() string
|
||||
ToEntity() IRecord
|
||||
FromEntity(any) error
|
||||
GetType() string
|
||||
GetValue() IRecord
|
||||
}
|
142
models/record_types.go
Normal file
142
models/record_types.go
Normal file
@ -0,0 +1,142 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
dns "github.com/cloud66-oss/coredns_mysql"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidIPv4 = errors.New("not a valid ipv4 address")
|
||||
ErrInvalidIPv6 = errors.New("not a valid ipv6 address")
|
||||
ErrEmptyTXT = errors.New("txt record should not empty")
|
||||
ErrNoDotSuffix = errors.New("should end with dot")
|
||||
ErrBadEmailFormat = errors.New("email here should have no '@'")
|
||||
ErrBadCAATag = errors.New("caa tag should not empty")
|
||||
ErrBadCAAValue = errors.New("caa value should not empty")
|
||||
ErrInvalidType = errors.New("invalid type")
|
||||
)
|
||||
|
||||
type ARecord struct {
|
||||
dns.ARecord
|
||||
}
|
||||
|
||||
func (r ARecord) Validate() error {
|
||||
ok := regexp.MustCompile("^((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])[.]){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])$").MatchString(r.Ip.String())
|
||||
if !ok {
|
||||
return ErrInvalidIPv4
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type AAAARecord struct {
|
||||
dns.AAAARecord
|
||||
}
|
||||
|
||||
func (r AAAARecord) Validate() error {
|
||||
ok := regexp.MustCompile("^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])[.]){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])[.]){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$").MatchString(r.Ip.String())
|
||||
if !ok {
|
||||
return ErrInvalidIPv6
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type TXTRecord struct {
|
||||
dns.TXTRecord
|
||||
}
|
||||
|
||||
func (r TXTRecord) Validate() error {
|
||||
if r.Text == "" {
|
||||
return ErrEmptyTXT
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type CNAMERecord struct {
|
||||
dns.CNAMERecord
|
||||
}
|
||||
|
||||
func (r CNAMERecord) Validate() error {
|
||||
if !strings.HasSuffix(r.Host, ".") {
|
||||
return ErrNoDotSuffix
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type NSRecord struct {
|
||||
dns.NSRecord
|
||||
}
|
||||
|
||||
func (r NSRecord) Validate() error {
|
||||
if !strings.HasSuffix(r.Host, ".") {
|
||||
return ErrNoDotSuffix
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type MXRecord struct {
|
||||
dns.MXRecord
|
||||
}
|
||||
|
||||
func (r MXRecord) Validate() error {
|
||||
if !strings.HasSuffix(r.Host, ".") {
|
||||
return ErrNoDotSuffix
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type SRVRecord struct {
|
||||
dns.SRVRecord
|
||||
}
|
||||
|
||||
func (r SRVRecord) Validate() error {
|
||||
if !strings.HasPrefix(r.Target, ".") {
|
||||
return ErrNoDotSuffix
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type SOARecord struct {
|
||||
dns.SOARecord
|
||||
}
|
||||
|
||||
func (r SOARecord) Validate() error {
|
||||
if !strings.HasSuffix(r.Ns, ".") {
|
||||
return ErrNoDotSuffix
|
||||
}
|
||||
|
||||
if strings.Contains(r.MBox, "@") {
|
||||
return ErrBadEmailFormat
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type CAARecord struct {
|
||||
dns.CAARecord
|
||||
}
|
||||
|
||||
func (r CAARecord) Validate() error {
|
||||
if r.Tag == "" {
|
||||
return ErrBadCAATag
|
||||
}
|
||||
|
||||
if r.Value == "" {
|
||||
return ErrBadCAAValue
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type RecordContentDefault map[string]any
|
||||
|
||||
func (r RecordContentDefault) Validate() error {
|
||||
return ErrInvalidType
|
||||
}
|
||||
|
||||
type IRecordType interface {
|
||||
Validate() error
|
||||
}
|
31
models/settings.go
Normal file
31
models/settings.go
Normal file
@ -0,0 +1,31 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
const (
|
||||
SettingsKeyAdminUsername = "admin.username"
|
||||
SettingsKeyAdminPassword = "admin.password"
|
||||
SettingsKeyDNSServer = "dns.servers"
|
||||
)
|
||||
|
||||
// Settings settings for this app
|
||||
type Settings struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Key string `gorm:"unique;not null;size:255"`
|
||||
Value string `gorm:"not null;size:255"`
|
||||
}
|
||||
|
||||
func (s *Settings) String() string {
|
||||
return fmt.Sprintf("%s: %s", s.Key, s.Value)
|
||||
}
|
||||
|
||||
func (s *Settings) GetValue() Settings {
|
||||
return *s
|
||||
}
|
||||
|
||||
type ISettings interface {
|
||||
String() string
|
||||
GetValue() Settings
|
||||
}
|
18
package-lock.json
generated
Normal file
18
package-lock.json
generated
Normal file
@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "recored-ui",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"devDependencies": {
|
||||
"@vicons/fa": "^0.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vicons/fa": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmmirror.com/@vicons/fa/-/fa-0.12.0.tgz",
|
||||
"integrity": "sha512-g2PIeJLsTHUjt6bK63LxqC0uYQB7iu+xViJOxvp1s8b9/akpXVPVWjDTTsP980/0KYyMMe4U7F/aUo7wY+MsXA==",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
}
|
5
package.json
Normal file
5
package.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"devDependencies": {
|
||||
"@vicons/fa": "^0.12.0"
|
||||
}
|
||||
}
|
1
server/.gitignore
vendored
Normal file
1
server/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
dist/
|
129
server/handlers_domains.go
Normal file
129
server/handlers_domains.go
Normal file
@ -0,0 +1,129 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"reCoreD-UI/controllers"
|
||||
"reCoreD-UI/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
_ "reCoreD-UI/docs"
|
||||
)
|
||||
|
||||
// GetDomains godoc
|
||||
//
|
||||
// @Router /domains/ [get]
|
||||
// @Summary List all domains
|
||||
// @Description List all domains
|
||||
// @Tags domains
|
||||
// @Accept json
|
||||
// @Product json
|
||||
// @Success 200 {object} Response{data=[]models.Domain}
|
||||
// @Failure 401 {object} Response{data=nil}
|
||||
// @Failure 500 {object} Response{data=nil}
|
||||
func getDomains(c *gin.Context) {
|
||||
domains, err := controllers.GetDomains("")
|
||||
if err != nil {
|
||||
errorHandler(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, Response{
|
||||
Succeed: true,
|
||||
Data: domains,
|
||||
})
|
||||
}
|
||||
|
||||
// CreateDomain godoc
|
||||
//
|
||||
// @Router /domains/ [post]
|
||||
// @Summary Create a domain
|
||||
// @Description Create a domain
|
||||
// @Tags domains
|
||||
// @Product json
|
||||
// @Param object body models.Domain true "content"
|
||||
// @Success 201 {object} Response{data=models.Domain}
|
||||
// @Failure 400 {object} Response{data=nil}
|
||||
// @Failure 401 {object} Response{data=nil}
|
||||
// @Failure 500 {object} Response{data=nil}
|
||||
func createDomain(c *gin.Context) {
|
||||
domain := &models.Domain{}
|
||||
|
||||
if err := c.BindJSON(domain); err != nil {
|
||||
c.JSON(http.StatusBadRequest, Response{
|
||||
Succeed: false,
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
domain, err := controllers.CreateDomain(domain)
|
||||
if err != nil {
|
||||
errorHandler(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, Response{
|
||||
Succeed: true,
|
||||
Data: domain,
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateDomain godoc
|
||||
//
|
||||
// @Router /domains/ [put]
|
||||
// @Summary Update a domain
|
||||
// @Description Update a domain
|
||||
// @Tags domains
|
||||
// @Accept json
|
||||
// @Product json
|
||||
// @Param object body models.Domain true "content"
|
||||
// @Success 200 {object} Response{data=models.Domain}
|
||||
// @Failure 400 {object} Response{data=nil}
|
||||
// @Failure 401 {object} Response{data=nil}
|
||||
// @Failure 404 {object} Response{data=nil}
|
||||
// @Failure 500 {object} Response{data=nil}
|
||||
func updateDomain(c *gin.Context) {
|
||||
domain := &models.Domain{}
|
||||
|
||||
if err := c.BindJSON(domain); err != nil {
|
||||
c.JSON(http.StatusBadRequest, Response{
|
||||
Succeed: false,
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if err := controllers.UpdateDomain(domain); err != nil {
|
||||
errorHandler(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, Response{
|
||||
Succeed: true,
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteDomain godoc
|
||||
//
|
||||
// @Router /domains/{id} [delete]
|
||||
// @Summary Delete a domain
|
||||
// @Description Delete a domain
|
||||
// @Tags domains
|
||||
// @Product json
|
||||
// @Param id path int true "Domain ID"
|
||||
// @Success 204 {object} Response{data=nil}
|
||||
// @Failure 401 {object} Response{data=nil}
|
||||
// @Failure 404 {object} Response{data=nil}
|
||||
// @Failure 500 {object} Response{data=nil}
|
||||
func deleteDomain(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if err := controllers.DeleteDomain(id); err != nil {
|
||||
errorHandler(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusNoContent, Response{
|
||||
Succeed: true,
|
||||
})
|
||||
}
|
281
server/handlers_records.go
Normal file
281
server/handlers_records.go
Normal file
@ -0,0 +1,281 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"reCoreD-UI/controllers"
|
||||
"reCoreD-UI/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func validateRecord(r models.IRecord) error {
|
||||
switch r.GetType() {
|
||||
case models.RecordTypeA:
|
||||
record := &models.Record[models.ARecord]{}
|
||||
if err := record.FromEntity(r); err != nil {
|
||||
return err
|
||||
}
|
||||
return record.Content.Validate()
|
||||
case models.RecordTypeAAAA:
|
||||
record := &models.Record[models.AAAARecord]{}
|
||||
if err := record.FromEntity(r); err != nil {
|
||||
return err
|
||||
}
|
||||
return record.Content.Validate()
|
||||
case models.RecordTypeCNAME:
|
||||
record := &models.Record[models.CNAMERecord]{}
|
||||
if err := record.FromEntity(r); err != nil {
|
||||
return err
|
||||
}
|
||||
return record.Content.Validate()
|
||||
case models.RecordTypeCAA:
|
||||
record := &models.Record[models.CAARecord]{}
|
||||
if err := record.FromEntity(r); err != nil {
|
||||
return err
|
||||
}
|
||||
return record.Content.Validate()
|
||||
case models.RecordTypeMX:
|
||||
record := &models.Record[models.MXRecord]{}
|
||||
if err := record.FromEntity(r); err != nil {
|
||||
return err
|
||||
}
|
||||
return record.Content.Validate()
|
||||
case models.RecordTypeNS:
|
||||
record := &models.Record[models.NSRecord]{}
|
||||
if err := record.FromEntity(r); err != nil {
|
||||
return err
|
||||
}
|
||||
return record.Content.Validate()
|
||||
case models.RecordTypeSOA:
|
||||
record := &models.Record[models.SOARecord]{}
|
||||
if err := record.FromEntity(r); err != nil {
|
||||
return err
|
||||
}
|
||||
return record.Content.Validate()
|
||||
case models.RecordTypeSRV:
|
||||
record := &models.Record[models.SRVRecord]{}
|
||||
if err := record.FromEntity(r); err != nil {
|
||||
return err
|
||||
}
|
||||
return record.Content.Validate()
|
||||
case models.RecordTypeTXT:
|
||||
record := &models.Record[models.TXTRecord]{}
|
||||
if err := record.FromEntity(r); err != nil {
|
||||
return err
|
||||
}
|
||||
return record.Content.Validate()
|
||||
default:
|
||||
return models.ErrInvalidType
|
||||
}
|
||||
}
|
||||
|
||||
// GetRecords godoc
|
||||
//
|
||||
// @Router /records/{domain} [get]
|
||||
// @Summary List all records of a domain
|
||||
// @Description List all records of a domain
|
||||
// @Tags records
|
||||
// @Product json
|
||||
// @Param domain path string true "domain"
|
||||
// @Success 200 {object} Response{data=[]models.Record[models.RecordContentDefault]}
|
||||
// @Failure 401 {object} Response{data=nil}
|
||||
// @Failure 404 {object} Response{data=nil}
|
||||
// @Failure 500 {object} Response{data=nil}
|
||||
func getRecords(c *gin.Context) {
|
||||
query := &models.Record[models.RecordContentDefault]{Content: make(models.RecordContentDefault)}
|
||||
if err := c.BindQuery(query); err != nil {
|
||||
c.JSON(http.StatusBadRequest, Response{
|
||||
Succeed: false,
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
domain := c.Param("domain")
|
||||
query.Zone = fmt.Sprintf("%s.", domain)
|
||||
|
||||
records, err := controllers.GetRecords(query)
|
||||
if err != nil {
|
||||
errorHandler(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, Response{
|
||||
Succeed: true,
|
||||
Data: records,
|
||||
})
|
||||
}
|
||||
|
||||
// CreateRecord godoc
|
||||
//
|
||||
// @Router /records/{domain} [post]
|
||||
// @Summary Create a record of a domain
|
||||
// @Description Create a record of a domain
|
||||
// @Tags records
|
||||
// @Accept json
|
||||
// @Product json
|
||||
// @Param domain path string true "domain"
|
||||
// @Param object body models.Record[models.RecordContentDefault] true "content"
|
||||
// @Success 201 {object} Response{data=models.Record[models.RecordContentDefault]}
|
||||
// @Failure 400 {object} Response{data=nil}
|
||||
// @Failure 401 {object} Response{data=nil}
|
||||
// @Failure 404 {object} Response{data=nil}
|
||||
// @Failure 500 {object} Response{data=nil}
|
||||
func createRecord(c *gin.Context) {
|
||||
record := &models.Record[models.RecordContentDefault]{Content: make(models.RecordContentDefault)}
|
||||
if err := c.BindJSON(record); err != nil {
|
||||
c.JSON(http.StatusBadRequest, Response{
|
||||
Succeed: false,
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
domain := c.Param("domain")
|
||||
if domain != record.WithOutDotTail() {
|
||||
c.JSON(http.StatusBadRequest, Response{
|
||||
Succeed: false,
|
||||
Message: "request body doesn't match URI",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if err := validateRecord(record); err != nil {
|
||||
c.JSON(http.StatusBadRequest, Response{
|
||||
Succeed: false,
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
irecord, err := controllers.CreateRecord(record)
|
||||
if err != nil {
|
||||
errorHandler(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, Response{
|
||||
Succeed: true,
|
||||
Data: irecord,
|
||||
})
|
||||
}
|
||||
|
||||
// CreateRecords godoc
|
||||
//
|
||||
// @Router /records/{domain}/bulk [post]
|
||||
// @Summary Create some records of a domain
|
||||
// @Description Create some records of a domain
|
||||
// @Tags records
|
||||
// @Accept json
|
||||
// @Product json
|
||||
// @Param domain path string true "domain"
|
||||
// @Param object body []models.Record[models.RecordContentDefault] true "content"
|
||||
// @Success 201 {object} Response{data=models.Record[models.RecordContentDefault]}
|
||||
// @Failure 400 {object} Response{data=nil}
|
||||
// @Failure 401 {object} Response{data=nil}
|
||||
// @Failure 404 {object} Response{data=nil}
|
||||
// @Failure 500 {object} Response{data=nil}
|
||||
func createRecords(c *gin.Context) {
|
||||
var records []models.Record[models.RecordContentDefault]
|
||||
if err := c.BindJSON(&records); err != nil {
|
||||
c.JSON(http.StatusBadRequest, Response{
|
||||
Succeed: false,
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var iRecords []models.IRecord
|
||||
for _, v := range records {
|
||||
iRecords = append(iRecords, &v)
|
||||
}
|
||||
|
||||
if err := controllers.CreateRecords(iRecords); err != nil {
|
||||
errorHandler(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, Response{
|
||||
Succeed: true,
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateRecord godoc
|
||||
//
|
||||
// @Router /records/{domain} [put]
|
||||
// @Summary Update a record of a domain
|
||||
// @Description Update a record of a domain
|
||||
// @Tags records
|
||||
// @Accept json
|
||||
// @Product json
|
||||
// @Param domain path string true "domain"
|
||||
// @Param object body models.Record[models.RecordContentDefault] true "content"
|
||||
// @Success 200 {object} Response{data=models.Record[models.RecordContentDefault]}
|
||||
// @Failure 400 {object} Response{data=nil}
|
||||
// @Failure 401 {object} Response{data=nil}
|
||||
// @Failure 404 {object} Response{data=nil}
|
||||
// @Failure 500 {object} Response{data=nil}
|
||||
func updateRecord(c *gin.Context) {
|
||||
record := &models.Record[models.RecordContentDefault]{Content: make(models.RecordContentDefault)}
|
||||
if err := c.BindJSON(record); err != nil {
|
||||
c.JSON(http.StatusBadRequest, Response{
|
||||
Succeed: false,
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if err := validateRecord(record); err != nil {
|
||||
c.JSON(http.StatusBadRequest, Response{
|
||||
Succeed: false,
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
domain := c.Param("domain")
|
||||
if domain != record.WithOutDotTail() {
|
||||
c.JSON(http.StatusBadRequest, Response{
|
||||
Succeed: false,
|
||||
Message: "request body doesn't match URI",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if err := controllers.UpdateRecord(record); err != nil {
|
||||
errorHandler(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, Response{
|
||||
Succeed: true,
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteRecord godoc
|
||||
//
|
||||
// @Router /records/{domain}/{id} [delete]
|
||||
// @Summary Delete a record of a domain
|
||||
// @Description Delete a record of a domain, except SOA record.
|
||||
// @Tags records
|
||||
// @Product json
|
||||
// @Param domain path string true "domain"
|
||||
// @Param id path int true "Record ID"
|
||||
// @Success 204 {object} Response{data=nil}
|
||||
// @Failure 400 {object} Response{data=nil}
|
||||
// @Failure 401 {object} Response{data=nil}
|
||||
// @Failure 404 {object} Response{data=nil}
|
||||
// @Failure 500 {object} Response{data=nil}
|
||||
func deleteRecord(c *gin.Context) {
|
||||
domain := c.Param("domain")
|
||||
id := c.Param("id")
|
||||
|
||||
if err := controllers.DeleteRecord(domain, id); err != nil {
|
||||
errorHandler(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusNoContent, Response{
|
||||
Succeed: true,
|
||||
})
|
||||
}
|
44
server/response.go
Normal file
44
server/response.go
Normal file
@ -0,0 +1,44 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"reCoreD-UI/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sirupsen/logrus"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Response common http response
|
||||
type Response struct {
|
||||
// `true` for 2xx, else `false`
|
||||
Succeed bool `json:"succeed"`
|
||||
|
||||
// error message
|
||||
Message string `json:"message"`
|
||||
|
||||
// payload here
|
||||
Data interface{} `json:"data"`
|
||||
}
|
||||
|
||||
func errorHandler(c *gin.Context, err error) {
|
||||
logrus.Error(err)
|
||||
switch {
|
||||
case errors.Is(err, gorm.ErrRecordNotFound):
|
||||
c.JSON(http.StatusNotFound, Response{
|
||||
Succeed: false,
|
||||
Message: err.Error(),
|
||||
})
|
||||
case errors.Is(err, models.ErrorZoneNotEndWithDot):
|
||||
c.JSON(http.StatusBadRequest, Response{
|
||||
Succeed: false,
|
||||
Message: err.Error(),
|
||||
})
|
||||
default:
|
||||
c.JSON(http.StatusInternalServerError, Response{
|
||||
Succeed: false,
|
||||
Message: err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
99
server/route.go
Normal file
99
server/route.go
Normal file
@ -0,0 +1,99 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"path"
|
||||
"reCoreD-UI/controllers"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
"github.com/sirupsen/logrus"
|
||||
swaggerfiles "github.com/swaggo/files"
|
||||
ginSwagger "github.com/swaggo/gin-swagger"
|
||||
)
|
||||
|
||||
const (
|
||||
apiPrefix = "/api"
|
||||
metricPrefix = "/metrics"
|
||||
swaggerPrefix = "/swagger"
|
||||
)
|
||||
|
||||
|
||||
func (s *Server) setupRoute() {
|
||||
username, password, err := controllers.GetAdmin()
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
logrus.Debugf("got %s:%s", username, password)
|
||||
|
||||
server := s.webServer.Group(s.prefix)
|
||||
|
||||
swaggerHandler := server
|
||||
if s.debug {
|
||||
swaggerHandler.GET(path.Join(swaggerPrefix, "*any"), ginSwagger.WrapHandler(swaggerfiles.Handler))
|
||||
} else {
|
||||
swaggerHandler.GET(path.Join(swaggerPrefix, "*any"), func(ctx *gin.Context) {
|
||||
ctx.HTML(http.StatusNotFound, "", nil)
|
||||
})
|
||||
}
|
||||
|
||||
controllers.RegisterMetrics()
|
||||
metricHandler := server
|
||||
metricHandler.GET(metricPrefix, func(ctx *gin.Context) {
|
||||
if err := controllers.RefreshMetrics(); err != nil {
|
||||
logrus.Error(err)
|
||||
}
|
||||
promhttp.Handler().ServeHTTP(ctx.Writer, ctx.Request)
|
||||
})
|
||||
|
||||
apiHandler := server
|
||||
|
||||
groupV1 := apiHandler.Group(apiPrefix, gin.BasicAuth(gin.Accounts{
|
||||
username: password,
|
||||
}), func(ctx *gin.Context) {
|
||||
_, ok := ctx.Get(gin.AuthUserKey)
|
||||
if !ok {
|
||||
ctx.AbortWithStatusJSON(http.StatusUnauthorized, Response{
|
||||
Succeed: false,
|
||||
})
|
||||
}
|
||||
}).Group("/v1")
|
||||
|
||||
domains := groupV1.Group("/domains")
|
||||
domains.
|
||||
GET("/", getDomains).
|
||||
POST("/", createDomain).
|
||||
PUT("/", updateDomain).
|
||||
DELETE("/:id", deleteDomain)
|
||||
|
||||
records := groupV1.Group("/records")
|
||||
records.
|
||||
GET("/:domain", getRecords).
|
||||
POST("/:domain", createRecord).
|
||||
POST("/:domain/bulk", createRecords).
|
||||
PUT("/:domain", updateRecord).
|
||||
DELETE("/:domain/:id", deleteRecord)
|
||||
|
||||
/*server := s.webServer.Group(s.prefix)
|
||||
server.Use(apiHandler.HandleContext, metricHandler.HandleContext, func(ctx *gin.Context) {
|
||||
uri := ctx.Request.RequestURI
|
||||
logrus.Debug(uri)
|
||||
switch {
|
||||
case strings.HasPrefix(uri, path.Join(s.prefix, apiPrefix)):
|
||||
//apiHandler.HandleContext(ctx)
|
||||
case strings.HasPrefix(uri, path.Join(s.prefix, metricPrefix)):
|
||||
//metricHandler.HandleContext(ctx)
|
||||
case strings.HasPrefix(uri, path.Join(s.prefix, swaggerPrefix)):
|
||||
if s.debug {
|
||||
swaggerHandler.HandleContext(ctx)
|
||||
} else {
|
||||
ctx.HTML(http.StatusNotFound, "", nil)
|
||||
}
|
||||
default:
|
||||
staticFileHandler()(ctx)
|
||||
}
|
||||
})*/
|
||||
|
||||
server.GET("/", staticFileHandler())
|
||||
server.GET("/assets/*any", staticFileHandler())
|
||||
}
|
48
server/server.go
Normal file
48
server/server.go
Normal file
@ -0,0 +1,48 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net"
|
||||
"reCoreD-UI/database"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
webServer *gin.Engine
|
||||
listen string
|
||||
prefix string
|
||||
debug bool
|
||||
}
|
||||
|
||||
func NewServer(c *cli.Context) (*Server, error) {
|
||||
if err := database.Connect(c.String("mysql-dsn")); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if c.Bool("debug") {
|
||||
database.Client = database.Client.Debug()
|
||||
gin.SetMode(gin.DebugMode)
|
||||
} else {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
}
|
||||
|
||||
return &Server{
|
||||
webServer: gin.New(),
|
||||
listen: net.JoinHostPort(
|
||||
c.String("listen"),
|
||||
c.String("port"),
|
||||
),
|
||||
prefix: c.String("prefix"),
|
||||
debug: c.Bool("debug"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Server) Run() error {
|
||||
logrus.Debug("server running")
|
||||
defer logrus.Debug("server exit")
|
||||
|
||||
s.setupRoute()
|
||||
|
||||
return s.webServer.Run(s.listen)
|
||||
}
|
35
server/static.go
Normal file
35
server/static.go
Normal file
@ -0,0 +1,35 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
//go:generate cp -r ../web/dist ./
|
||||
//go:embed dist
|
||||
var staticFiles embed.FS
|
||||
|
||||
func staticFileHandler() gin.HandlerFunc {
|
||||
sf, err := fs.Sub(staticFiles, "dist")
|
||||
if err != nil {
|
||||
logrus.Fatal("compile error: ", err)
|
||||
}
|
||||
|
||||
fs := http.FileServer(http.FS(sf))
|
||||
|
||||
return func(ctx *gin.Context) {
|
||||
defer ctx.Abort()
|
||||
filename := strings.TrimLeft(ctx.Request.RequestURI, "/")
|
||||
|
||||
if filename == "" {
|
||||
filename = "index.html"
|
||||
}
|
||||
|
||||
fs.ServeHTTP(ctx.Writer, ctx.Request)
|
||||
}
|
||||
}
|
12
web/.eslintrc
Normal file
12
web/.eslintrc
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"root": true,
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"plugins": [
|
||||
"@typescript-eslint"
|
||||
],
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/eslint-recommended",
|
||||
"plugin:@typescript-eslint/recommended"
|
||||
]
|
||||
}
|
30
web/README.md
Normal file
30
web/README.md
Normal file
@ -0,0 +1,30 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
|
||||
|
||||
- Configure the top-level `parserOptions` property like this:
|
||||
|
||||
```js
|
||||
export default {
|
||||
// other rules...
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
project: ['./tsconfig.json', './tsconfig.node.json'],
|
||||
tsconfigRootDir: __dirname,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked`
|
||||
- Optionally add `plugin:@typescript-eslint/stylistic-type-checked`
|
||||
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list
|
13
web/index.html
Normal file
13
web/index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite + React + TS</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
3750
web/package-lock.json
generated
Normal file
3750
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
36
web/package.json
Normal file
36
web/package.json
Normal file
@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "web",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^5.3.6",
|
||||
"antd": "^5.16.1",
|
||||
"axios": "^1.6.8",
|
||||
"i18next": "^23.11.1",
|
||||
"i18next-browser-languagedetector": "^7.2.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-i18next": "^14.1.0",
|
||||
"react-redux": "^9.1.0",
|
||||
"react-router-dom": "^6.22.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.66",
|
||||
"@types/react-dom": "^18.2.22",
|
||||
"@typescript-eslint/eslint-plugin": "^7.6.0",
|
||||
"@typescript-eslint/parser": "^7.6.0",
|
||||
"@vitejs/plugin-react-swc": "^3.5.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.6",
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^5.2.0"
|
||||
}
|
||||
}
|
42
web/src/App.css
Normal file
42
web/src/App.css
Normal file
@ -0,0 +1,42 @@
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
34
web/src/App.tsx
Normal file
34
web/src/App.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import { RouterProvider } from 'react-router-dom'
|
||||
import router from './router'
|
||||
import { App, ConfigProvider, Spin, theme } from 'antd'
|
||||
import zhCN from 'antd/locale/zh_CN'
|
||||
import enUS from 'antd/locale/en_US'
|
||||
|
||||
import './App.css'
|
||||
import isBrowserDarkTheme from './isBrowserDarkTheme'
|
||||
import i18n from './locale'
|
||||
|
||||
function detectLanguage() {
|
||||
switch (i18n.language) {
|
||||
case 'zh-CN':
|
||||
case 'zh':
|
||||
return zhCN
|
||||
default:
|
||||
return enUS
|
||||
}
|
||||
}
|
||||
|
||||
function ReactApp() {
|
||||
document.title = 'reCoreD-UI'
|
||||
const themeUsed = isBrowserDarkTheme() ? theme.darkAlgorithm : theme.defaultAlgorithm
|
||||
|
||||
return (
|
||||
<ConfigProvider theme={{ algorithm: themeUsed }} locale={detectLanguage()}>
|
||||
<App>
|
||||
<RouterProvider router={router} fallbackElement={<Spin size='large' />} />
|
||||
</App>
|
||||
</ConfigProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default ReactApp
|
92
web/src/api/index.ts
Normal file
92
web/src/api/index.ts
Normal file
@ -0,0 +1,92 @@
|
||||
import axios, { type AxiosInstance, type AxiosRequestConfig, type AxiosResponse } from "axios"
|
||||
import { type Record } from '../stores/records'
|
||||
import { type Domain } from "../stores/domains"
|
||||
import i18n from "../locale"
|
||||
|
||||
type Result<T> = {
|
||||
success: boolean
|
||||
message: string
|
||||
data: T
|
||||
}
|
||||
|
||||
const t = i18n.t
|
||||
// 5 second.
|
||||
const notificationDuration = 5000
|
||||
const messages = new Map<number, {
|
||||
message: string, description: string, duration: number
|
||||
}>(
|
||||
[
|
||||
[400, {
|
||||
message: t("api.error400.title"),
|
||||
description: t("api.error400.content"),
|
||||
duration: notificationDuration
|
||||
}],
|
||||
[401, {
|
||||
message: t("api.error401.title"),
|
||||
description: t("api.error401.content"),
|
||||
duration: notificationDuration
|
||||
}],
|
||||
[403, {
|
||||
message: t("api.error403.title"),
|
||||
description: t("api.error403.content"),
|
||||
duration: notificationDuration
|
||||
}],
|
||||
[404, {
|
||||
message: t("api.error404.title"),
|
||||
description: t("api.error404.content"),
|
||||
duration: notificationDuration
|
||||
}],
|
||||
[500, {
|
||||
message: t("api.error500.title"),
|
||||
description: t("api.error500.content"),
|
||||
duration: notificationDuration
|
||||
}]
|
||||
]
|
||||
)
|
||||
|
||||
export interface ResponseError {
|
||||
response: {
|
||||
status: number
|
||||
}
|
||||
}
|
||||
|
||||
export function getErrorInfo(err: ResponseError) {
|
||||
const msg = messages.get(err.response.status)
|
||||
return msg ? msg : {
|
||||
message: t("api.errorUnknown.title"),
|
||||
description: t("api.errorUnknown.content"),
|
||||
duration: notificationDuration
|
||||
}
|
||||
}
|
||||
|
||||
export class Request {
|
||||
private instance: AxiosInstance;
|
||||
private baseConfig: AxiosRequestConfig = { baseURL: "api/v1" }
|
||||
|
||||
constructor(config: AxiosRequestConfig) {
|
||||
this.instance = axios.create(Object.assign(this.baseConfig, config))
|
||||
|
||||
}
|
||||
|
||||
public request(config: AxiosRequestConfig): Promise<AxiosResponse> {
|
||||
return this.instance.request(config)
|
||||
}
|
||||
|
||||
public get<T = Record[] | Domain[]>(url: string, config?: AxiosRequestConfig): Promise<AxiosResponse<Result<T>>> {
|
||||
return this.instance.get(url, config)
|
||||
}
|
||||
|
||||
public post<T = Record | Domain>(url: string, data?: T, config?: AxiosRequestConfig): Promise<AxiosResponse<Result<T>>> {
|
||||
return this.instance.post(url, data, config)
|
||||
}
|
||||
|
||||
public put<T = Record | Domain>(url: string, data?: T, config?: AxiosRequestConfig): Promise<AxiosResponse<Result<null>>> {
|
||||
return this.instance.put(url, data, config)
|
||||
}
|
||||
|
||||
public delete(url: string, config?: AxiosRequestConfig): Promise<AxiosResponse<Result<null>>> {
|
||||
return this.instance.delete(url, config)
|
||||
}
|
||||
}
|
||||
|
||||
export default new Request({})
|
32
web/src/components/domains/DomainCard.tsx
Normal file
32
web/src/components/domains/DomainCard.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
import { Card, Tooltip } from "antd"
|
||||
import { Domain } from "../../stores/domains"
|
||||
import { BookOutlined, DeleteOutlined, EditOutlined } from "@ant-design/icons"
|
||||
import DomainInfo from "./DomainInfo"
|
||||
import i18n from '../../locale'
|
||||
const { t } = i18n
|
||||
|
||||
type DomainCardProps = {
|
||||
domain: Domain
|
||||
onRecordClick(): void
|
||||
onEditClick(): void
|
||||
onDeleteClick(): void
|
||||
}
|
||||
|
||||
export default function DomainCard({ domain, onRecordClick, onEditClick, onDeleteClick }: DomainCardProps) {
|
||||
return (
|
||||
<Card className='domain-info' title={domain.domain_name} actions={[
|
||||
<Tooltip title={t('domains.dnsRecord')}>
|
||||
<BookOutlined key='records' className='icon' onClick={onRecordClick} />
|
||||
</Tooltip>,
|
||||
<Tooltip title={t('common.edit')} >
|
||||
<EditOutlined key='edit' className='icon' onClick={onEditClick} />
|
||||
</Tooltip>,
|
||||
<Tooltip title={t('common.delete')}>
|
||||
<DeleteOutlined key='delete' className='icon' onClick={onDeleteClick} />
|
||||
</Tooltip>
|
||||
]} key={domain.id}>
|
||||
<DomainInfo domain={domain} />
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
59
web/src/components/domains/DomainDeleteModal.tsx
Normal file
59
web/src/components/domains/DomainDeleteModal.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
import { App, Input, Modal } from "antd"
|
||||
import { Domain } from "../../stores/domains"
|
||||
import { useState } from "react"
|
||||
import i18n from '../../locale'
|
||||
import { CloseOutlined, DeleteOutlined } from "@ant-design/icons"
|
||||
import { ResponseError, getErrorInfo } from "../../api"
|
||||
const { t } = i18n
|
||||
|
||||
type Props = {
|
||||
open: boolean
|
||||
domain: Domain
|
||||
removeDomain(domain: Domain): Promise<void>
|
||||
onOk(): void
|
||||
onCancel(): void
|
||||
}
|
||||
|
||||
export default function DomainDeleteModal({ open, domain, removeDomain, onOk, onCancel }: Props) {
|
||||
const [input, setInput] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const { notification } = App.useApp()
|
||||
|
||||
function confirm() {
|
||||
setLoading(true)
|
||||
removeDomain(domain).then(onOk).finally(() => setLoading(false)).catch(e => {
|
||||
const msg = getErrorInfo(e as ResponseError)
|
||||
notification.error(msg)
|
||||
console.error(e)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal onOk={confirm} onCancel={onCancel}
|
||||
title={`${t('domains.delete')} - ${domain.domain_name}`}
|
||||
confirmLoading={loading}
|
||||
okButtonProps={{
|
||||
disabled: input !== domain.domain_name,
|
||||
icon: <DeleteOutlined />,
|
||||
danger: true
|
||||
}}
|
||||
cancelButtonProps={{
|
||||
icon: <CloseOutlined />
|
||||
}}
|
||||
open={open}
|
||||
closeIcon={false}
|
||||
maskClosable={false}
|
||||
|
||||
centered
|
||||
destroyOnClose
|
||||
>
|
||||
<p>{t('common.deleteConfirm')}</p>
|
||||
<p>{t('domains.deleteHint')}</p>
|
||||
<p>{t('domains.confirm1')} <b id="boldit">{domain.domain_name}</b> {t('domains.confirm2')}</p>
|
||||
<p />
|
||||
<p>
|
||||
<Input placeholder={domain.domain_name} onChange={e => setInput(e.target.value)} />
|
||||
</p>
|
||||
</Modal>
|
||||
)
|
||||
}
|
132
web/src/components/domains/DomainEditModal.tsx
Normal file
132
web/src/components/domains/DomainEditModal.tsx
Normal file
@ -0,0 +1,132 @@
|
||||
import { App, Form, FormInstance, Input, InputNumber, Modal, Space } from "antd"
|
||||
import { Domain } from "../../stores/domains"
|
||||
import i18n from '../../locale'
|
||||
import { useEffect, useState } from "react"
|
||||
import { CheckOutlined, CloseOutlined } from "@ant-design/icons"
|
||||
import { ResponseError, getErrorInfo } from "../../api"
|
||||
const { t } = i18n
|
||||
|
||||
type Props = {
|
||||
open: boolean
|
||||
domain: Domain
|
||||
editDomain(domain: Domain): Promise<void>
|
||||
createDomain(domain: Domain): Promise<void>
|
||||
onCancel(): void
|
||||
onOk(): void
|
||||
}
|
||||
|
||||
export default function DomainEditModal({ open, domain, editDomain, createDomain, onCancel, onOk }: Props) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [form] = Form.useForm<Domain>()
|
||||
const { notification } = App.useApp()
|
||||
|
||||
useEffect(() => {
|
||||
form.setFieldsValue(domain)
|
||||
}, [open])
|
||||
|
||||
async function confirm() {
|
||||
const commitFunction = (!domain.id || domain.id < 1) ? createDomain : editDomain
|
||||
setLoading(true)
|
||||
try {
|
||||
domain = await form.validateFields()
|
||||
await commitFunction(domain)
|
||||
onOk()
|
||||
} catch (error) {
|
||||
const msg = getErrorInfo(error as ResponseError)
|
||||
notification.error(msg)
|
||||
console.error(error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function easyInput(form: FormInstance<Domain>, domain_name: string) {
|
||||
form.setFieldValue('admin_email', `admin@${domain_name}`)
|
||||
form.setFieldValue('main_dns', `ns1.${domain_name}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
onCancel={onCancel} onOk={confirm}
|
||||
title={
|
||||
<span>
|
||||
{
|
||||
(!domain || !domain.id || domain.id < 1) ? t('common.new') : t('common.edit')
|
||||
}
|
||||
{
|
||||
t('domains._')
|
||||
}
|
||||
</span>
|
||||
}
|
||||
confirmLoading={loading}
|
||||
cancelButtonProps={{
|
||||
icon: <CloseOutlined />,
|
||||
}}
|
||||
okButtonProps={{
|
||||
icon: <CheckOutlined />,
|
||||
htmlType: 'submit'
|
||||
}}
|
||||
|
||||
open={open}
|
||||
closeIcon={false}
|
||||
maskClosable={false}
|
||||
|
||||
centered
|
||||
destroyOnClose
|
||||
forceRender
|
||||
>
|
||||
<Form<Domain> name="domain" form={form}
|
||||
scrollToFirstError
|
||||
autoComplete="off"
|
||||
>
|
||||
<Form.Item<Domain> hidden name='id' />
|
||||
<Form.Item<Domain>
|
||||
label={t('domains._')}
|
||||
name='domain_name'
|
||||
rules={[
|
||||
{ required: true, message: t('common.mandatory') },
|
||||
{ pattern: /^([\w-]+\.)+[\w-]+$/, message: t('domains.errors.domainName') },
|
||||
]}
|
||||
>
|
||||
<Input placeholder='example.com' onChange={v => easyInput(form, v.target.value)} />
|
||||
</Form.Item>
|
||||
<Form.Item<Domain>
|
||||
label={t('domains.form.mainDNS')}
|
||||
name='main_dns'
|
||||
rules={[
|
||||
{ required: true, message: t('common.mandatory') },
|
||||
{ pattern: /^([\w-]+\.)+[\w-]+$/, message: t('domains.errors.domainName') },
|
||||
]}
|
||||
>
|
||||
<Input placeholder="ns1.example.com" />
|
||||
</Form.Item>
|
||||
<Form.Item<Domain>
|
||||
label={t('domains.form.adminMail')}
|
||||
name='admin_email'
|
||||
rules={[
|
||||
{ required: true, message: t('common.mandatory') },
|
||||
{ pattern: /^[\w-.]+@([\w-]+\.)+[\w-]+$/, message: t('domains.errors.mail') }
|
||||
]}
|
||||
>
|
||||
<Input placeholder="admin@example.com" />
|
||||
</Form.Item>
|
||||
<Space>
|
||||
<Form.Item<Domain> name='refresh_interval' label={t('records.refresh')}>
|
||||
<InputNumber controls={false} addonAfter={t('common.unitForSecond')} />
|
||||
</Form.Item>
|
||||
<Form.Item<Domain> name='retry_interval' label={t('records.retry')}>
|
||||
<InputNumber controls={false} addonAfter={t('common.unitForSecond')} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
<Space>
|
||||
<Form.Item<Domain> name='expiry_period' label={t('records.expire')}>
|
||||
<InputNumber controls={false} addonAfter={t('common.unitForSecond')} />
|
||||
</Form.Item>
|
||||
<Form.Item<Domain> name='negative_ttl' label={t('records.ttl')}>
|
||||
<InputNumber controls={false} addonAfter={t('common.unitForSecond')} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
</Form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
7
web/src/components/domains/DomainInfo.css
Normal file
7
web/src/components/domains/DomainInfo.css
Normal file
@ -0,0 +1,7 @@
|
||||
.icon-info {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
span.info {
|
||||
padding-left: 0.5em;
|
||||
}
|
22
web/src/components/domains/DomainInfo.tsx
Normal file
22
web/src/components/domains/DomainInfo.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import { GlobalOutlined, MailOutlined } from "@ant-design/icons"
|
||||
import { Domain } from "../../stores/domains"
|
||||
import './DomainInfo.css'
|
||||
|
||||
type Props = {
|
||||
domain: Domain
|
||||
}
|
||||
|
||||
export default function DomainInfo({ domain }: Props) {
|
||||
return (
|
||||
<>
|
||||
<p>
|
||||
<MailOutlined className="icon-info" />
|
||||
<span className="info">{domain.admin_email}</span>
|
||||
</p>
|
||||
<p>
|
||||
<GlobalOutlined className="icon-info" />
|
||||
<span className="info">{domain.main_dns}</span>
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
}
|
261
web/src/components/records/RecordEditModal.tsx
Normal file
261
web/src/components/records/RecordEditModal.tsx
Normal file
@ -0,0 +1,261 @@
|
||||
import { App, Form, Input, InputNumber, Modal, Select } from 'antd'
|
||||
import i18n from '../../locale'
|
||||
import { AAAARecord, ARecord, CAARecord, CNAMERecord, MXRecord, NSRecord, Record, RecordTypes, SRVRecord, TXTRecord } from '../../stores/records'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { CheckOutlined, CloseOutlined } from '@ant-design/icons'
|
||||
import { ResponseError, getErrorInfo } from '../../api'
|
||||
import { FormInstance } from 'antd/lib/form/Form'
|
||||
const { t } = i18n
|
||||
|
||||
type Props = {
|
||||
open: boolean
|
||||
record: Record
|
||||
//domain: string
|
||||
onCancel(): void
|
||||
onOk(): void
|
||||
|
||||
editRecord(record: Record): Promise<void>
|
||||
createRecord(record: Record): Promise<void>
|
||||
}
|
||||
|
||||
const recordTypeOptions = Object.entries(RecordTypes).filter(e => e[1] !== RecordTypes.RecordTypeSOA).map(e => {
|
||||
return {
|
||||
value: e[1],
|
||||
label: e[1]
|
||||
}
|
||||
})
|
||||
|
||||
export default function RecordEditModal({ open, record, onOk, onCancel, editRecord, createRecord }: Props) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [form] = Form.useForm<Record>()
|
||||
const { notification } = App.useApp()
|
||||
|
||||
useEffect(() => { form.setFieldsValue(record) }, [open])
|
||||
|
||||
async function confirm() {
|
||||
const commitFunction = (!record.id || record.id < 1) ? createRecord : editRecord
|
||||
setLoading(true)
|
||||
try {
|
||||
record = await form.validateFields()
|
||||
await commitFunction(record)
|
||||
onOk()
|
||||
} catch (error) {
|
||||
const msg = getErrorInfo(error as ResponseError)
|
||||
notification.error(msg)
|
||||
console.error(error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const controls = new Map<RecordTypes, (f: FormInstance<Record>) => JSX.Element>([
|
||||
[
|
||||
RecordTypes.RecordTypeA, (
|
||||
({ getFieldValue }) =>
|
||||
<Form.Item<Record<ARecord>> label='IP' name={['content', 'ip']} required rules={[{
|
||||
validator() {
|
||||
|
||||
const result = ARecord.validate(getFieldValue('content') as ARecord)
|
||||
return (result === true) ? Promise.resolve() : Promise.reject(result)
|
||||
}
|
||||
}]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
)
|
||||
],
|
||||
[
|
||||
RecordTypes.RecordTypeAAAA, (
|
||||
({ getFieldValue }) =>
|
||||
<Form.Item<Record<AAAARecord>> label='IP' name={['content', 'ip']} required rules={[{
|
||||
validator() {
|
||||
const result = AAAARecord.validate(getFieldValue('content') as AAAARecord)
|
||||
return (result === true) ? Promise.resolve() : Promise.reject(result)
|
||||
}
|
||||
}]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
)
|
||||
],
|
||||
[
|
||||
RecordTypes.RecordTypeCNAME, (
|
||||
({ getFieldValue }) =>
|
||||
<Form.Item<Record<CNAMERecord>> label={t('records.form.host')} required name={['content', 'host']}
|
||||
rules={[{
|
||||
validator() {
|
||||
const result = CNAMERecord.validate(getFieldValue('content') as CNAMERecord)
|
||||
return (result === true) ? Promise.resolve() : Promise.reject(result)
|
||||
}
|
||||
}]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
)
|
||||
],
|
||||
[
|
||||
RecordTypes.RecordTypeNS, (
|
||||
({ getFieldValue }) =>
|
||||
<Form.Item<Record<NSRecord>> label={t('records.form.host')} name={['content', 'host']} required
|
||||
rules={[{
|
||||
validator() {
|
||||
const result = NSRecord.validate(getFieldValue('content') as NSRecord)
|
||||
return (result === true) ? Promise.resolve() : Promise.reject(result)
|
||||
}
|
||||
}]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
)
|
||||
],
|
||||
[
|
||||
RecordTypes.RecordTypeTXT, (
|
||||
({ getFieldValue }) =>
|
||||
<Form.Item<Record<TXTRecord>> label={t('records.form.text')} name={['content', 'text']} required
|
||||
rules={[{
|
||||
validator() {
|
||||
const result = TXTRecord.validate(getFieldValue('content') as TXTRecord)
|
||||
return (result === true) ? Promise.resolve() : Promise.reject(result)
|
||||
}
|
||||
}]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
)
|
||||
],
|
||||
[
|
||||
RecordTypes.RecordTypeMX, (
|
||||
({ getFieldValue }) =>
|
||||
<>
|
||||
<Form.Item<Record<MXRecord>> label={t('records.form.host')} name={['content', 'host']} required
|
||||
rules={[{
|
||||
validator() {
|
||||
const result = MXRecord.validate(getFieldValue('content') as MXRecord)
|
||||
return (result === true) ? Promise.resolve() : Promise.reject(result)
|
||||
}
|
||||
}]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item<Record<MXRecord>> label={t('records.form.preference')} name={['content', 'preference']} required>
|
||||
<InputNumber controls={false} />
|
||||
</Form.Item>
|
||||
</>
|
||||
)
|
||||
],
|
||||
[
|
||||
RecordTypes.RecordTypeCAA, (
|
||||
({ getFieldValue }) =>
|
||||
<>
|
||||
<Form.Item<Record<CAARecord>> label={t('records.form.flag')} name={['content', 'flag']} required>
|
||||
<InputNumber controls={false} />
|
||||
</Form.Item>
|
||||
<Form.Item<Record<CAARecord>> label={t('records.form.tag')} name={['content', 'tag']} required rules={[{
|
||||
validator() {
|
||||
const result = CAARecord.validate(getFieldValue('content') as CAARecord)
|
||||
return (result === true) ? Promise.resolve() : Promise.reject(result)
|
||||
}
|
||||
}]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item<Record<CAARecord>> label={t('records.form.value')} name={['content', 'value']} required rules={[
|
||||
{ required: true, message: t('common.mandatory') }
|
||||
]} >
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</>
|
||||
)
|
||||
],
|
||||
[
|
||||
RecordTypes.RecordTypeSRV, (
|
||||
({ getFieldValue }) =>
|
||||
<>
|
||||
<Form.Item<Record<SRVRecord>> label={t('records.form.priority')} name={['content', 'priority']} required>
|
||||
<InputNumber controls={false} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item<Record<SRVRecord>> label={t('records.form.weight')} name={['content', 'weight']} required>
|
||||
<InputNumber controls={false} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item<Record<SRVRecord>> label={t('records.form.port')} name={['content', 'port']} required>
|
||||
<InputNumber controls={false} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item<Record<SRVRecord>> label={t('records.form.target')} name={['content', 'target']} required rules={[{
|
||||
validator() {
|
||||
const result = SRVRecord.validate(getFieldValue('content') as SRVRecord)
|
||||
return (result === true) ? Promise.resolve() : Promise.reject(result)
|
||||
}
|
||||
}]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</>
|
||||
)
|
||||
]
|
||||
])
|
||||
|
||||
return (
|
||||
<Modal
|
||||
onCancel={onCancel} onOk={confirm}
|
||||
title={
|
||||
<span>
|
||||
{
|
||||
(!record || !record.id || record.id < 1) ? t('common.new') : t('common.edit')
|
||||
}
|
||||
{
|
||||
t('records._')
|
||||
}
|
||||
</span>
|
||||
}
|
||||
confirmLoading={loading}
|
||||
cancelButtonProps={{
|
||||
icon: <CloseOutlined />,
|
||||
}}
|
||||
okButtonProps={{
|
||||
icon: <CheckOutlined />,
|
||||
htmlType: 'submit'
|
||||
}}
|
||||
|
||||
open={open}
|
||||
closeIcon={false}
|
||||
maskClosable={false}
|
||||
|
||||
centered
|
||||
destroyOnClose
|
||||
forceRender
|
||||
>
|
||||
<Form<Record> name='record' form={form}
|
||||
scrollToFirstError
|
||||
autoComplete='off'
|
||||
labelCol={{ span: 4 }}
|
||||
wrapperCol={{ span: 20 }}
|
||||
validateTrigger='onBlur'
|
||||
>
|
||||
<Form.Item<Record> hidden name='id' />
|
||||
<Form.Item<Record> hidden name='zone' />
|
||||
<Form.Item<Record> label={t('records.recordType')} required name='record_type'>
|
||||
<Select allowClear={false} options={recordTypeOptions} />
|
||||
</Form.Item>
|
||||
<Form.Item<Record> label={t('records.name')} required name='name' rules={[
|
||||
{
|
||||
validator(_rule, value) {
|
||||
const result = Record.validateName(value)
|
||||
return (result === true) ? Promise.resolve() : Promise.reject(result)
|
||||
},
|
||||
}
|
||||
]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item<Record> label='TTL' required name='ttl'>
|
||||
<InputNumber controls={false} addonAfter={t('common.unitForSecond')} />
|
||||
</Form.Item>
|
||||
<Form.Item<Record> noStyle shouldUpdate={(p, c) => p.record_type !== c.record_type}>
|
||||
{
|
||||
({ getFieldValue }: FormInstance<Record>) => {
|
||||
const e = controls.get(getFieldValue('record_type'))
|
||||
if (!e) {
|
||||
return <></>
|
||||
}
|
||||
return e({ getFieldValue } as FormInstance<Record>)
|
||||
}
|
||||
}
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
26
web/src/components/records/RecordOps.tsx
Normal file
26
web/src/components/records/RecordOps.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import { Button, Flex, Popconfirm, Tooltip } from "antd"
|
||||
import { DeleteOutlined, EditFilled } from "@ant-design/icons"
|
||||
import i18n from '../../locale'
|
||||
|
||||
const { t } = i18n
|
||||
|
||||
type Props = {
|
||||
onEdit(): void
|
||||
onDelete(): void
|
||||
}
|
||||
|
||||
export default function RecordOps({ onEdit, onDelete }: Props) {
|
||||
return (
|
||||
<Flex justify="end" gap='small'>
|
||||
<Tooltip title={t("common.edit")}>
|
||||
<Button icon={<EditFilled />} size="small" onClick={onEdit}/>
|
||||
</Tooltip>
|
||||
|
||||
<Popconfirm onConfirm={onDelete} title={t("common.deleteConfirm")}>
|
||||
<Tooltip title={t("common.delete")}>
|
||||
<Button danger type="primary" icon={<DeleteOutlined />} size="small" />
|
||||
</Tooltip>
|
||||
</Popconfirm>
|
||||
</Flex>
|
||||
)
|
||||
}
|
78
web/src/index.css
Normal file
78
web/src/index.css
Normal file
@ -0,0 +1,78 @@
|
||||
:root {
|
||||
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.2em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
background-color: #1a1a1a;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.25s;
|
||||
}
|
||||
button:hover {
|
||||
border-color: #646cff;
|
||||
}
|
||||
button:focus,
|
||||
button:focus-visible {
|
||||
outline: 4px auto -webkit-focus-ring-color;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
background-color: #ffffff;
|
||||
--records-layout-header-bgcolor: #eee;
|
||||
}
|
||||
a:hover {
|
||||
color: #747bff;
|
||||
}
|
||||
button {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
.records-layout-header{
|
||||
background-color: #eee;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--records-layout-header-bgcolor: #333;
|
||||
}
|
||||
}
|
18
web/src/isBrowserDarkTheme.ts
Normal file
18
web/src/isBrowserDarkTheme.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
const isBrowserDarkTheme = () => {
|
||||
const [isDarkTheme, setIsDarkTheme] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const mediaQueryList = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const handleChange = (e: { matches: boolean | ((prevState: boolean) => boolean); }) => setIsDarkTheme(e.matches);
|
||||
mediaQueryList.addEventListener('change', handleChange);
|
||||
setIsDarkTheme(mediaQueryList.matches);
|
||||
|
||||
return () => mediaQueryList.removeEventListener('change', handleChange);
|
||||
}, []);
|
||||
|
||||
return isDarkTheme
|
||||
};
|
||||
|
||||
export default isBrowserDarkTheme;
|
98
web/src/locale/en.ts
Normal file
98
web/src/locale/en.ts
Normal file
@ -0,0 +1,98 @@
|
||||
export default {
|
||||
common: {
|
||||
delete: 'Remove',
|
||||
remove: 'Remove',
|
||||
deleteConfirm: 'Are you sure?',
|
||||
removeConfirm: 'Are you sure?',
|
||||
edit: 'Edit',
|
||||
add: 'New',
|
||||
new: 'New',
|
||||
cancel: 'Cancel',
|
||||
confirm: 'OK',
|
||||
mandatory: 'This field is mandatory',
|
||||
unitForSecond: 'Second(s)'
|
||||
},
|
||||
api: {
|
||||
error400: {
|
||||
title: 'Bad Request (400)',
|
||||
content: 'Bad Parameters'
|
||||
},
|
||||
error401: {
|
||||
title: 'Unauthorized (401)',
|
||||
content: 'Refresh page and relogin'
|
||||
},
|
||||
error403: {
|
||||
title: 'Forbbiden (403)',
|
||||
content: 'Permission denied'
|
||||
},
|
||||
error404: {
|
||||
title: 'Not Found (404)',
|
||||
content: 'No such content'
|
||||
},
|
||||
error500: {
|
||||
title: "Internal Server Error (500)",
|
||||
content: "Check server log, please"
|
||||
},
|
||||
errorUnknown: {
|
||||
title: "Unknown Error",
|
||||
content: "Open console for details",
|
||||
}
|
||||
},
|
||||
domains: {
|
||||
'_': 'Domain',
|
||||
dnsRecord: 'DNS Record',
|
||||
delete: 'Remove Domain',
|
||||
deleteHint: 'All records of this domain will be WIPED!',
|
||||
confirm1: 'Please input',
|
||||
confirm2: 'for comfirmation',
|
||||
|
||||
form: {
|
||||
adminMail: 'Admin Email',
|
||||
mainDNS: 'Main DNS',
|
||||
},
|
||||
|
||||
errors: {
|
||||
domainName: 'Invalid domain name',
|
||||
mail: 'Invalid email',
|
||||
}
|
||||
},
|
||||
records: {
|
||||
'_': 'Record',
|
||||
name: 'Resource Record',
|
||||
recordType: 'Type',
|
||||
content: 'Record',
|
||||
search: 'Search...',
|
||||
|
||||
refresh: 'Refresh Interval',
|
||||
retry: 'Retry Interval',
|
||||
expire: 'Expiry Period',
|
||||
ttl: 'Negative TTL',
|
||||
|
||||
form: {
|
||||
text: 'Text',
|
||||
host: 'Host',
|
||||
preference: 'Preference',
|
||||
priority: 'Priority',
|
||||
weight: 'Weight',
|
||||
port: 'Port',
|
||||
target: 'Target',
|
||||
flag: 'Flag',
|
||||
tag: 'Tag',
|
||||
value: 'Value'
|
||||
},
|
||||
|
||||
errors: {
|
||||
endWithDot: 'should end with a dot',
|
||||
hasSpace: 'shoule have no space',
|
||||
badIPv4: 'invalid IPv4 address',
|
||||
badIPv6: 'invalid IPv6 address',
|
||||
badEmail: 'no @ for this email address',
|
||||
badName: {
|
||||
dotAndMinus: 'should not start or end with "." "-"',
|
||||
doubleDots: 'should have no contianus "."',
|
||||
logerThan63: 'should not longer than 63 characters splited by "."'
|
||||
},
|
||||
tooLong: 'too long'
|
||||
}
|
||||
}
|
||||
}
|
24
web/src/locale/index.ts
Normal file
24
web/src/locale/index.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import i18n from 'i18next'
|
||||
import { initReactI18next } from 'react-i18next'
|
||||
import LanguageDetector from 'i18next-browser-languagedetector'
|
||||
import zh from './zh'
|
||||
import en from './en'
|
||||
|
||||
i18n.use(LanguageDetector).use(initReactI18next)
|
||||
.init({
|
||||
debug: import.meta.env.DEV,
|
||||
fallbackLng: 'zh',
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
resources: {
|
||||
en: {
|
||||
translation: en
|
||||
},
|
||||
zh: {
|
||||
translation: zh
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export default i18n
|
98
web/src/locale/zh.ts
Normal file
98
web/src/locale/zh.ts
Normal file
@ -0,0 +1,98 @@
|
||||
export default {
|
||||
common: {
|
||||
delete: '删除',
|
||||
remove: '删除',
|
||||
deleteConfirm: '确定要删除吗?',
|
||||
removeConfirm: '确定要删除吗?',
|
||||
edit: '修改',
|
||||
add: '新增',
|
||||
new: '新增',
|
||||
cancel: '取消',
|
||||
confirm: '确定',
|
||||
mandatory: '此项必填',
|
||||
unitForSecond: '秒'
|
||||
},
|
||||
api: {
|
||||
error400: {
|
||||
title: '请求错误 (400)',
|
||||
content: '参数提交错误'
|
||||
},
|
||||
error401: {
|
||||
title: '未授权 (401)',
|
||||
content: '请刷新页面重新登录'
|
||||
},
|
||||
error403: {
|
||||
title: '拒绝访问 (403)',
|
||||
content: '你没有权限!'
|
||||
},
|
||||
error404: {
|
||||
title: '查无此项 (404)',
|
||||
content: '没有该项内容'
|
||||
},
|
||||
error500: {
|
||||
title: "服务器错误 (500)",
|
||||
content: "请检查系统日志"
|
||||
},
|
||||
errorUnknown: {
|
||||
title: "未知错误",
|
||||
content: "请打开控制台了解详情",
|
||||
}
|
||||
},
|
||||
domains: {
|
||||
'_': '域名',
|
||||
dnsRecord: 'DNS 记录',
|
||||
delete: '删除域名',
|
||||
deleteHint: '该域名所有记录将被删除!',
|
||||
confirm1: '请输入',
|
||||
confirm2: '以确认要删除的域名',
|
||||
|
||||
form: {
|
||||
adminMail: '管理员邮箱',
|
||||
mainDNS: '主 DNS 服务器',
|
||||
},
|
||||
|
||||
errors: {
|
||||
domainName: '这不是一个有效的域名',
|
||||
mail: '这不是一个有效的邮箱',
|
||||
}
|
||||
},
|
||||
records: {
|
||||
'_': '记录',
|
||||
name: '资源记录',
|
||||
recordType: '类型',
|
||||
content: '记录值',
|
||||
search: '搜索...',
|
||||
|
||||
refresh: '刷新时间',
|
||||
retry: '重试间隔',
|
||||
expire: '超期时间',
|
||||
ttl: '缓存时间',
|
||||
|
||||
form: {
|
||||
text: '文本',
|
||||
host: '主机',
|
||||
preference: '优先级',
|
||||
priority: '优先级',
|
||||
weight: '权重',
|
||||
port: '端口',
|
||||
target: '目标',
|
||||
flag: '标志',
|
||||
tag: '标签',
|
||||
value: '值'
|
||||
},
|
||||
|
||||
errors: {
|
||||
endWithDot: '应当以 . 结尾',
|
||||
hasSpace: '不能有空格',
|
||||
badIPv4: '不是有效的 IPv4 地址',
|
||||
badIPv6: '不是有效的 IPv6 地址',
|
||||
badEmail: '这里的邮箱不能有 @ 符号',
|
||||
badName: {
|
||||
dotAndMinus: '资源记录不能以 "."、"-" 开头或结尾',
|
||||
doubleDots: '资源记录不能有连续的 "."',
|
||||
logerThan63: '资源记录以 "." 分割的每个字符串长度不能超过63字符'
|
||||
},
|
||||
tooLong: '记录值过长'
|
||||
}
|
||||
}
|
||||
}
|
10
web/src/main.tsx
Normal file
10
web/src/main.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.tsx'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
28
web/src/router/index.tsx
Normal file
28
web/src/router/index.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
import { createHashRouter, redirect } from 'react-router-dom'
|
||||
import { Suspense, lazy } from 'react'
|
||||
import { Spin } from 'antd'
|
||||
|
||||
const DomainsView = lazy(() => import('../views/DomainsView'))
|
||||
const RecordsView = lazy(() => import('../views/RecordsView'))
|
||||
|
||||
const router = createHashRouter([
|
||||
{
|
||||
path: '/',
|
||||
loader: async () => {
|
||||
return redirect('/domains')
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/domains',
|
||||
id: 'domains',
|
||||
element: <Suspense fallback={<Spin size='large' />}><DomainsView /></Suspense>
|
||||
},
|
||||
{
|
||||
path: '/records/:domain',
|
||||
id: 'records',
|
||||
loader: args => args.params,
|
||||
element: <Suspense fallback={<Spin size='large' />}><RecordsView /></Suspense>
|
||||
}
|
||||
])
|
||||
|
||||
export default router
|
76
web/src/stores/domains.ts
Normal file
76
web/src/stores/domains.ts
Normal file
@ -0,0 +1,76 @@
|
||||
import { useState } from 'react'
|
||||
import api from '../api'
|
||||
|
||||
export class Domain {
|
||||
id?: number
|
||||
domain_name?: string
|
||||
main_dns?: string
|
||||
admin_email?: string
|
||||
serial_number?: number
|
||||
refresh_interval?: number
|
||||
retry_interval?: number
|
||||
expiry_period?: number
|
||||
negative_ttl?: number
|
||||
}
|
||||
|
||||
// example data for development
|
||||
const domainDevData: Domain[] = [
|
||||
{
|
||||
id: 1,
|
||||
domain_name: "example.com",
|
||||
main_dns: "ns1.example.com",
|
||||
admin_email: "admin@example.com",
|
||||
serial_number: 114514,
|
||||
refresh_interval: 86400,
|
||||
retry_interval: 7200,
|
||||
expiry_period: 3600000,
|
||||
negative_ttl: 86400
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
domain_name: "example.org",
|
||||
main_dns: "ns1.example.org",
|
||||
admin_email: "admin@example.org",
|
||||
serial_number: 1919810,
|
||||
refresh_interval: 86400,
|
||||
retry_interval: 7200,
|
||||
expiry_period: 3600000,
|
||||
negative_ttl: 86400
|
||||
},
|
||||
]
|
||||
|
||||
export const useDomainStore = () => {
|
||||
const [domains, setDomains] = useState<Domain[]>([])
|
||||
|
||||
async function loadDomains() {
|
||||
setDomains(import.meta.env.DEV ? domainDevData : (await api.get<Domain[]>('/domains')).data.data)
|
||||
}
|
||||
|
||||
async function addDomain(domain: Domain) {
|
||||
if (!import.meta.env.DEV) {
|
||||
domain = (await api.post("/domains", domain)).data.data
|
||||
} else if (!domain.id) {
|
||||
domain.id = Math.floor(1000 + Math.random() * 9000)
|
||||
}
|
||||
|
||||
setDomains(domains.concat(domain))
|
||||
}
|
||||
|
||||
async function updateDomain(domain: Domain) {
|
||||
console.log(domain)
|
||||
if (!import.meta.env.DEV) {
|
||||
await api.put("/domains", domain)
|
||||
}
|
||||
|
||||
setDomains(domains.map((e: Domain) => (e.id === domain.id || e.domain_name === domain.domain_name) ? domain : e))
|
||||
}
|
||||
|
||||
async function removeDomain(domain: Domain) {
|
||||
if (!import.meta.env.DEV) {
|
||||
await api.delete(`/domains/${domain.id}`)
|
||||
}
|
||||
setDomains(domains.filter(e => e.id !== domain.id))
|
||||
}
|
||||
|
||||
return { domains, loadDomains, addDomain, updateDomain, removeDomain }
|
||||
}
|
358
web/src/stores/records.ts
Normal file
358
web/src/stores/records.ts
Normal file
@ -0,0 +1,358 @@
|
||||
import { useState } from 'react'
|
||||
import i18n from '../locale'
|
||||
import api from '../api';
|
||||
const { t } = i18n
|
||||
export class ARecord {
|
||||
ip?: string
|
||||
|
||||
static validate(v: ARecord): true | Error {
|
||||
if (!v.ip || v.ip === '') return new Error(t('common.mandatory'))
|
||||
if (!/^((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])$/.test(v.ip)) return new Error(t('records.errors.badIPv4'))
|
||||
return true
|
||||
}
|
||||
|
||||
toString(): string | undefined {
|
||||
return this.ip
|
||||
}
|
||||
}
|
||||
|
||||
export class AAAARecord {
|
||||
ip?: string
|
||||
|
||||
static validate(v: AAAARecord): true | Error {
|
||||
if (!v.ip || v.ip === '') return new Error(t('common.mandatory'))
|
||||
if (!/^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$/.test(v.ip)) return new Error(t('records.errors.badIPv4'))
|
||||
return true
|
||||
}
|
||||
|
||||
toString(): string | undefined {
|
||||
return this.ip
|
||||
}
|
||||
}
|
||||
|
||||
export class TXTRecord {
|
||||
text?: string
|
||||
|
||||
static validate(v: TXTRecord): true | Error {
|
||||
if (!v.text || v.text === '') return new Error(t('common.mandatory'))
|
||||
if (v.text.length > 512) return new Error('records.errors.tooLong')
|
||||
return true
|
||||
}
|
||||
|
||||
toString(): string | undefined {
|
||||
return this.text
|
||||
}
|
||||
}
|
||||
|
||||
export class CNAMERecord {
|
||||
host?: string
|
||||
|
||||
static validate(v: CNAMERecord): true | Error {
|
||||
if (!v.host || v.host === '') return new Error(t('common.mandatory'))
|
||||
if (v.host.includes(' ')) return new Error(t('records.errors.hasSpace'))
|
||||
if (!v.host.endsWith('.')) return new Error(t('records.errors.endWithDot'))
|
||||
return true
|
||||
}
|
||||
|
||||
toString(): string | undefined {
|
||||
return this.host
|
||||
}
|
||||
}
|
||||
|
||||
export class NSRecord {
|
||||
host?: string
|
||||
|
||||
static validate(v: NSRecord): true | Error {
|
||||
if (!v.host || v.host === '') return new Error(t('common.mandatory'))
|
||||
if (v.host.includes(' ')) return new Error(t('records.errors.hasSpace'))
|
||||
if (!v.host.endsWith('.')) return new Error(t('records.errors.endWithDot'))
|
||||
return true
|
||||
}
|
||||
|
||||
toString(): string | undefined {
|
||||
return this.host
|
||||
}
|
||||
}
|
||||
|
||||
export class MXRecord {
|
||||
host?: string
|
||||
preference?: number
|
||||
|
||||
static validate(v: MXRecord): true | Error {
|
||||
if (!v.host || v.host === '' || !v.preference) return new Error(t('common.mandatory'))
|
||||
if (v.host.includes(' ')) return new Error(t('records.errors.hasSpace'))
|
||||
if (!v.host.endsWith('.')) return new Error(t('records.errors.endWithDot'))
|
||||
return true
|
||||
}
|
||||
|
||||
toString(): string | undefined {
|
||||
return Object.entries(this).map(i => i[1]).join(" ")
|
||||
}
|
||||
}
|
||||
|
||||
export class SRVRecord {
|
||||
priority?: number;
|
||||
weight?: number;
|
||||
port?: number;
|
||||
target?: string;
|
||||
|
||||
static validate(v: SRVRecord): true | Error {
|
||||
if (!v.port || !v.priority || !v.weight || !v.target || v.target === '') return new Error(t('common.mandatory'))
|
||||
if (v.target?.includes(' ')) return new Error(t('records.errors.hasSpace'))
|
||||
if (!v.target?.endsWith('.')) return new Error(t('records.errors.endWithDot'))
|
||||
return true
|
||||
}
|
||||
|
||||
toString(): string | undefined {
|
||||
return Object.entries(this).map(i => i[1]).join(" ")
|
||||
}
|
||||
}
|
||||
|
||||
export class SOARecord {
|
||||
ns?: string;
|
||||
MBox?: string;
|
||||
refresh?: number;
|
||||
retry?: number;
|
||||
expire?: number;
|
||||
minttl?: number;
|
||||
|
||||
static validate(v: SOARecord): true | Error {
|
||||
if (!v.refresh || !v.retry || !v.expire! || !v.minttl || !v.MBox || v.MBox === '' || !v.ns || v.ns === '') return new Error(t('common.mandatory'))
|
||||
if (v.ns?.includes(' ') || v.MBox?.includes(' ')) return new Error(t('records.errors.hasSpace'))
|
||||
if (!v.ns?.endsWith('.') || !v.MBox?.endsWith('.')) return new Error(t('records.errors.endWithDot'))
|
||||
if (v.MBox?.includes('@')) return new Error(t('records.errors.badEmail'))
|
||||
return true
|
||||
}
|
||||
|
||||
toString(): string | undefined {
|
||||
return Object.entries(this).map(i => i[1]).join(" ")
|
||||
}
|
||||
}
|
||||
|
||||
export class CAARecord {
|
||||
flag?: number
|
||||
tag?: string
|
||||
value?: string
|
||||
|
||||
static validate(v: CAARecord): true | Error {
|
||||
if (!v.flag || !v.tag || v.tag === '' || !v.value || v.value === '') return new Error(t('common.mandatory'))
|
||||
if (v.tag?.includes(' ')) return new Error(t('records.errors.hasSpace'))
|
||||
return true
|
||||
}
|
||||
|
||||
toString(): string | undefined {
|
||||
return Object.entries(this).map(i => i[1]).join(" ")
|
||||
}
|
||||
}
|
||||
|
||||
export enum RecordTypes {
|
||||
RecordTypeA = "A",
|
||||
RecordTypeAAAA = "AAAA",
|
||||
RecordTypeCNAME = "CNAME",
|
||||
RecordTypeSOA = "SOA",
|
||||
RecordTypeTXT = "TXT",
|
||||
RecordTypeNS = "NS",
|
||||
RecordTypeMX = "MX",
|
||||
RecordTypeCAA = "CAA",
|
||||
RecordTypeSRV = "SRV"
|
||||
}
|
||||
|
||||
export type RecordT = ARecord | AAAARecord | TXTRecord | CNAMERecord | NSRecord | MXRecord | SRVRecord | SOARecord | CAARecord
|
||||
|
||||
export class Record<T = RecordT> {
|
||||
id?: number
|
||||
zone?: string
|
||||
name?: string
|
||||
ttl?: number
|
||||
content?: T
|
||||
record_type?: RecordTypes
|
||||
|
||||
validate(): true | Error {
|
||||
const zone = Record.validateZone(this.zone!)
|
||||
if (zone !== true) return zone
|
||||
const name = Record.validateName(this.name!)
|
||||
if (name !== true) return name
|
||||
return true
|
||||
}
|
||||
|
||||
static validateZone(zone: string): true | Error {
|
||||
if (zone === '') return new Error(t('common.mandatory'))
|
||||
if (zone.includes(' ')) new Error(t('records.errors.hasSpace'))
|
||||
if (zone.endsWith('.')) return new Error(t('records.errors.endWithDot'))
|
||||
return true
|
||||
}
|
||||
|
||||
static validateName(name: string): true | Error {
|
||||
if (name === '') return new Error(t('common.mandatory'))
|
||||
|
||||
// RR should not contain space, and should not start or end with '-' or '.'
|
||||
if (name.includes(' ')) return new Error(t('records.errors.hasSpace'))
|
||||
if (name.startsWith('.') || name.endsWith('.')) return new Error(t('records.errors.badName.dotAndMinus'))
|
||||
if (name.startsWith('-') || name.endsWith('.')) return new Error(t('records.errors.badName.dotAndMinus'))
|
||||
|
||||
// RR should not have continuous dots
|
||||
if (name.includes('..')) new Error(t('records.errors.badName.doubleDots'))
|
||||
|
||||
// RR should not longer than 63 characters for every section seprated by '.'
|
||||
if (name.split('.').filter(e => e.length > 63).length > 0) return new Error(t('records.errors.badName.longerThan63'))
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// example data for development
|
||||
const recordDevData = new Map<string, Record[]>([
|
||||
['example.com', [
|
||||
{
|
||||
id: 1,
|
||||
zone: "example.com",
|
||||
name: "@",
|
||||
ttl: 3600,
|
||||
record_type: RecordTypes.RecordTypeSOA,
|
||||
content: {
|
||||
ns: "ns1.example.com.",
|
||||
MBox: "admin@example.com.",
|
||||
refresh: 86400,
|
||||
retry: 7200,
|
||||
expire: 3600000,
|
||||
minttl: 86400,
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
zone: "example.com",
|
||||
name: "@",
|
||||
ttl: 3600,
|
||||
record_type: RecordTypes.RecordTypeNS,
|
||||
content: {
|
||||
host: "ns1.example.com."
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
zone: "example.com",
|
||||
name: "@",
|
||||
ttl: 3600,
|
||||
record_type: RecordTypes.RecordTypeNS,
|
||||
content: {
|
||||
host: "ns2.example.com."
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
zone: "example.com",
|
||||
name: "www",
|
||||
ttl: 3600,
|
||||
record_type: RecordTypes.RecordTypeA,
|
||||
content: {
|
||||
ip: "233.233.233.233"
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
zone: "example.com",
|
||||
name: "cname",
|
||||
ttl: 3600,
|
||||
record_type: RecordTypes.RecordTypeCNAME,
|
||||
content: {
|
||||
host: "www.example.com."
|
||||
}
|
||||
}
|
||||
] as Record[]],
|
||||
['example.org', [
|
||||
{
|
||||
id: 1,
|
||||
zone: "example.org",
|
||||
name: "@",
|
||||
ttl: 3600,
|
||||
record_type: RecordTypes.RecordTypeSOA,
|
||||
content: {
|
||||
ns: "ns1.example.org.",
|
||||
MBox: "admin@example.org.",
|
||||
refresh: 86400,
|
||||
retry: 7200,
|
||||
expire: 3600000,
|
||||
minttl: 86400,
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
zone: "example.org",
|
||||
name: "@",
|
||||
ttl: 3600,
|
||||
record_type: RecordTypes.RecordTypeNS,
|
||||
content: {
|
||||
host: "ns1.example.org."
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
zone: "example.org",
|
||||
name: "@",
|
||||
ttl: 3600,
|
||||
record_type: RecordTypes.RecordTypeNS,
|
||||
content: {
|
||||
host: "ns2.example.org."
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
zone: "example.org",
|
||||
name: "www",
|
||||
ttl: 3600,
|
||||
record_type: RecordTypes.RecordTypeA,
|
||||
content: {
|
||||
ip: "233.233.233.233"
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
zone: "example.org",
|
||||
name: "cname",
|
||||
ttl: 3600,
|
||||
record_type: RecordTypes.RecordTypeCNAME,
|
||||
content: {
|
||||
host: "www.example.org."
|
||||
}
|
||||
}
|
||||
] as Record[]]
|
||||
])
|
||||
|
||||
export const useRecordStore = () => {
|
||||
const [records, setRecords] = useState<Record[]>([])
|
||||
|
||||
async function loadRecords(domain: string) {
|
||||
// TODO: load from api
|
||||
setRecords(import.meta.env.DEV ?
|
||||
recordDevData.get(domain)! :
|
||||
(await api.get<Record[]>(`/records/${domain}`)).data.data)
|
||||
}
|
||||
|
||||
async function addRecord(domain: string, record: Record) {
|
||||
// TODO: load from api
|
||||
if (!import.meta.env.DEV) {
|
||||
record = (await api.post(`/records/${domain}`, record)).data.data
|
||||
}
|
||||
|
||||
setRecords(records.concat(record))
|
||||
}
|
||||
|
||||
async function updateRecord(domain: string, record: Record) {
|
||||
// TODO: load from api
|
||||
if (!import.meta.env.DEV) {
|
||||
await api.put(`/records/${domain}`, record)
|
||||
}
|
||||
|
||||
setRecords(records.map(e => e.id === record.id ? record : e))
|
||||
}
|
||||
|
||||
async function removeRecord(domain: string, record: Record) {
|
||||
// TODO: load from api
|
||||
if (!import.meta.env.DEV) {
|
||||
await api.delete(`/records/${domain}/${record.id}`)
|
||||
}
|
||||
|
||||
setRecords(records.filter(e => e.id !== record.id))
|
||||
}
|
||||
|
||||
return { records, loadRecords, addRecord, updateRecord, removeRecord }
|
||||
}
|
4
web/src/views/DomainsView.css
Normal file
4
web/src/views/DomainsView.css
Normal file
@ -0,0 +1,4 @@
|
||||
.domain-info {
|
||||
width: 32vw;
|
||||
text-align: left;
|
||||
}
|
102
web/src/views/DomainsView.tsx
Normal file
102
web/src/views/DomainsView.tsx
Normal file
@ -0,0 +1,102 @@
|
||||
import './DomainsView.css'
|
||||
import { Domain, useDomainStore } from '../stores/domains'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { App, Button, Card, Space, Spin } from 'antd'
|
||||
import { PlusOutlined } from '@ant-design/icons'
|
||||
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import DomainDeleteModal from '../components/domains/DomainDeleteModal'
|
||||
import DomainCard from '../components/domains/DomainCard'
|
||||
import DomainEditModal from '../components/domains/DomainEditModal'
|
||||
import { ResponseError, getErrorInfo } from '../api'
|
||||
|
||||
const emptyDomain: Domain = { domain_name: '' }
|
||||
|
||||
export default function DomainsView() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [deleteModalShow, setDeleteModalShow] = useState(false)
|
||||
const [editModalShow, setEditModalShow] = useState(false)
|
||||
const [currentDomain, setCurrentDomain] = useState(emptyDomain)
|
||||
const { notification } = App.useApp()
|
||||
const domainStore = useDomainStore()
|
||||
const go = useNavigate()
|
||||
|
||||
function openDeleteModal(domain: Domain) {
|
||||
setCurrentDomain(domain)
|
||||
setDeleteModalShow(true)
|
||||
}
|
||||
|
||||
function closeDeleteModdal() {
|
||||
setDeleteModalShow(false)
|
||||
}
|
||||
|
||||
function openEditModal(domain: Domain) {
|
||||
setCurrentDomain(domain)
|
||||
setEditModalShow(true)
|
||||
}
|
||||
|
||||
function closeEditModal() {
|
||||
setEditModalShow(false)
|
||||
}
|
||||
|
||||
function newDomain() {
|
||||
openEditModal({
|
||||
domain_name: '',
|
||||
admin_email: '',
|
||||
main_dns: '',
|
||||
refresh_interval: 86400,
|
||||
retry_interval: 7200,
|
||||
expiry_period: 3600000,
|
||||
negative_ttl: 86400,
|
||||
serial_number: 1,
|
||||
})
|
||||
}
|
||||
|
||||
// called once only.
|
||||
useEffect(() => {
|
||||
domainStore.loadDomains().then(() => setLoading(false)).catch(e => {
|
||||
const msg = getErrorInfo(e as ResponseError)
|
||||
notification.error(msg)
|
||||
console.error(e)
|
||||
})
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
{
|
||||
loading ? <Spin size='large' /> :
|
||||
<>
|
||||
<Space direction="vertical" >
|
||||
{
|
||||
domainStore.domains.map(domain => (
|
||||
<DomainCard domain={domain}
|
||||
onDeleteClick={() => openDeleteModal(domain)}
|
||||
onRecordClick={() => go(`/records/${domain.domain_name}`)}
|
||||
onEditClick={() => openEditModal(domain)}
|
||||
key={domain.id}
|
||||
/>
|
||||
))
|
||||
}
|
||||
<Card>
|
||||
<Button icon={<PlusOutlined className='icon' />}
|
||||
block type="text" onClick={newDomain} />
|
||||
</Card>
|
||||
</Space>
|
||||
<DomainDeleteModal open={deleteModalShow}
|
||||
onCancel={closeDeleteModdal}
|
||||
onOk={closeDeleteModdal}
|
||||
domain={currentDomain}
|
||||
removeDomain={domainStore.removeDomain}
|
||||
/>
|
||||
<DomainEditModal open={editModalShow}
|
||||
onCancel={closeEditModal}
|
||||
onOk={closeEditModal}
|
||||
domain={currentDomain}
|
||||
editDomain={domainStore.updateDomain}
|
||||
createDomain={domainStore.addDomain}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
</>
|
||||
)
|
||||
}
|
38
web/src/views/RecordsView.css
Normal file
38
web/src/views/RecordsView.css
Normal file
@ -0,0 +1,38 @@
|
||||
.records-layout {
|
||||
position: fixed;
|
||||
display: block;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
padding-top: 1em;
|
||||
}
|
||||
|
||||
.right {
|
||||
position:absolute ;
|
||||
right: 64px;
|
||||
}
|
||||
|
||||
.records-layout-header {
|
||||
background-color: var(--records-layout-header-bgcolor);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.display-as-inline {
|
||||
display:inline-block;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
h1.title {
|
||||
font-weight:normal;
|
||||
font-size: 14pt;
|
||||
}
|
||||
|
||||
h2.subtitle {
|
||||
font-weight:normal;
|
||||
font-size: 12pt;
|
||||
}
|
119
web/src/views/RecordsView.tsx
Normal file
119
web/src/views/RecordsView.tsx
Normal file
@ -0,0 +1,119 @@
|
||||
import { LeftOutlined, PlusOutlined, SearchOutlined } from "@ant-design/icons"
|
||||
import { App, Button, Flex, Input, Layout, Spin, Table, Typography, theme } from "antd"
|
||||
import { Params, useLoaderData, useNavigate } from "react-router-dom"
|
||||
import './RecordsView.css'
|
||||
import i18n from '../locale'
|
||||
import { useEffect, useState } from "react"
|
||||
import { type Record, RecordT, useRecordStore, RecordTypes } from "../stores/records"
|
||||
import { ResponseError, getErrorInfo } from "../api"
|
||||
import RecordOps from "../components/records/RecordOps"
|
||||
import RecordEditModal from "../components/records/RecordEditModal"
|
||||
|
||||
const { t } = i18n
|
||||
const emptyRecord: Record<RecordT> = {} as Record
|
||||
|
||||
export default function RecordsView() {
|
||||
const { domain } = useLoaderData() as Params<string>
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [searchText, setSearchText] = useState<string>('')
|
||||
const [editModalShow, setEditModalShow] = useState(false)
|
||||
const [currentRecord, setCurrentRecord] = useState<Record>(emptyRecord)
|
||||
const { notification } = App.useApp()
|
||||
const {
|
||||
token: { colorBgContainer, borderRadiusLG },
|
||||
} = theme.useToken();
|
||||
const go = useNavigate()
|
||||
const recordStore = useRecordStore()
|
||||
|
||||
useEffect(() => {
|
||||
if (domain)
|
||||
recordStore.loadRecords(domain).then(() => setLoading(false)).catch(e => {
|
||||
const msg = getErrorInfo(e as ResponseError)
|
||||
notification.error(msg)
|
||||
console.error(e)
|
||||
})
|
||||
}, [domain])
|
||||
|
||||
function closeEditModal() {
|
||||
setCurrentRecord(emptyRecord)
|
||||
setEditModalShow(false)
|
||||
}
|
||||
|
||||
function openEditModal(record: Record) {
|
||||
setCurrentRecord(record)
|
||||
setEditModalShow(true)
|
||||
}
|
||||
|
||||
function newRecord() {
|
||||
openEditModal({
|
||||
zone: `${domain}.`,
|
||||
name: '',
|
||||
record_type: RecordTypes.RecordTypeA,
|
||||
ttl: 600
|
||||
} as Record)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{
|
||||
loading ? <Spin size='large' /> :
|
||||
<>
|
||||
<Layout className="records-layout">
|
||||
<Layout.Header className="records-layout-header">
|
||||
<Flex align='center' className="toolbar">
|
||||
<Flex align="center" gap='small'>
|
||||
<Button onClick={() => go('/domains')} type="text" icon={<LeftOutlined />} />
|
||||
<Typography.Title level={1} className="display-as-inline title">{t('domains.dnsRecord')}</Typography.Title>
|
||||
<Typography.Title level={2} type='secondary' className="display-as-inline subtitle">{domain}</Typography.Title>
|
||||
</Flex>
|
||||
<Flex align="center" gap='small' className="right">
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={newRecord}>
|
||||
{t('common.add')}
|
||||
</Button>
|
||||
<Input prefix={<SearchOutlined />} placeholder={t('records.search')} onChange={v => setSearchText(v.target.value)} />
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Layout.Header>
|
||||
<Layout.Content style={{
|
||||
margin: 24,
|
||||
borderRadius: borderRadiusLG,
|
||||
minHeight: 480,
|
||||
background: colorBgContainer,
|
||||
}}>
|
||||
<Table<Record<RecordT>>
|
||||
dataSource={recordStore.records
|
||||
.filter(i => i.record_type !== RecordTypes.RecordTypeSOA)
|
||||
.filter(i => i.name?.includes(searchText) || Object.entries(i.content as RecordT).map(i => i[1]).join(" ").includes(searchText))
|
||||
}
|
||||
pagination={{ defaultPageSize: 20 }}
|
||||
rowKey={e => `${e.id}`}
|
||||
>
|
||||
<Table.Column<Record<RecordT>> title='#' render={(_v, _r, index) => index + 1} />
|
||||
<Table.Column<Record<RecordT>> title={t("records.name")} dataIndex='name' key='name' />
|
||||
<Table.Column<Record<RecordT>> title={t('records.recordType')} dataIndex='record_type' key='record_type' />
|
||||
<Table.Column<Record<RecordT>> title={t('records.content')} render={(v) => Object.entries(v.content).map(i => i[1]).join(" ")} />
|
||||
<Table.Column<Record<RecordT>> title='TTL' key='ttl' dataIndex='ttl' />
|
||||
<Table.Column<Record<RecordT>> key='op' render={(v: Record) =>
|
||||
<RecordOps
|
||||
onDelete={() => {
|
||||
if (domain)
|
||||
recordStore.removeRecord(domain, v).catch(e => {
|
||||
const msg = getErrorInfo(e as ResponseError)
|
||||
notification.error(msg)
|
||||
console.error(e)
|
||||
})
|
||||
}} onEdit={() => openEditModal(v)}
|
||||
/>
|
||||
} />
|
||||
</Table>
|
||||
</Layout.Content>
|
||||
</Layout>
|
||||
<RecordEditModal open={editModalShow} onCancel={closeEditModal}
|
||||
onOk={closeEditModal} record={currentRecord}
|
||||
editRecord={record => recordStore.updateRecord(domain!, record)}
|
||||
createRecord={record => recordStore.addRecord(domain!, record)} />
|
||||
</>
|
||||
}
|
||||
</>
|
||||
)
|
||||
}
|
1
web/src/vite-env.d.ts
vendored
Normal file
1
web/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
25
web/tsconfig.json
Normal file
25
web/tsconfig.json
Normal file
@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
11
web/tsconfig.node.json
Normal file
11
web/tsconfig.node.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
7
web/vite.config.ts
Normal file
7
web/vite.config.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react-swc'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
Loading…
Reference in New Issue
Block a user