diff --git a/.air.toml b/.air.toml index 05d3a24..336884b 100644 --- a/.air.toml +++ b/.air.toml @@ -14,7 +14,7 @@ tmp_dir = "tmp" follow_symlink = false full_bin = "" include_dir = [] - include_ext = ["go", "tpl", "tmpl", "html"] + include_ext = ["go", "tpl", "tmpl", "html", "gohtml"] include_file = [] kill_delay = "0s" log = "build-errors.log" diff --git a/build/init.go b/build/init.go new file mode 100644 index 0000000..e51e1ca --- /dev/null +++ b/build/init.go @@ -0,0 +1,10 @@ +package build + +import "gorm.io/gorm" + +var db *gorm.DB + +func Init(gdb *gorm.DB) error { + db = gdb + return nil +} diff --git a/deploy.yml b/deploy.yml index 9f5be93..f8c95e6 100644 --- a/deploy.yml +++ b/deploy.yml @@ -1,8 +1,7 @@ -name: Deploy - -vcs: - branch: main +# 使用的分支 +branch: main +# 构建步骤 steps: - name: Clone repository uses: actions/checkout@v3 @@ -19,6 +18,10 @@ steps: - name: Build step run: "npm install && npm run build" + env: + VERSION: 1.0 + USERNAME: root + PASSWORD: pwd - name: Upload to Deno Deploy uses: denoland/deployctl@v1 diff --git a/entities/build.go b/entities/build.go new file mode 100644 index 0000000..2585078 --- /dev/null +++ b/entities/build.go @@ -0,0 +1,75 @@ +package entities + +import ( + "devops/util/dts" + "time" +) + +// BuildStatus 构建状态 +type BuildStatus string + +const ( + BuildPending BuildStatus = "pending" // 等待运行 + BuildRunning BuildStatus = "running" // 正在运行 + BuildSuccess BuildStatus = "success" // 构建成功 + BuildFailure BuildStatus = "failure" // 构建失败 + BuildAborted BuildStatus = "aborted" // 构建中断 +) + +// Build 项目构建 +type Build struct { + // 构建编号 + ID string `json:"id" gorm:"primaryKey"` + // 构建项目 + ProjectID string `json:"projectId"` + // 关联项目,如果项目删除也删除 + Project *Project `json:"project,omitempty" gorm:"foreignKey:ProjectID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` + // 使用的仓库分支名称 + Branch string `json:"branch"` + // 构建时,项目最后一次提交的 commit 标识 + CommitId string `json:"commitId"` + // 构建时,项目最后一次提交的 commit 信息 + CommitText string `json:"commitText"` + // 构建项目时的标准输出内容(不包含构建步骤的) + Stdout string `json:"stdout"` + // 构建项目时的错误输出内容(不包含构建步骤的) + Stderr string `json:"startedError"` + // 项目构建步骤 + Steps []BuildStep `json:"steps"` + // 启动构建程序的用户的编号,如果是通过 webhook 修改,会被置空 + StartedBy dts.NullUint `json:"startedBy"` + // 启动构建项目时的时间 + StartedAt time.Time `json:"startedAt"` + // 手动停止该项目构建的用户的编号,自动完成会将该值置空。 + StoppedBy dts.NullUint `json:"stoppedBy"` + // 手动停止或自动完成该项目构建时的时间 + StoppedAt time.Time `json:"stoppedAt"` + // 当前所处状态 + Status BuildStatus `json:"status"` +} + +// BuildStep 构建步骤日志 +type BuildStep struct { + // 日志编号 + Id uint `json:"id" gorm:"primaryKey"` + // 归属的构建编号 + BuildID string `json:"buildId"` + // 关联项目,如果项目删除也删除 + Build *Build `json:"build,omitempty" gorm:"foreignKey:BuildID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` + // 构建步骤名称 + Name string `json:"name"` + // 运行构建步骤的命令 + Run string `json:"run"` + // 运行构建步骤时的环境变量 + Env map[string]string `json:"env" gorm:"serializer:json"` + // 构建步骤的标准输出内容 + Stdout string `json:"stdout"` + // 构建步骤的错误输出内容 + Stderr string `json:"stderr"` + // 开始该步骤时的时间 + StartedAt time.Time `json:"startedAt"` + // 结束该步骤时的时间 + StoppedAt time.Time `json:"stoppedAt"` + // 当前所处状态 + Status BuildStatus `json:"status"` +} diff --git a/entities/init.go b/entities/init.go new file mode 100644 index 0000000..6ef7d75 --- /dev/null +++ b/entities/init.go @@ -0,0 +1,33 @@ +package entities + +import ( + "encoding/gob" + "log" + + "gorm.io/gorm" +) + +func Init(db *gorm.DB) error { + err := db.AutoMigrate( + &User{}, + &Project{}, + &Build{}, + &BuildStep{}, + ) + if err != nil { + log.Fatalln(err) + } + err = db.Model(&User{}).FirstOrCreate(&User{ + ID: 1, + Username: "admin", + RawPassword: "111111", + }, User{ID: 1}).Error + if err != nil { + log.Fatalln(err) + } + + // 使用 github.com/gorilla/sessions 需要 + gob.Register(&User{}) + + return nil +} diff --git a/entities/project.go b/entities/project.go new file mode 100644 index 0000000..872fc83 --- /dev/null +++ b/entities/project.go @@ -0,0 +1,33 @@ +package entities + +import ( + "devops/util/dts" + "time" +) + +// Project 项目 +type Project struct { + // 项目编号,推荐使用 ObjectId 算法生成,也可以使用 UUID/NanoID 等。 + ID string `json:"id" gorm:"primaryKey"` + // 项目名称 + Name string `json:"name" gorm:"unique"` + // 项目别称,显示使用 + Alias string `json:"alias" gorm:"unique"` + // 项目介绍 + Intro string `json:"intro"` + // 项目网站 + Website string `json:"website"` + // 项目的仓库地址,比如 GitHub、Gitee、Gitlab、Gitea 或自建的仓库, + // 如果是私有仓库,应该携带授权信息(建议使用私有令牌而不是账号密码)。 + RepositoryURL string `json:"repositoryURL"` + // 用于构建的仓库分支,若置空则使用主分支 + Branch string `json:"branch"` + // 创建项目的用户编号 + CreatedBy uint `json:"createdBy"` + // 创建项目时的时间 + CreatedAt time.Time `json:"createdAt"` + // 上一次修改项目信息的用户编号 + UpdatedBy dts.NullUint `json:"updatedBy"` + // 上一次修改项目信息的时间 + UpdatedAt dts.NullTime `json:"updatedAt" gorm:"autoUpdateTime:false"` +} diff --git a/entities/user.go b/entities/user.go new file mode 100644 index 0000000..d0fdb57 --- /dev/null +++ b/entities/user.go @@ -0,0 +1,32 @@ +package entities + +import ( + "errors" + "time" + + "gorm.io/gorm" + "zestack.dev/misc" +) + +// User 用户 +type User struct { + ID uint `json:"id" gorm:"primaryKey"` + Username string `json:"username" gorm:"unique"` + RawPassword string `json:"-" gorm:"-"` // 原始密码 + Password string `json:"password"` + Disabled bool `json:"disabled" gorm:"default:false"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +func (u *User) BeforeCreate(_ *gorm.DB) error { + if u.RawPassword != "" { + hash, err := misc.PasswordHash(u.RawPassword) + if err != nil { + return err + } + u.Password = hash + return nil + } + return errors.New("缺少密码") +} diff --git a/go.mod b/go.mod index 2e74f15..a9e7075 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( gopkg.in/yaml.v3 v3.0.1 gorm.io/driver/sqlite v1.5.6 gorm.io/gorm v1.25.11 + zestack.dev/env v0.0.0-20240108012311-632035163eec zestack.dev/misc v0.0.0-20240815120320-73293b130b9f zestack.dev/slim v0.0.0-20240815120229-098d3294fc1a ) @@ -19,6 +20,7 @@ require ( github.com/gorilla/securecookie v1.1.2 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect + github.com/joho/godotenv v1.5.1 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-sqlite3 v1.14.22 // indirect github.com/rs/xid v1.6.0 // indirect @@ -27,6 +29,7 @@ require ( golang.org/x/sync v0.6.0 // indirect golang.org/x/sys v0.20.0 // indirect golang.org/x/text v0.15.0 // indirect + zestack.dev/cast v0.0.0-20240523001414-e34212374a23 // indirect zestack.dev/color v0.0.0-20240522040239-8edfb0bd027f // indirect zestack.dev/log v0.0.0-20240523001421-24d58305cd03 // indirect ) diff --git a/go.sum b/go.sum index 368eba0..0a46788 100644 --- a/go.sum +++ b/go.sum @@ -5,6 +5,7 @@ github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7 github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= @@ -76,11 +77,14 @@ github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkr github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= -github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= @@ -101,8 +105,11 @@ github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= @@ -124,6 +131,8 @@ github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/wader/gormstore/v2 v2.0.3 h1:/29GWPauY8xZkpLnB8hsp+dZfP3ivA9fiDw1YVNTp6U= github.com/wader/gormstore/v2 v2.0.3/go.mod h1:sr3N3a8F1+PBc3fHoKaphFqDXLRJ9Oe6Yow0HxKFbbg= github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= @@ -204,8 +213,9 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= @@ -226,8 +236,12 @@ gorm.io/gorm v1.24.0/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA= gorm.io/gorm v1.25.11 h1:/Wfyg1B/je1hnDx3sMkX+gAlxrlZpn6X0BXRlwXlvHg= gorm.io/gorm v1.25.11/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= +zestack.dev/cast v0.0.0-20240523001414-e34212374a23 h1:xlcaGxt2tomZX91jzN7mk4vyy+d3fImb7FQcr8hd9Aw= +zestack.dev/cast v0.0.0-20240523001414-e34212374a23/go.mod h1:Z0WKru8M3FN1fDhqVEzw+I8MNNOPo3bc9lzB6oEJW6s= zestack.dev/color v0.0.0-20240522040239-8edfb0bd027f h1:5tSc5qVy2f6vyG5LVRETRe8tAFeZKxcjFsl0Jy7rRUQ= zestack.dev/color v0.0.0-20240522040239-8edfb0bd027f/go.mod h1:lHvP85VRaqzm4E/9Q9kW/WmEnEHL4PQ9Fls5xDah3zI= +zestack.dev/env v0.0.0-20240108012311-632035163eec h1:VOz6k4dJeHHoozGrF6ISIGaR7amRXGCJfMH+0amYrf0= +zestack.dev/env v0.0.0-20240108012311-632035163eec/go.mod h1:TgAagxCpU5oBFIHQSSuVcGw3ygfNvm3CSU92t/ufEWA= zestack.dev/log v0.0.0-20240523001421-24d58305cd03 h1:ZNnBVHIiV4R2SBypa24SLuhkrc9unlvPdXg/u0ZqSro= zestack.dev/log v0.0.0-20240523001421-24d58305cd03/go.mod h1:1ZoaLtIYHxx/J7jFoQpNLxsCIcpBZ1NQWFjafTEBzN8= zestack.dev/misc v0.0.0-20240815120320-73293b130b9f h1:77RL7xP7l0kV8s12EDo/JcQDbYuuwfbBO3JhYu9V8MI= diff --git a/main.go b/main.go index 4360e78..70ed636 100644 --- a/main.go +++ b/main.go @@ -1,493 +1,20 @@ package main import ( - "bytes" - "database/sql" - "database/sql/driver" - "encoding/gob" - "encoding/json" - "errors" - "gopkg.in/yaml.v3" + "devops/build" + "devops/entities" + "devops/routes" "html/template" "io" "log" - "net/http" - "os" - "os/exec" - "sync" - "time" - "github.com/gorilla/sessions" - "github.com/gorilla/websocket" - "github.com/wader/gormstore/v2" "gorm.io/driver/sqlite" "gorm.io/gorm" - "zestack.dev/misc" + "gorm.io/gorm/schema" + "zestack.dev/env" "zestack.dev/slim" ) -const ( - // Time allowed to write a message to the peer. - writeWait = 10 * time.Second - - // Time allowed to read the next pong message from the peer. - pongWait = 60 * time.Second - - // Send pings to peer with this period. Must be less than pongWait. - pingPeriod = (pongWait * 9) / 10 - - // Maximum message size allowed from peer. - maxMessageSize = 512 -) - -var ( - newline = []byte{'\n'} - space = []byte{' '} - - upgrader *websocket.Upgrader - clients *sync.Map - broadcast chan []byte - - store sessions.Store - db *gorm.DB -) - -type NullString sql.NullString - -// Scan implements the [Scanner] interface. -func (ns *NullString) Scan(value any) error { - ss := new(sql.NullString) - err := ss.Scan(value) - ns.String = ss.String - ns.Valid = ss.Valid - return err -} - -// Value implements the [driver.Valuer] interface. -func (ns NullString) Value() (driver.Value, error) { - if !ns.Valid { - return nil, nil - } - return ns.String, nil -} - -func (ns NullString) MarshalJSON() ([]byte, error) { - if ns.Valid { - return json.Marshal(ns.String) - } - return json.Marshal(nil) -} - -func (ns *NullString) UnmarshalJSON(b []byte) error { - if string(b) == "null" { - ns.Valid = false - return nil - } - err := json.Unmarshal(b, &ns.String) - if err == nil { - ns.Valid = true - } - return err -} - -type NullTime sql.NullTime - -func (nt *NullTime) Scan(value any) error { - st := new(sql.NullTime) - err := st.Scan(value) - nt.Time = st.Time - nt.Valid = st.Valid - return err -} - -func (nt NullTime) Value() (driver.Value, error) { - if !nt.Valid { - return nil, nil - } - return nt.Time, nil -} - -func (nt NullTime) MarshalJSON() ([]byte, error) { - if nt.Valid { - return nt.Time.MarshalJSON() - } - return json.Marshal(nil) -} - -func (nt *NullTime) UnmarshalJSON(b []byte) error { - if string(b) == "null" { - nt.Valid = false - return nil - } - err := json.Unmarshal(b, &nt.Time) - if err == nil { - nt.Valid = true - } - return err -} - -type NullUint struct { - V uint - Valid bool -} - -func (nu *NullUint) Scan(value any) error { - sn := new(sql.Null[uint]) - err := sn.Scan(value) - nu.V = sn.V - nu.Valid = sn.Valid - return err -} - -func (nu NullUint) Value() (driver.Value, error) { - if !nu.Valid { - return nil, nil - } - // 注意:driver.Value 不支持 uint - return int64(nu.V), nil -} - -func (nu NullUint) MarshalJSON() ([]byte, error) { - if nu.Valid { - return json.Marshal(nu.V) - } - return json.Marshal(nil) -} - -func (nu *NullUint) UnmarshalJSON(b []byte) error { - if string(b) == "null" { - nu.Valid = false - return nil - } - err := json.Unmarshal(b, &nu.V) - if err == nil { - nu.Valid = true - } - return err -} - -// User 用户 -type User struct { - ID uint `json:"id" gorm:"primaryKey"` - Username string `json:"username" gorm:"unique"` - RawPassword string `json:"-" gorm:"-"` // 原始密码 - Password string `json:"password"` - Disabled bool `json:"disabled" gorm:"default:false"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` -} - -func (u *User) BeforeCreate(_ *gorm.DB) error { - if u.RawPassword != "" { - hash, err := misc.PasswordHash(u.RawPassword) - if err != nil { - return err - } - u.Password = hash - return nil - } - return errors.New("缺少密码") -} - -// Project 项目 -type Project struct { - // 项目编号,推荐使用 ObjectId 算法生成,也可以使用 UUID/NanoID 等。 - ID string `json:"id" gorm:"primaryKey"` - // 项目名称 - Name string `json:"name" gorm:"unique"` - // 项目介绍 - Intro string `json:"intro"` - // 项目网站 - Website string `json:"website"` - // 项目的仓库地址,比如 GitHub、Gitee、Gitlab、Gitea 或自建的仓库, - // 如果是私有仓库,应该携带授权信息(建议使用私有令牌而不是账号密码)。 - RepositoryURL string `json:"repositoryURL"` - // 用于构建的仓库分支,若置空则使用主分支 - Branch string `json:"branch"` - // 创建项目的用户编号 - CreatedBy uint `json:"createdBy"` - // 创建项目时的时间 - CreatedAt time.Time `json:"createdAt"` - // 上一次修改项目信息的用户编号 - UpdatedBy NullUint `json:"updatedBy"` - // 上一次修改项目信息的时间 - UpdatedAt NullTime `json:"updatedAt" gorm:"autoUpdateTime:false"` -} - -// BuildStatus 构建状态 -type BuildStatus string - -const ( - BuildRunning BuildStatus = "running" // 正在运行 - BuildSuccess BuildStatus = "success" // 构建成功 - BuildFailure BuildStatus = "failure" // 构建失败 - BuildAborted BuildStatus = "aborted" // 构建中断 -) - -// Build 项目构建 -type Build struct { - // 构建编号 - ID string `json:"id" gorm:"primaryKey"` - // 构建项目 - ProjectID string `json:"projectId"` - // 关联项目,如果项目删除也删除 - Project *Project `json:"project,omitempty" gorm:"foreignKey:ProjectID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` - // 构建时,项目最后一次提交的 commit 标识 - CommitId string `json:"commitId"` - // 构建时,项目最后一次提交的 commit 信息 - CommitText string `json:"commitText"` - // 非构建步骤失败时的错误信息 - Error NullString `json:"startedError"` - // 启动构建程序的用户的编号,如果是通过 webhook 修改,会被置空 - StartedBy NullUint `json:"startedBy"` - // 启动构建项目时的时间 - StartedAt time.Time `json:"startedAt"` - // 手动停止或自动完成该项目构建时的时间 - StoppedAt NullTime `json:"stoppedAt"` - // 手动停止该项目构建的用户的编号,自动完成会将该值置空。 - StoppedBy NullUint `json:"stoppedBy"` - // 当前所处状态 - Status BuildStatus `json:"status"` - - // 步骤日志 - Logs []BuildLog `json:"logs"` - - // 创建构建步骤的用户编号,如果是通过 webhook 修改,会被置空 - CreatedBy NullUint `json:"createdBy"` - // 创建构建步骤时的时间 - CreatedAt time.Time `json:"createdAt"` - // 上一次修改构建步骤信息的用户编号,如果是通过 webhook 修改,会被置空 - UpdatedBy NullUint `json:"updatedBy"` - // 上一次修改构建步骤信息的时间 - UpdatedAt NullTime `json:"updatedAt" gorm:"autoUpdateTime:false"` -} - -// BuildLog 构建步骤日志 -type BuildLog struct { - // 日志编号 - Id uint `json:"id" gorm:"primaryKey"` - // 归属的构建编号 - BuildID string `json:"buildId"` - // 关联项目,如果项目删除也删除 - Build *Build `json:"build,omitempty" gorm:"foreignKey:BuildID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` - // 构建步骤名称 - Name string `json:"name"` - // 该构建步骤使用的程序 - Uses string `json:"uses"` - // 该构建步骤时传递给程序的参数 - With map[string]string `json:"with" gorm:"serializer:json"` - // 程序的运行日志 - Logs []string `json:"logs" gorm:"serializer:json"` - // 当前所处状态 - Status BuildStatus `json:"status"` - // 开始该步骤时的时间 - StartedAt time.Time `json:"startedAt"` - // 结束该步骤时的时间 - StoppedAt time.Time `json:"stoppedAt"` -} - -type BuildConfig struct { - Name string `json:"name" yaml:"name"` - VCS VCSConfig `json:"vcs" yaml:"vcs"` - Steps []StepConfig `json:"steps" yaml:"steps"` -} - -type VCSConfig struct { - Branch string `json:"branch" yaml:"branch"` -} - -type StepConfig struct { - Name string `json:"name" yaml:"name"` - Uses string `json:"uses" yaml:"uses"` - With map[string]string `json:"with" yaml:"with"` -} - -func Fail(c slim.Context, msg string, code ...int) error { - statusCode := http.StatusBadRequest - if len(code) > 0 { - statusCode = code[0] - } - return c.JSON(statusCode, slim.Map{ - "ok": false, - "msg": msg, - }) -} - -func AuthSession(c slim.Context, next slim.HandlerFunc) error { - sess, err := store.Get(c.Request(), "session-key") - if err != nil { - return err - } - user, ok := sess.Values["user"].(*User) - if !ok { - return Fail(c, "请登录", http.StatusUnauthorized) - } - c.Set("user", user) - return next(c) -} - -func handleLogin(c slim.Context) error { - var req struct { - Username string `json:"username" form:"username"` - Password string `json:"password" form:"password"` - } - if err := c.Bind(&req); err != nil { - return err - } - if req.Username == "" { - return c.Redirect(301, "/login?error=用户名不能为空") - } - if req.Password == "" { - return c.Redirect(301, "/login?error=登录密码不能为空") - } - var user User - err := db.Model(&User{}).Where("username", req.Username).First(&user).Error - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return c.Redirect(301, "/login?error=用户名或密码错误") - } - return err - } - if !misc.PasswordVerify(req.Password, user.Password) { - return c.Redirect(301, "/login?error=用户名或密码错误") - } - sess, err := store.Get(c.Request(), "session-key") - if err != nil { - return err - } - sess.Values["user"] = &user - err = sess.Save(c.Request(), c.Response()) - if err != nil { - return err - } - return c.Redirect(http.StatusMovedPermanently, "/") -} - -func handleLogout(c slim.Context) error { - // Get a session. We're ignoring the error resulted from decoding an - // existing session: Get() always returns a session, even if empty. - session, _ := store.Get(c.Request(), "session-name") - // Set some session values. - session.Values["foo"] = nil - // Save it before we write to the response/return from the handler. - err := session.Save(c.Request(), c.Response()) - if err != nil { - return err - } - return c.Redirect(http.StatusMovedPermanently, "/") -} - -func handleEcho(c slim.Context) error { - conn, err := upgrader.Upgrade(c.Response(), c.Request(), nil) - if err != nil { - return err - } - - clients.Store(conn, struct{}{}) - defer clients.Delete(conn) - - // TODO 使用 pool 实现复用 - exit := make(chan error) - defer close(exit) - - stop := func(err error) { - select { - case <-exit: - default: - exit <- err - } - } - - go func() { - conn.SetReadLimit(maxMessageSize) - conn.SetReadDeadline(time.Now().Add(pongWait)) - conn.SetPongHandler(func(string) error { - conn.SetReadDeadline(time.Now().Add(pongWait)) - return nil - }) - for { - _, message, ex := conn.ReadMessage() - if ex != nil { - if websocket.IsUnexpectedCloseError(ex, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { - stop(ex) - } else { - stop(nil) - } - return - } - message = bytes.TrimSpace(bytes.Replace(message, newline, space, -1)) - broadcast <- message - } - }() - - go func() { - ticker := time.NewTicker(pingPeriod) - defer func() { - ticker.Stop() - conn.Close() - }() - - for { - for { - select { - case message, ok := <-broadcast: - conn.SetWriteDeadline(time.Now().Add(writeWait)) - if !ok { - // The hub closed the channel. - conn.WriteMessage(websocket.CloseMessage, []byte{}) - stop(nil) - return - } - - w, err2 := conn.NextWriter(websocket.TextMessage) - if err2 != nil { - stop(err2) - return - } - w.Write(message) - - // Add queued chat messages to the current websocket message. - n := len(broadcast) - for i := 0; i < n; i++ { - w.Write(newline) - w.Write(<-broadcast) - } - - if err3 := w.Close(); err3 != nil { - stop(err3) - return - } - case <-ticker.C: - conn.SetWriteDeadline(time.Now().Add(writeWait)) - if err4 := conn.WriteMessage(websocket.PingMessage, nil); err4 != nil { - stop(err4) - return - } - } - } - } - }() - - return <-exit -} - -func handleStart(c slim.Context) error { - cmd := exec.Command("go") - cmd.Stdout = c.Response() - cmd.Stderr = c.Response() - cmd.Start() - cmd.Wait() - return nil -} - -func handleAbort(c slim.Context) error { - return c.String(200, "ok") -} - -func handleHome(c slim.Context) error { - return c.Render(http.StatusOK, "index.gohtml", "ws://"+c.Request().Host+"/echo") -} - type Template struct { templates *template.Template } @@ -498,71 +25,48 @@ func (t *Template) Render(c slim.Context, w io.Writer, name string, data any) er func main() { var err error - db, err = gorm.Open(sqlite.Open("devops.db"), &gorm.Config{}) - if err != nil { + var db *gorm.DB + + // 初始化环境变量 + if err = env.Init(); err != nil { log.Fatalln(err) } - err = db.AutoMigrate( - &User{}, - &Project{}, - &Build{}, - &BuildLog{}, - ) - if err != nil { + + // 初始化数据库 + if db, err = gorm.Open(sqlite.Open(env.String("DB_DSN", "devops.db")), &gorm.Config{ + NamingStrategy: schema.NamingStrategy{ + TablePrefix: env.String("DB_TABLE_PREFIX", "do_"), + }, + TranslateError: env.Bool("DB_TRANSLATE_ERROR", true), + }); err != nil { log.Fatalln(err) - } - err = db.Model(&User{}).FirstOrCreate(&User{ - ID: 1, - Username: "admin", - RawPassword: "111111", - }, User{ID: 1}).Error - if err != nil { + } else if err = entities.Init(db); err != nil { log.Fatalln(err) } - upgrader = &websocket.Upgrader{} - clients = new(sync.Map) - broadcast = make(chan []byte) - store = gormstore.New(db, []byte("secret")) - - gob.Register(&User{}) + // 启动构建程序 + if err = build.Init(db); err != nil { + log.Fatalln(err) + } + // 初始化 http s := slim.New() s.Debug = true s.Renderer = &Template{templates: template.Must(template.ParseGlob("views/*.gohtml"))} + s.ResetRouterCreator(func(s *slim.Slim) slim.Router { + return slim.NewRouter(slim.RouterConfig{ + RoutingTrailingSlash: true, + }) + }) s.Use(slim.Logging()) s.Use(slim.Recovery()) s.Use(slim.Static("public")) - s.File("/login", "login.html") - s.POST("/login", handleLogin) - s.GET("/logout", handleLogout) - s.GET("/echo", handleEcho) - s.GET("/", handleHome) - s.Route("/p/", func(sub slim.RouteCollector) { - sub.Use(AuthSession) - sub.POST(":id/start", handleStart) - sub.POST(":id/abort", handleAbort) - sub.GET("*", handleStart) - }) - - s.GET("/~", func(c slim.Context) error { - file, err := os.ReadFile("deploy.yml") - if err != nil { - return err - } - - var config BuildConfig - err = yaml.Unmarshal(file, &config) - if err != nil { - return err - } - - return c.JSONPretty(200, config, " ") - }) + // 注册路由 + if err = routes.Init(db, s); err != nil { + log.Fatalln(err) + } - //http.HandleFunc("/echo", handleWebsocket) - //http.HandleFunc("/restart", restart) - //http.ListenAndServe(":5000", nil) + // 启动网络服务 log.Fatalln(s.Start(":5000")) } diff --git a/routes/build_project.go b/routes/build_project.go new file mode 100644 index 0000000..36336ff --- /dev/null +++ b/routes/build_project.go @@ -0,0 +1,24 @@ +package routes + +import ( + "zestack.dev/slim" +) + +type BuildConfig struct { + Branch string `json:"branch" yaml:"branch"` + Steps []StepConfig `json:"steps" yaml:"steps"` +} + +type StepConfig struct { + Name string `json:"name" yaml:"name"` + Run string `json:"run" yaml:"run"` + Env map[string]string `json:"env" yaml:"env"` +} + +type VCSConfig struct { + Branch string `json:"branch" yaml:"branch"` +} + +func buildProject(c slim.Context) error { + return nil +} diff --git a/routes/delete_project.go b/routes/delete_project.go new file mode 100644 index 0000000..bc09cfb --- /dev/null +++ b/routes/delete_project.go @@ -0,0 +1,7 @@ +package routes + +import "zestack.dev/slim" + +func deleteProject(c slim.Context) error { + return nil +} diff --git a/routes/do_login.go b/routes/do_login.go new file mode 100644 index 0000000..285aaa0 --- /dev/null +++ b/routes/do_login.go @@ -0,0 +1,52 @@ +package routes + +import ( + "devops/entities" + "errors" + "net/http" + + "gorm.io/gorm" + "zestack.dev/misc" + "zestack.dev/slim" +) + +func handleLogin(c slim.Context) error { + var req struct { + Username string `json:"username" form:"username"` + Password string `json:"password" form:"password"` + } + if err := c.Bind(&req); err != nil { + return err + } + if req.Username == "" { + return c.Redirect(http.StatusMovedPermanently, "/login?error=用户名不能为空") + } + if req.Password == "" { + return c.Redirect(http.StatusMovedPermanently, "/login?error=登录密码不能为空") + } + var user entities.User + err := db. + Model(&entities.User{}). + Where("username", req.Username). + First(&user). + Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return c.Redirect(http.StatusMovedPermanently, "/login?error=用户名或密码错误") + } + return err + } + if !misc.PasswordVerify(req.Password, user.Password) { + return c.Redirect(http.StatusMovedPermanently, "/login?error=用户名或密码错误") + } + sess, err := store.Get(c.Request(), "session-key") + if err != nil { + return err + } + sess.Values["user"] = &user + err = sess.Save(c.Request(), c.Response()) + if err != nil { + return err + } + return c.Redirect(http.StatusMovedPermanently, "/") +} diff --git a/routes/echo.go b/routes/echo.go new file mode 100644 index 0000000..74da74b --- /dev/null +++ b/routes/echo.go @@ -0,0 +1,117 @@ +package routes + +import ( + "bytes" + "time" + + "github.com/gorilla/websocket" + "zestack.dev/slim" +) + +const ( + // Time allowed to write a message to the peer. + writeWait = 10 * time.Second + + // Time allowed to read the next pong message from the peer. + pongWait = 60 * time.Second + + // Send pings to peer with this period. Must be less than pongWait. + pingPeriod = (pongWait * 9) / 10 + + // Maximum message size allowed from peer. + maxMessageSize = 512 +) + +func handleNotify(c slim.Context) error { + conn, err := upgrader.Upgrade(c.Response(), c.Request(), nil) + if err != nil { + return err + } + + clients.Store(conn, struct{}{}) + defer clients.Delete(conn) + + // TODO 使用 pool 实现复用 + exit := make(chan error) + defer close(exit) + + stop := func(err error) { + select { + case <-exit: + default: + exit <- err + } + } + + go func() { + conn.SetReadLimit(maxMessageSize) + conn.SetReadDeadline(time.Now().Add(pongWait)) + conn.SetPongHandler(func(string) error { + conn.SetReadDeadline(time.Now().Add(pongWait)) + return nil + }) + for { + _, message, ex := conn.ReadMessage() + if ex != nil { + if websocket.IsUnexpectedCloseError(ex, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { + stop(ex) + } else { + stop(nil) + } + return + } + message = bytes.TrimSpace(bytes.Replace(message, newline, space, -1)) + broadcast <- message + } + }() + + go func() { + ticker := time.NewTicker(pingPeriod) + defer func() { + ticker.Stop() + conn.Close() + }() + + for { + for { + select { + case message, ok := <-broadcast: + conn.SetWriteDeadline(time.Now().Add(writeWait)) + if !ok { + // The hub closed the channel. + conn.WriteMessage(websocket.CloseMessage, []byte{}) + stop(nil) + return + } + + w, err2 := conn.NextWriter(websocket.TextMessage) + if err2 != nil { + stop(err2) + return + } + w.Write(message) + + // Add queued chat messages to the current websocket message. + n := len(broadcast) + for i := 0; i < n; i++ { + w.Write(newline) + w.Write(<-broadcast) + } + + if err3 := w.Close(); err3 != nil { + stop(err3) + return + } + case <-ticker.C: + conn.SetWriteDeadline(time.Now().Add(writeWait)) + if err4 := conn.WriteMessage(websocket.PingMessage, nil); err4 != nil { + stop(err4) + return + } + } + } + } + }() + + return <-exit +} diff --git a/routes/handle_new_project.go b/routes/handle_new_project.go new file mode 100644 index 0000000..3b34177 --- /dev/null +++ b/routes/handle_new_project.go @@ -0,0 +1,9 @@ +package routes + +import ( + "zestack.dev/slim" +) + +func handleNewProject(c slim.Context) error { + return nil +} diff --git a/routes/init.go b/routes/init.go new file mode 100644 index 0000000..af9f4a7 --- /dev/null +++ b/routes/init.go @@ -0,0 +1,51 @@ +package routes + +import ( + "sync" + "zestack.dev/env" + + "github.com/gorilla/sessions" + "github.com/gorilla/websocket" + "github.com/wader/gormstore/v2" + "gorm.io/gorm" + "zestack.dev/slim" +) + +var ( + newline = []byte{'\n'} + space = []byte{' '} + + upgrader *websocket.Upgrader + clients *sync.Map + broadcast chan []byte + + store sessions.Store + db *gorm.DB +) + +func Init(gdb *gorm.DB, s *slim.Slim) error { + db = gdb + upgrader = &websocket.Upgrader{} + clients = new(sync.Map) + broadcast = make(chan []byte) + store = gormstore.NewOptions(db, gormstore.Options{ + TableName: env.String("DB_TABLE_PREFIX", "do_") + "sessions", + }, []byte("secret")) + + s.GET("/login", showLogin) + s.POST("/login", handleLogin) + s.Route("/", func(r slim.RouteCollector) { + r.Use(authSession) + r.GET("/", showHome) + r.GET("/logout", handleLogout) + r.GET("/notify", handleNotify) + r.GET("/projects", listProjects) + r.GET("/new-projects", showNewProject) + r.POST("/new-projects", handleNewProject) + r.GET("/projects/:id", showProject) + r.DELETE("/projects/:id", deleteProject) + r.PUT("/projects/:id", updateProject) + r.POST("/build", buildProject) + }) + return nil +} diff --git a/routes/list_projects.go b/routes/list_projects.go new file mode 100644 index 0000000..7356152 --- /dev/null +++ b/routes/list_projects.go @@ -0,0 +1,7 @@ +package routes + +import "zestack.dev/slim" + +func listProjects(c slim.Context) error { + return nil +} diff --git a/routes/logout.go b/routes/logout.go new file mode 100644 index 0000000..2429d8a --- /dev/null +++ b/routes/logout.go @@ -0,0 +1,20 @@ +package routes + +import ( + "net/http" + "zestack.dev/slim" +) + +func handleLogout(c slim.Context) error { + // Get a session. We're ignoring the error resulted from decoding an + // existing session: Get() always returns a session, even if empty. + session, _ := store.Get(c.Request(), "session-name") + // Set some session values. + session.Values["foo"] = nil + // Save it before we write to the response/return from the handler. + err := session.Save(c.Request(), c.Response()) + if err != nil { + return err + } + return c.Redirect(http.StatusMovedPermanently, "/") +} diff --git a/routes/show_home.go b/routes/show_home.go new file mode 100644 index 0000000..7629b41 --- /dev/null +++ b/routes/show_home.go @@ -0,0 +1,11 @@ +package routes + +import ( + "net/http" + + "zestack.dev/slim" +) + +func showHome(c slim.Context) error { + return c.Render(http.StatusOK, "index.gohtml", "ws://"+c.Request().Host+"/notify") +} diff --git a/routes/show_login.go b/routes/show_login.go new file mode 100644 index 0000000..7d552b2 --- /dev/null +++ b/routes/show_login.go @@ -0,0 +1,10 @@ +package routes + +import ( + "net/http" + "zestack.dev/slim" +) + +func showLogin(c slim.Context) error { + return c.Render(http.StatusOK, "login.gohtml", slim.Map{}) +} diff --git a/routes/show_new_project.go b/routes/show_new_project.go new file mode 100644 index 0000000..73e0e72 --- /dev/null +++ b/routes/show_new_project.go @@ -0,0 +1,10 @@ +package routes + +import ( + "net/http" + "zestack.dev/slim" +) + +func showNewProject(c slim.Context) error { + return c.Render(http.StatusOK, "new_project.gohtml", slim.Map{}) +} diff --git a/routes/show_project.go b/routes/show_project.go new file mode 100644 index 0000000..9fa2452 --- /dev/null +++ b/routes/show_project.go @@ -0,0 +1,10 @@ +package routes + +import ( + "net/http" + "zestack.dev/slim" +) + +func showProject(c slim.Context) error { + return c.Render(http.StatusOK, "project.gohtml", slim.Map{}) +} diff --git a/routes/update_project.go b/routes/update_project.go new file mode 100644 index 0000000..c0e142c --- /dev/null +++ b/routes/update_project.go @@ -0,0 +1,7 @@ +package routes + +import "zestack.dev/slim" + +func updateProject(c slim.Context) error { + return nil +} diff --git a/routes/utils.go b/routes/utils.go new file mode 100644 index 0000000..c724ac3 --- /dev/null +++ b/routes/utils.go @@ -0,0 +1,20 @@ +package routes + +import ( + "devops/entities" + "net/http" + "zestack.dev/slim" +) + +func authSession(c slim.Context, next slim.HandlerFunc) error { + sess, err := store.Get(c.Request(), "session-key") + if err != nil { + return err + } + user, ok := sess.Values["user"].(*entities.User) + if !ok { + return c.Redirect(http.StatusMovedPermanently, "/login") + } + c.Set("user", user) + return next(c) +} diff --git a/util/dts/null_string.go b/util/dts/null_string.go new file mode 100644 index 0000000..c4f9321 --- /dev/null +++ b/util/dts/null_string.go @@ -0,0 +1,45 @@ +package dts + +import ( + "database/sql" + "database/sql/driver" + "encoding/json" +) + +type NullString sql.NullString + +// Scan implements the [Scanner] interface. +func (ns *NullString) Scan(value any) error { + ss := new(sql.NullString) + err := ss.Scan(value) + ns.String = ss.String + ns.Valid = ss.Valid + return err +} + +// Value implements the [driver.Valuer] interface. +func (ns NullString) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return ns.String, nil +} + +func (ns NullString) MarshalJSON() ([]byte, error) { + if ns.Valid { + return json.Marshal(ns.String) + } + return json.Marshal(nil) +} + +func (ns *NullString) UnmarshalJSON(b []byte) error { + if string(b) == "null" { + ns.Valid = false + return nil + } + err := json.Unmarshal(b, &ns.String) + if err == nil { + ns.Valid = true + } + return err +} diff --git a/util/dts/null_time.go b/util/dts/null_time.go new file mode 100644 index 0000000..1d180c7 --- /dev/null +++ b/util/dts/null_time.go @@ -0,0 +1,43 @@ +package dts + +import ( + "database/sql" + "database/sql/driver" + "encoding/json" +) + +type NullTime sql.NullTime + +func (nt *NullTime) Scan(value any) error { + st := new(sql.NullTime) + err := st.Scan(value) + nt.Time = st.Time + nt.Valid = st.Valid + return err +} + +func (nt NullTime) Value() (driver.Value, error) { + if !nt.Valid { + return nil, nil + } + return nt.Time, nil +} + +func (nt NullTime) MarshalJSON() ([]byte, error) { + if nt.Valid { + return nt.Time.MarshalJSON() + } + return json.Marshal(nil) +} + +func (nt *NullTime) UnmarshalJSON(b []byte) error { + if string(b) == "null" { + nt.Valid = false + return nil + } + err := json.Unmarshal(b, &nt.Time) + if err == nil { + nt.Valid = true + } + return err +} diff --git a/util/dts/null_uint.go b/util/dts/null_uint.go new file mode 100644 index 0000000..a06dbf7 --- /dev/null +++ b/util/dts/null_uint.go @@ -0,0 +1,47 @@ +package dts + +import ( + "database/sql" + "database/sql/driver" + "encoding/json" +) + +type NullUint struct { + V uint + Valid bool +} + +func (nu *NullUint) Scan(value any) error { + sn := new(sql.Null[uint]) + err := sn.Scan(value) + nu.V = sn.V + nu.Valid = sn.Valid + return err +} + +func (nu NullUint) Value() (driver.Value, error) { + if !nu.Valid { + return nil, nil + } + // 注意:driver.Value 不支持 uint + return int64(nu.V), nil +} + +func (nu NullUint) MarshalJSON() ([]byte, error) { + if nu.Valid { + return json.Marshal(nu.V) + } + return json.Marshal(nil) +} + +func (nu *NullUint) UnmarshalJSON(b []byte) error { + if string(b) == "null" { + nu.Valid = false + return nil + } + err := json.Unmarshal(b, &nu.V) + if err == nil { + nu.Valid = true + } + return err +} diff --git a/misc/chdir.go b/util/misc/chdir.go similarity index 100% rename from misc/chdir.go rename to util/misc/chdir.go diff --git a/misc/git_clone.go b/util/misc/git_clone.go similarity index 100% rename from misc/git_clone.go rename to util/misc/git_clone.go diff --git a/misc/git_pull.go b/util/misc/git_pull.go similarity index 100% rename from misc/git_pull.go rename to util/misc/git_pull.go diff --git a/misc/git_reset.go b/util/misc/git_reset.go similarity index 100% rename from misc/git_reset.go rename to util/misc/git_reset.go diff --git a/misc/go_build.go b/util/misc/go_build.go similarity index 100% rename from misc/go_build.go rename to util/misc/go_build.go diff --git a/misc/go_mod_tidy.go b/util/misc/go_mod_tidy.go similarity index 100% rename from misc/go_mod_tidy.go rename to util/misc/go_mod_tidy.go diff --git a/misc/go_test.go b/util/misc/go_test.go similarity index 100% rename from misc/go_test.go rename to util/misc/go_test.go diff --git a/views/base.gohtml b/views/base.gohtml new file mode 100644 index 0000000..eabe63c --- /dev/null +++ b/views/base.gohtml @@ -0,0 +1,24 @@ + + + + + + + + {{block "title" .}}{{end}} + {{block "head" .}}{{end}} + + + +
+
+
Overview
+
Projects
+
Settings
+
Docs
+
+
+{{block "content" .}}{{end}} + + + \ No newline at end of file diff --git a/views/index.gohtml b/views/index.gohtml index b191842..e4770f4 100644 --- a/views/index.gohtml +++ b/views/index.gohtml @@ -1,43 +1,43 @@ - - - - - +{{template "base.gohtml"}} + +{{define "title"}}Home{{end}} + +{{define "head"}} - - - -
-

Click "Open" to create a connection to the server, - "Send" to send a message to the server and "Close" to close the connection. - You can change the message and send multiple times. -

-

- - -

- -

-
-
-
- - \ No newline at end of file +{{end}} + +{{define "content"}} + + + + + +
+

Click "Open" to create a connection to the server, + "Send" to send a message to the server and "Close" to close the connection. + You can change the message and send multiple times. +

+

+ + +

+ +

+
+
+
+{{end}} \ No newline at end of file diff --git a/login.html b/views/login.gohtml similarity index 100% rename from login.html rename to views/login.gohtml