commit 16cd7aa63564ef7a6278e461fd92def8ae91cfeb Author: Ivan Reshetnikov Date: Thu Apr 6 10:36:11 2023 +0500 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a86cc73 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +*.sqlite3 +*.db diff --git a/assets/auth.css b/assets/auth.css new file mode 100644 index 0000000..010849d --- /dev/null +++ b/assets/auth.css @@ -0,0 +1,24 @@ +.page { + width: 100%; + height: 100%; + display: flex; + justify-content: center; + align-items: center; +} + +form { + width: 100%; + padding: 0 1em; +} + +input, +button { + width: 100%; + margin-top: 10px; +} + +@media screen and (min-width: 600px) { + form { + max-width: 400px; + } +} diff --git a/assets/base.css b/assets/base.css new file mode 100644 index 0000000..1607004 --- /dev/null +++ b/assets/base.css @@ -0,0 +1,48 @@ +html, +body { + height: 100%; +} + +body { + margin: 0; + background: + linear-gradient(0deg, #1d1926 19px, transparent 1px) center, + linear-gradient(90deg, #1d1926 19px, transparent 1px) center, + #7a7093; + background-size: 20px 20px; + font-family: sans-serif; + font-size: 16px; + color: white; +} + +* { + box-sizing: border-box; +} + +a { + color: inherit; +} + +input, +button { + font-size: inherit; + font-family: inherit; + padding: 8px 10px; + outline: none; + background-color: #24272d; + color: white; + border: 2px solid gray; + border-radius: 5px; + transition: border-color 0.1s ease-in-out; +} + +button { + cursor: pointer; +} + +input:focus, +input:hover, +button:active, +button:hover { + border-color: #812abd; +} diff --git a/assets/error.css b/assets/error.css new file mode 100644 index 0000000..1bbd9b0 --- /dev/null +++ b/assets/error.css @@ -0,0 +1,18 @@ +.page { + width: 100%; + height: 100%; + display: flex; + justify-content: center; + align-items: center; +} + +.content { + padding: 0 1em; + width: 100%; +} + +@media screen and (min-width: 600px) { + .content { + max-width: 400px; + } +} diff --git a/assets/index.css b/assets/index.css new file mode 100644 index 0000000..ac134ee --- /dev/null +++ b/assets/index.css @@ -0,0 +1,32 @@ +.page { + padding: 2em 1em; +} + +.row { + display: flex; + flex-wrap: wrap; + gap: 1em; + margin-top: 3em; +} + +.group { + width: 100%; +} + +h2 { + margin-top: 0; +} + +a { + display: block; + margin-top: 10px; +} + +@media screen and (min-width: 600px) { + .page { + padding: 2em 10em + } + .group { + max-width: 230px; + } +} diff --git a/assets/settings.css b/assets/settings.css new file mode 100644 index 0000000..4f069f4 --- /dev/null +++ b/assets/settings.css @@ -0,0 +1,50 @@ +body { + padding: 2em 1em; +} + +.row { + display: flex; + gap: 10px; + margin-top: 1em; + width: 100%; +} + +.innerForm { + display: flex; + gap: 10px; + min-width: 0; + flex-grow: 1; +} + +.row:last-child { + padding-bottom: 4em; +} + +h2 { + font-size: 20px; + margin-top: 3em; + margin-bottom: 10px; +} + +input { + min-width: 0; + flex-grow: 1; +} + +.link-name { + width: 130px; + flex-grow: 0; +} + +.link-href { + flex-grow: 1; +} + +@media screen and (min-width: 600px) { + body { + padding: 2em 10em; + } + .row { + max-width: 500px; + } +} diff --git a/assets/svg/floppy-disk-solid.svg b/assets/svg/floppy-disk-solid.svg new file mode 100644 index 0000000..f631504 --- /dev/null +++ b/assets/svg/floppy-disk-solid.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/svg/plus-solid.svg b/assets/svg/plus-solid.svg new file mode 100644 index 0000000..c9688bc --- /dev/null +++ b/assets/svg/plus-solid.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/svg/trash-solid.svg b/assets/svg/trash-solid.svg new file mode 100644 index 0000000..aaccb15 --- /dev/null +++ b/assets/svg/trash-solid.svg @@ -0,0 +1,4 @@ + + + + diff --git a/backend/admins.go b/backend/admins.go new file mode 100644 index 0000000..7bb9aaa --- /dev/null +++ b/backend/admins.go @@ -0,0 +1,108 @@ +package backend + +import ( + "crypto/rand" + "encoding/base64" + "errors" + "github.com/gin-gonic/gin" + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" + "time" +) + +type Admin struct { + ID uint64 `gorm:"primaryKey"` + Username string `gorm:"unique;notNull"` + Bcrypt string `gorm:"notNull"` + AccessTokens []AccessToken +} + +type AccessToken struct { + ID uint64 `gorm:"primaryKey"` + Value string `gorm:"notNull"` + AdminID uint64 `gorm:"notNull"` + ValidUntil time.Time `gorm:"NotNull"` +} + +func CountAdmins(db *gorm.DB) int64 { + var admins []Admin + var count int64 + db.Model(&admins).Count(&count) + return count +} + +func CreateAdmin(db *gorm.DB, username string, password string) (Admin, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(password), 10) + if err != nil { + return Admin{}, err + } + + admin := Admin{ + Username: username, + Bcrypt: string(hash), + } + result := db.Create(&admin) + + if result.Error != nil { + return Admin{}, result.Error + } + + return admin, nil +} + +func AuthorizeAdmin(db *gorm.DB, username string, password string) (Admin, error) { + var admin Admin + result := db.Where("username = ?", username).First(&admin) + + if result.Error != nil { + return Admin{}, result.Error + } + + err := bcrypt.CompareHashAndPassword([]byte(admin.Bcrypt), []byte(password)) + if err != nil { + return Admin{}, err + } + + return admin, nil +} + +func CreateAccessToken(db *gorm.DB, adminID uint64) (AccessToken, error) { + bytes := make([]byte, 64) + if _, err := rand.Read(bytes); err != nil { + return AccessToken{}, err + } + + accessToken := AccessToken{ + AdminID: adminID, + Value: base64.StdEncoding.EncodeToString(bytes), + // Valid for 1 month + ValidUntil: time.Now().AddDate(0, 1, 0), + } + result := db.Create(&accessToken) + + if result.Error != nil { + return AccessToken{}, result.Error + } + + return accessToken, nil +} + +// Save token for 29 days in cookies +func SetTokenCookie(c *gin.Context, token AccessToken) { + c.SetCookie("phoenix-token", token.Value, 60*60*24*29, "/", "", false, true) +} + +func ValidateToken(db *gorm.DB, value string) error { + var token AccessToken + result := db.Where("value = ?", value).First(&token) + + if result.Error != nil { + return result.Error + } + + if time.Now().After(token.ValidUntil) { + return errors.New("Access token expired") + } + + return nil +} diff --git a/backend/db.go b/backend/db.go new file mode 100644 index 0000000..8cf29ea --- /dev/null +++ b/backend/db.go @@ -0,0 +1,18 @@ +package backend + +import ( + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func GetDatabaseConnection() (*gorm.DB, error) { + db, err := gorm.Open(sqlite.Open("test.db"), &gorm.Config{}) + if err != nil { + return nil, err + } + + // Migrate the schema + db.AutoMigrate(&Admin{}, &AccessToken{}, &Group{}, &Link{}) + + return db, nil +} diff --git a/backend/groups.go b/backend/groups.go new file mode 100644 index 0000000..c30c2ef --- /dev/null +++ b/backend/groups.go @@ -0,0 +1,32 @@ +package backend + +import ( + "gorm.io/gorm" +) + +type Group struct { + ID uint64 `gorm:"primaryKey"` + Name string `gorm:"unique,notNull"` + Links []Link +} + +func GetGroups(db *gorm.DB) ([]Group, error) { + var groups []Group + result := db.Model(&Group{}).Preload("Links").Find(&groups) + if result.Error != nil { + return nil, result.Error + } + return groups, nil +} + +func CreateGroup(db *gorm.DB, groupName string) (Group, error) { + group := Group{ + Name: groupName, + } + result := db.Create(&group) + if result.Error != nil { + return Group{}, result.Error + } + + return group, nil +} diff --git a/backend/links.go b/backend/links.go new file mode 100644 index 0000000..559c59a --- /dev/null +++ b/backend/links.go @@ -0,0 +1,48 @@ +package backend + +import ( + "gorm.io/gorm" +) + +type Link struct { + ID uint64 `gorm:"primaryKey"` + Name string `gorm:"notNull"` + Href string `gorm:"notNull"` + GroupID uint64 `gorm:"notNull"` +} + +func CreateLink(db *gorm.DB, linkName string, href string, groupID uint64) (Link, error) { + link := Link{ + Name: linkName, + Href: href, + GroupID: groupID, + } + result := db.Create(&link) + if result.Error != nil { + return Link{}, result.Error + } + + return link, nil +} + +func UpdateLink(db *gorm.DB, id uint64, linkName string, href string) (Link, error) { + var link Link + db.First(&link, id) + + link.Name = linkName + link.Href = href + result := db.Save(&link) + if result.Error != nil { + return Link{}, result.Error + } + + return link, nil +} + +func DeleteLink(db *gorm.DB, id uint64) error { + result := db.Delete(&Link{}, id) + if result.Error != nil { + return result.Error + } + return nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..df6b102 --- /dev/null +++ b/go.mod @@ -0,0 +1,38 @@ +module github.com/ordinary-dev/phoenix + +go 1.20 + +require ( + github.com/gin-gonic/gin v1.9.0 + gorm.io/driver/sqlite v1.4.4 + gorm.io/gorm v1.24.6 +) + +require ( + github.com/bytedance/sonic v1.8.0 // indirect + github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect + github.com/gin-contrib/sse v0.1.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.11.2 // indirect + github.com/goccy/go-json v0.10.0 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.0.9 // indirect + github.com/leodido/go-urn v1.2.1 // indirect + github.com/mattn/go-isatty v0.0.17 // indirect + github.com/mattn/go-sqlite3 v1.14.15 // indirect + github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.0.6 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.9 // indirect + golang.org/x/arch v0.0.0-20210923205945-b76863e36670 // indirect + golang.org/x/crypto v0.5.0 // indirect + golang.org/x/net v0.7.0 // indirect + golang.org/x/sys v0.5.0 // indirect + golang.org/x/text v0.7.0 // indirect + google.golang.org/protobuf v1.28.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..0a928af --- /dev/null +++ b/go.sum @@ -0,0 +1,93 @@ +github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= +github.com/bytedance/sonic v1.8.0 h1:ea0Xadu+sHlu7x5O3gKhRpQ1IKiMrSiHttPF0ybECuA= +github.com/bytedance/sonic v1.8.0/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U= +github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= +github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams= +github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= +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/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.9.0 h1:OjyFBKICoexlu99ctXNR2gg+c5pKrKMuyjgARg9qeY8= +github.com/gin-gonic/gin v1.9.0/go.mod h1:W1Me9+hsUSyj3CePGrd1/QrKJMSJ1Tu/0hFEH89961k= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.11.2 h1:q3SHpufmypg+erIExEKUmsgmhDTyhcJ38oeKGACXohU= +github.com/go-playground/validator/v10 v10.11.2/go.mod h1:NieE624vt4SCTJtD87arVLvdmjPAeV8BQlHtMnw9D7s= +github.com/goccy/go-json v0.10.0 h1:mXKd9Qw4NuzShiRlOXKews24ufknHO7gx30lsDyokKA= +github.com/goccy/go-json v0.10.0/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w= +github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY= +github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= +github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-sqlite3 v1.14.15 h1:vfoHhTN1af61xCRSWzFIWzx2YskyMTwHLrExkBOjvxI= +github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.0.6 h1:nrzqCb7j9cDFj2coyLNLaZuJTLjWjlaz6nvTvIwycIU= +github.com/pelletier/go-toml/v2 v2.0.6/go.mod h1:eumQOmlWiOPt5WriQQqoM5y18pDHwha2N+QD+EUNTek= +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.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/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.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.9 h1:rmenucSohSTiyL09Y+l2OCk+FrMxGMzho2+tjr5ticU= +github.com/ugorji/go/codec v1.2.9/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670 h1:18EFjUmQOcUvxNYSkA6jO9VAiXCnxFY6NyDX0bHDmkU= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/crypto v0.5.0 h1:U/0M97KRkSFvyD/3FSmdP5W5swImpNgle/EHFhOsQPE= +golang.org/x/crypto v0.5.0/go.mod h1:NK/OQwhpMQP3MwtdjgLlYHnH9ebylxKWv3e0fK+mkQU= +golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w= +google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/sqlite v1.4.4 h1:gIufGoR0dQzjkyqDyYSCvsYR6fba1Gw5YKDqKeChxFc= +gorm.io/driver/sqlite v1.4.4/go.mod h1:0Aq3iPO+v9ZKbcdiz8gLWRw5VOPcBOPUQJFLq5e2ecI= +gorm.io/gorm v1.24.0/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA= +gorm.io/gorm v1.24.6 h1:wy98aq9oFEetsc4CAbKD2SoBCdMzsbSIvSUUFJuHi5s= +gorm.io/gorm v1.24.6/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/main.go b/main.go new file mode 100644 index 0000000..20f58a4 --- /dev/null +++ b/main.go @@ -0,0 +1,194 @@ +package main + +import ( + "errors" + "github.com/gin-gonic/gin" + "github.com/ordinary-dev/phoenix/backend" + "github.com/ordinary-dev/phoenix/views" + "log" + "net/http" + "strconv" +) + +func main() { + db, err := backend.GetDatabaseConnection() + if err != nil { + log.Fatal(err) + } + + r := gin.Default() + r.LoadHTMLGlob("templates/*") + r.Static("/assets", "./assets") + + // Main page + r.GET("/", func(c *gin.Context) { + views.RequireAuth(c, db) + groups, err := backend.GetGroups(db) + if err != nil { + views.ShowError(c, err) + return + } + c.HTML(http.StatusOK, "index.html.tmpl", gin.H{ + "groups": groups, + }) + }) + + // Settings + r.GET("/settings", func(c *gin.Context) { + views.RequireAuth(c, db) + groups, err := backend.GetGroups(db) + if err != nil { + views.ShowError(c, err) + return + } + + c.HTML(http.StatusOK, "settings.html.tmpl", gin.H{ + "groups": groups, + }) + }) + + // Create new user + r.POST("/users", func(c *gin.Context) { + // If at least 1 administator exists, require authorization + if backend.CountAdmins(db) > 0 { + tokenValue, err := c.Cookie("phoenix-token") + + // Anonymous visitor + if err != nil { + err = errors.New("At least 1 user exists, you have to sign in first") + views.ShowError(c, err) + return + } + + err = backend.ValidateToken(db, tokenValue) + if err != nil { + views.ShowError(c, err) + return + } + } + + // User is authorized or no user exists. + // Try to create a user. + username := c.PostForm("username") + password := c.PostForm("password") + admin, err := backend.CreateAdmin(db, username, password) + if err != nil { + views.ShowError(c, err) + return + } + + // Generate access token. + token, err := backend.CreateAccessToken(db, admin.ID) + if err != nil { + views.ShowError(c, err) + return + } + backend.SetTokenCookie(c, token) + + // Redirect to homepage. + c.Redirect(http.StatusFound, "/") + }) + + // Create new group + r.POST("/groups", func(c *gin.Context) { + views.RequireAuth(c, db) + + groupName := c.PostForm("groupName") + _, err := backend.CreateGroup(db, groupName) + if err != nil { + views.ShowError(c, err) + return + } + + // Redirect to settings. + c.Redirect(http.StatusFound, "/settings") + }) + + // Create new link + r.POST("/links", func(c *gin.Context) { + views.RequireAuth(c, db) + + linkName := c.PostForm("linkName") + href := c.PostForm("href") + groupID, err := strconv.ParseUint(c.PostForm("groupID"), 10, 32) + if err != nil { + views.ShowError(c, err) + return + } + + _, err = backend.CreateLink(db, linkName, href, groupID) + if err != nil { + views.ShowError(c, err) + return + } + + // Redirect to settings. + c.Redirect(http.StatusFound, "/settings") + }) + + // Update link + r.POST("/links/:id/put", func(c *gin.Context) { + views.RequireAuth(c, db) + + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + views.ShowError(c, err) + return + } + linkName := c.PostForm("linkName") + href := c.PostForm("href") + + _, err = backend.UpdateLink(db, id, linkName, href) + if err != nil { + views.ShowError(c, err) + return + } + + // Redirect to settings. + c.Redirect(http.StatusFound, "/settings") + }) + + // Delete link + r.POST("/links/:id/delete", func(c *gin.Context) { + views.RequireAuth(c, db) + + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + views.ShowError(c, err) + return + } + + err = backend.DeleteLink(db, id) + if err != nil { + views.ShowError(c, err) + return + } + + // Redirect to settings. + c.Redirect(http.StatusFound, "/settings") + }) + + r.POST("/signin", func(c *gin.Context) { + // Check credentials. + username := c.PostForm("username") + password := c.PostForm("password") + admin, err := backend.AuthorizeAdmin(db, username, password) + if err != nil { + views.ShowError(c, err) + return + } + + // Generate an access token. + token, err := backend.CreateAccessToken(db, admin.ID) + if err != nil { + views.ShowError(c, err) + return + } + backend.SetTokenCookie(c, token) + + // Redirect to homepage. + c.Redirect(http.StatusFound, "/") + }) + + r.Run() +} diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..e8a9272 --- /dev/null +++ b/readme.md @@ -0,0 +1,7 @@ +# Phoenix + +Self-hosted start page without the extra stuff. + +## Features +- No javascript +- Tiny footprint diff --git a/templates/auth.html.tmpl b/templates/auth.html.tmpl new file mode 100644 index 0000000..1caf816 --- /dev/null +++ b/templates/auth.html.tmpl @@ -0,0 +1,28 @@ + + + + {{template "head"}} + + + +
+
+

{{.title}}

+

{{.description}}

+ + + +
+
+ + diff --git a/templates/error.html.tmpl b/templates/error.html.tmpl new file mode 100644 index 0000000..df432cc --- /dev/null +++ b/templates/error.html.tmpl @@ -0,0 +1,18 @@ + + + + {{template "head"}} + + + +
+
+

Error

+ {{.error}} +

+ Main page +

+
+
+ + diff --git a/templates/head.html.tmpl b/templates/head.html.tmpl new file mode 100644 index 0000000..cf8476d --- /dev/null +++ b/templates/head.html.tmpl @@ -0,0 +1,6 @@ +{{define "head"}} +Phoenix + + + +{{end}} diff --git a/templates/index.html.tmpl b/templates/index.html.tmpl new file mode 100644 index 0000000..a6b9402 --- /dev/null +++ b/templates/index.html.tmpl @@ -0,0 +1,31 @@ + + + + {{template "head"}} + + + +
+

Phoenix

+ {{if not .groups}} +

+ You don't have any links. + Go to settings and create one. +

+ {{else}} + Settings + {{end}} + +
+ {{range .groups}} +
+

{{.Name}}

+ {{range .Links}} + {{.Name}} + {{end}} +
+ {{end}} +
+
+ + diff --git a/templates/settings.html.tmpl b/templates/settings.html.tmpl new file mode 100644 index 0000000..b5ca279 --- /dev/null +++ b/templates/settings.html.tmpl @@ -0,0 +1,93 @@ + + + + {{template "head"}} + + + +

Settings

+ Main page + + {{range .groups}} +

Group "{{.Name}}"

+
+ + +
+ + {{range .Links}} +
+
+ + + + +
+
+ +
+
+ {{end}} + +
+ + + + +
+ {{end}} + +

New group

+
+ + +
+ + diff --git a/views/auth.go b/views/auth.go new file mode 100644 index 0000000..8aa441d --- /dev/null +++ b/views/auth.go @@ -0,0 +1,52 @@ +package views + +import ( + "github.com/gin-gonic/gin" + "github.com/ordinary-dev/phoenix/backend" + "gorm.io/gorm" + "net/http" +) + +func ShowRegistrationForm(c *gin.Context) { + c.HTML(http.StatusOK, "auth.html.tmpl", gin.H{ + "title": "Create an account", + "description": "To prevent other people from seeing your links, create an account.", + "button": "Create", + "formAction": "/users", + }) +} + +func ShowLoginForm(c *gin.Context) { + c.HTML(http.StatusOK, "auth.html.tmpl", gin.H{ + "title": "Sign in", + "description": "Authorization is required to view this page.", + "button": "Sign in", + "formAction": "/signin", + }) +} + +// Requires the user to log in before viewing the page. +// If successful, does nothing. +// In case of an error, it shows the login page or the error page. +func RequireAuth(c *gin.Context, db *gorm.DB) { + number_of_accounts := backend.CountAdmins(db) + + // First run + if number_of_accounts == 0 { + ShowRegistrationForm(c) + } + + tokenValue, err := c.Cookie("phoenix-token") + + // Anonymous visitor + if err != nil { + ShowLoginForm(c) + return + } + + err = backend.ValidateToken(db, tokenValue) + if err != nil { + ShowError(c, err) + return + } +} diff --git a/views/errors.go b/views/errors.go new file mode 100644 index 0000000..b911ade --- /dev/null +++ b/views/errors.go @@ -0,0 +1,16 @@ +package views + +import ( + "github.com/gin-gonic/gin" + "net/http" +) + +func ShowError(c *gin.Context, err error) { + c.HTML( + http.StatusBadRequest, + "error.html.tmpl", + gin.H{ + "error": err.Error(), + }, + ) +}