feat!: migrate to net/http

With the release of Go 1.22, the standard library now has
all the necessary functions that allow us to abandon Gin.

I hope this rewrite will lower the entry barrier for new developers.
As a nice bonus, the size of the program has decreased from 20 to 15.4 MB.

To solve issue #81, request logging has been improved.
Now all errors are displayed in the logs.
This commit is contained in:
Ivan R. 2024-03-25 15:52:18 +05:00
parent f0f8d4ff8d
commit 6d25c4e8af
No known key found for this signature in database
GPG key ID: 56C7BAAE859B302C
33 changed files with 661 additions and 642 deletions

View file

@ -6,18 +6,29 @@ import (
"github.com/sirupsen/logrus"
)
var Cfg Config
type Config struct {
// A long and random secret string used for authorization.
SecretKey string `required:"true"`
// Path to the sqlite database.
DBPath string `required:"true"`
LogLevel string `default:"warning"`
EnableGinLogger bool `default:"false"`
Production bool `default:"true"`
// Allows you to skip authorization if the "Remote-User" header is specified.
// Don't use it if you don't know why you need it.
HeaderAuth bool `default:"false"`
// Data for the first user.
// Optional, the site also allows you to create the first user.
DefaultUsername string
DefaultPassword string
// Controls the "secure" option for a token cookie.
SecureCookie bool `default:"true"`
// Site title.
Title string `default:"Phoenix"`
// Any supported css value, embedded directly into every page.
FontFamily string `default:"sans-serif"`
@ -29,13 +40,12 @@ func GetConfig() (*Config, error) {
logrus.Infof("Config: %v", err)
}
var cfg Config
err = envconfig.Process("p", &cfg)
err = envconfig.Process("p", &Cfg)
if err != nil {
return nil, err
}
return &cfg, nil
return &Cfg, nil
}
func (cfg *Config) GetLogLevel() logrus.Level {
@ -44,7 +54,7 @@ func (cfg *Config) GetLogLevel() logrus.Level {
return logrus.DebugLevel
case "info":
return logrus.InfoLevel
case "warning":
case "warning", "warn":
return logrus.WarnLevel
case "error":
return logrus.ErrorLevel

View file

@ -2,7 +2,6 @@ package database
import (
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
type Admin struct {
@ -11,14 +10,14 @@ type Admin struct {
Bcrypt string `gorm:"notNull"`
}
func CountAdmins(db *gorm.DB) int64 {
func CountAdmins() int64 {
var admins []Admin
var count int64
db.Model(&admins).Count(&count)
DB.Model(&admins).Count(&count)
return count
}
func CreateAdmin(db *gorm.DB, username string, password string) (Admin, error) {
func CreateAdmin(username string, password string) (Admin, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), 10)
if err != nil {
return Admin{}, err
@ -28,7 +27,7 @@ func CreateAdmin(db *gorm.DB, username string, password string) (Admin, error) {
Username: username,
Bcrypt: string(hash),
}
result := db.Create(&admin)
result := DB.Create(&admin)
if result.Error != nil {
return Admin{}, result.Error
@ -37,9 +36,9 @@ func CreateAdmin(db *gorm.DB, username string, password string) (Admin, error) {
return admin, nil
}
func AuthorizeAdmin(db *gorm.DB, username string, password string) (Admin, error) {
func AuthorizeAdmin(username string, password string) (Admin, error) {
var admin Admin
result := db.Where("username = ?", username).First(&admin)
result := DB.Where("username = ?", username).First(&admin)
if result.Error != nil {
return Admin{}, result.Error

View file

@ -6,14 +6,17 @@ import (
"gorm.io/gorm"
)
func GetDatabaseConnection(cfg *config.Config) (*gorm.DB, error) {
db, err := gorm.Open(sqlite.Open(cfg.DBPath), &gorm.Config{})
var DB *gorm.DB
func EstablishDatabaseConnection(cfg *config.Config) error {
var err error
DB, err = gorm.Open(sqlite.Open(cfg.DBPath), &gorm.Config{})
if err != nil {
return nil, err
return err
}
// Migrate the schema
db.AutoMigrate(&Admin{}, &Group{}, &Link{})
DB.AutoMigrate(&Admin{}, &Group{}, &Link{})
return db, nil
return nil
}

26
go.mod
View file

@ -1,9 +1,8 @@
module github.com/ordinary-dev/phoenix
go 1.20
go 1.22
require (
github.com/gin-gonic/gin v1.9.1
github.com/golang-jwt/jwt/v5 v5.2.0
github.com/joho/godotenv v1.5.1
github.com/kelseyhightower/envconfig v1.4.0
@ -14,30 +13,9 @@ require (
)
require (
github.com/bytedance/sonic v1.9.1 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // 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.14.0 // indirect
github.com/goccy/go-json v0.10.2 // 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.2.4 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/mattn/go-sqlite3 v1.14.17 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
golang.org/x/arch v0.3.0 // indirect
golang.org/x/net v0.17.0 // indirect
github.com/stretchr/testify v1.8.3 // indirect
golang.org/x/sys v0.15.0 // indirect
golang.org/x/text v0.14.0 // indirect
google.golang.org/protobuf v1.30.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

68
go.sum
View file

@ -1,98 +1,31 @@
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
github.com/bytedance/sonic v1.9.1/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/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
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.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
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.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js=
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw=
github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
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.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/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8=
github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/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.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
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/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
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.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/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
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.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
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.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
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=
@ -101,4 +34,3 @@ gorm.io/driver/sqlite v1.5.4 h1:IqXwXi8M/ZlPzH/947tn5uik3aYQslP9BVveoax0nV0=
gorm.io/driver/sqlite v1.5.4/go.mod h1:qxAuCol+2r6PannQDpOP1FP6ag3mKi4esLnB/jHed+4=
gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls=
gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

33
jwttoken/token.go Normal file
View file

@ -0,0 +1,33 @@
package jwttoken
import (
"net/http"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/ordinary-dev/phoenix/config"
)
const (
TOKEN_LIFETIME_IN_SECONDS = 60 * 60 * 24 * 30
TOKEN_COOKIE_NAME = "phoenix-token"
)
func GetJWTToken() (string, error) {
claims := jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Second * TOKEN_LIFETIME_IN_SECONDS)),
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(config.Cfg.SecretKey))
}
func TokenToCookie(value string) *http.Cookie {
return &http.Cookie{
Name: TOKEN_COOKIE_NAME,
Value: value,
HttpOnly: true,
Secure: config.Cfg.SecureCookie,
MaxAge: TOKEN_LIFETIME_IN_SECONDS,
}
}

14
main.go
View file

@ -25,21 +25,23 @@ func main() {
logrus.Infof("Setting log level to %v", logLevel)
// Connect to the database
db, err := database.GetDatabaseConnection(cfg)
err = database.EstablishDatabaseConnection(cfg)
if err != nil {
logrus.Fatalf("%v", err)
}
// Create the first user
if cfg.DefaultUsername != "" && cfg.DefaultPassword != "" {
if database.CountAdmins(db) < 1 {
_, err := database.CreateAdmin(db, cfg.DefaultUsername, cfg.DefaultPassword)
if cfg.DefaultUsername != "" && cfg.DefaultPassword != "" && database.CountAdmins() < 1 {
_, err := database.CreateAdmin(cfg.DefaultUsername, cfg.DefaultPassword)
if err != nil {
logrus.Errorf("%v", err)
}
}
server, err := views.GetHttpServer()
if err != nil {
logrus.Fatal(err)
}
engine := views.GetGinEngine(cfg, db)
engine.Run(":8080")
server.ListenAndServe()
}

View file

@ -1,14 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
{{template "head" .}}
{{ template "head" . }}
<link rel="stylesheet" href="assets/css/auth.css" />
</head>
<body>
<div class="page">
<form action="{{.formAction}}" method="POST">
<h1>{{.title}}</h1>
<p>{{.description}}</p>
<form action="{{ .formAction }}" method="POST">
<h1>{{ .title }}</h1>
<p>{{ .description }}</p>
<input
placeholder="Username"
name="username"

View file

@ -1,14 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
{{template "head" .}}
{{ template "head" . }}
<link rel="stylesheet" href="assets/css/error.css" />
</head>
<body>
<div class="page">
<div class="content">
<h1>Error</h1>
<code>{{.error}}</code>
<code>{{ .error }}</code>
<p>
<a href="/">Main page</a>
</p>

View file

@ -1,5 +1,5 @@
{{define "head"}}
<title>{{.WebsiteTitle}}</title>
<title>{{ .title }}</title>
<meta charset="utf-8" />
<meta name="description" content="A minimalistic start page with your collection of links to important sites." />
<meta name="viewport" content="width=device-width, initial-scale=1" />
@ -10,7 +10,7 @@
<link rel="apple-touch-icon" href="/assets/favicons/favicon-180.png" />
<style>
body {
font-family: {{.FontFamily}};
font-family: {{ .fontFamily }};
}
</style>
{{end}}

View file

@ -6,7 +6,7 @@
</head>
<body>
<div class="page">
<h1>{{.WebsiteTitle}}</h1>
<h1>{{ .title }}</h1>
{{if not .groups}}
<p>
You don't have any links.

View file

@ -11,7 +11,7 @@
{{range .groups}}
<h2 id="group-{{.ID}}">Group "{{.Name}}"</h2>
<div class="row">
<form method="POST" action="/api/groups/{{.ID}}/put" class="innerForm">
<form method="POST" action="/groups/{{.ID}}/update" class="innerForm">
<input
value="{{.Name}}"
placeholder="Name"
@ -25,7 +25,7 @@
<img src="/assets/svg/floppy-disk-solid.svg" width="16px" height="16px" />
</button>
</form>
<form method="POST" action="/api/groups/{{.ID}}/delete">
<form method="POST" action="/groups/{{.ID}}/delete">
<button
type="submit"
aria-label="Delete the group"
@ -37,8 +37,7 @@
{{range .Links}}
<div class="row" id="link-{{.ID}}">
<form method="POST" action="/api/links/{{.ID}}/put" class="innerForm">
<!-- method: put -->
<form method="POST" action="/links/{{.ID}}/update" class="innerForm">
<input
class="small-row"
value="{{ if .Icon }}{{ .Icon }}{{ end }}"
@ -65,7 +64,7 @@
<img src="/assets/svg/floppy-disk-solid.svg" width="16px" height="16px" />
</button>
</form>
<form method="POST" action="/api/links/{{.ID}}/delete">
<form method="POST" action="/links/{{.ID}}/delete">
<button
type="submit"
aria-label="Delete the link"
@ -76,7 +75,7 @@
</div>
{{end}}
<form action="/api/links" method="POST" class="row">
<form action="/links" method="POST" class="row">
<input
class="small-row"
name="icon"
@ -110,7 +109,7 @@
{{end}}
<h2>New group</h2>
<form method="POST" action="/api/groups" class="row">
<form method="POST" action="/groups" class="row">
<input
placeholder="Name"
name="groupName"

View file

@ -1,176 +0,0 @@
package views
import (
"errors"
"fmt"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"github.com/ordinary-dev/phoenix/config"
"github.com/ordinary-dev/phoenix/database"
"gorm.io/gorm"
)
const TOKEN_LIFETIME_IN_SECONDS = 60 * 60 * 24 * 30
func ShowRegistrationForm(cfg *config.Config, db *gorm.DB) gin.HandlerFunc {
return func(ctx *gin.Context) {
if database.CountAdmins(db) > 0 {
ShowError(ctx, cfg, errors.New("At least 1 user already exists"))
return
}
Render(ctx, cfg, 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": "/api/users",
})
}
}
func ShowLoginForm(cfg *config.Config) gin.HandlerFunc {
return func(ctx *gin.Context) {
Render(ctx, cfg, http.StatusOK, "auth.html.tmpl", gin.H{
"title": "Sign in",
"description": "Authorization is required to view this page.",
"button": "Sign in",
"formAction": "/api/users/signin",
})
}
}
// Requires the user to log in before viewing the page.
// Returns error if the user is not authorized.
// If `nil` is returned instead of an error, it is safe to display protected content.
func RequireAuth(c *gin.Context, cfg *config.Config) (*jwt.RegisteredClaims, error) {
tokenValue, err := c.Cookie("phoenix-token")
// Anonymous visitor
if err != nil {
return nil, err
}
// Check token
token, err := jwt.ParseWithClaims(tokenValue, &jwt.RegisteredClaims{}, func(token *jwt.Token) (interface{}, error) {
// Validate the alg
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("Unexpected signing method: %v", token.Header["alg"])
}
return []byte(cfg.SecretKey), nil
})
if err != nil {
return nil, err
}
claims, ok := token.Claims.(*jwt.RegisteredClaims)
if !ok || !token.Valid {
return nil, errors.New("Token is invalid")
}
return claims, nil
}
func AuthMiddleware(db *gorm.DB, cfg *config.Config) gin.HandlerFunc {
return func(ctx *gin.Context) {
claims, err := RequireAuth(ctx, cfg)
if err != nil {
if cfg.HeaderAuth && ctx.Request.Header.Get("Remote-User") != "" {
// Generate access token.
token, err := GetJWTToken(cfg)
if err != nil {
ShowError(ctx, cfg, err)
return
}
SetTokenCookie(ctx, token, cfg)
return
}
if database.CountAdmins(db) < 1 {
ctx.Redirect(http.StatusFound, "/registration")
} else {
ctx.Redirect(http.StatusFound, "/signin")
}
ctx.Abort()
return
}
// Create a new token if the old one is about to expire
if time.Now().Add(time.Second * (TOKEN_LIFETIME_IN_SECONDS / 2)).After(claims.ExpiresAt.Time) {
newToken, err := GetJWTToken(cfg)
if err != nil {
ShowError(ctx, cfg, err)
return
}
SetTokenCookie(ctx, newToken, cfg)
}
}
}
func GetJWTToken(cfg *config.Config) (string, error) {
claims := jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Second * TOKEN_LIFETIME_IN_SECONDS)),
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(cfg.SecretKey))
}
func CreateUser(db *gorm.DB, cfg *config.Config) gin.HandlerFunc {
return func(ctx *gin.Context) {
if database.CountAdmins(db) > 0 {
ShowError(ctx, cfg, errors.New("At least 1 user already exists"))
return
}
// Try to create a user.
username := ctx.PostForm("username")
password := ctx.PostForm("password")
_, err := database.CreateAdmin(db, username, password)
if err != nil {
ShowError(ctx, cfg, err)
return
}
// Generate access token.
token, err := GetJWTToken(cfg)
if err != nil {
ShowError(ctx, cfg, err)
return
}
SetTokenCookie(ctx, token, cfg)
// Redirect to homepage.
ctx.Redirect(http.StatusFound, "/")
}
}
func AuthorizeUser(db *gorm.DB, cfg *config.Config) gin.HandlerFunc {
return func(ctx *gin.Context) {
// Check credentials.
username := ctx.PostForm("username")
password := ctx.PostForm("password")
_, err := database.AuthorizeAdmin(db, username, password)
if err != nil {
ShowError(ctx, cfg, err)
return
}
// Generate an access token.
token, err := GetJWTToken(cfg)
if err != nil {
ShowError(ctx, cfg, err)
return
}
SetTokenCookie(ctx, token, cfg)
// Redirect to homepage.
ctx.Redirect(http.StatusFound, "/")
}
}
// Save token in cookies
func SetTokenCookie(c *gin.Context, token string, cfg *config.Config) {
c.SetCookie("phoenix-token", token, TOKEN_LIFETIME_IN_SECONDS, "/", "", cfg.SecureCookie, true)
}

View file

@ -1,15 +0,0 @@
package views
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/ordinary-dev/phoenix/config"
)
func ShowError(ctx *gin.Context, cfg *config.Config, err error) {
Render(ctx, cfg, http.StatusBadRequest, "error.html.tmpl", gin.H{
"error": err.Error(),
})
ctx.Abort()
}

View file

@ -1,71 +0,0 @@
package views
import (
"fmt"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/ordinary-dev/phoenix/config"
"github.com/ordinary-dev/phoenix/database"
"gorm.io/gorm"
)
func CreateGroup(cfg *config.Config, db *gorm.DB) gin.HandlerFunc {
return func(ctx *gin.Context) {
// Save new group to the database.
group := database.Group{
Name: ctx.PostForm("groupName"),
}
if result := db.Create(&group); result.Error != nil {
ShowError(ctx, cfg, result.Error)
return
}
// This page is called from the settings, return the user back.
ctx.Redirect(http.StatusFound, fmt.Sprintf("/settings#group-%v", group.ID))
}
}
func UpdateGroup(cfg *config.Config, db *gorm.DB) gin.HandlerFunc {
return func(ctx *gin.Context) {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
if err != nil {
ShowError(ctx, cfg, err)
return
}
var group database.Group
if result := db.First(&group, id); result.Error != nil {
ShowError(ctx, cfg, result.Error)
return
}
group.Name = ctx.PostForm("groupName")
if result := db.Save(&group); result.Error != nil {
ShowError(ctx, cfg, result.Error)
return
}
// This page is called from the settings, return the user back.
ctx.Redirect(http.StatusFound, fmt.Sprintf("/settings#group-%v", group.ID))
}
}
func DeleteGroup(cfg *config.Config, db *gorm.DB) gin.HandlerFunc {
return func(ctx *gin.Context) {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
if err != nil {
ShowError(ctx, cfg, err)
return
}
if result := db.Delete(&database.Group{}, id); result.Error != nil {
ShowError(ctx, cfg, result.Error)
return
}
// Redirect to settings.
ctx.Redirect(http.StatusFound, "/settings")
}
}

View file

@ -1,30 +0,0 @@
package views
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/ordinary-dev/phoenix/config"
"github.com/ordinary-dev/phoenix/database"
"gorm.io/gorm"
)
func ShowMainPage(cfg *config.Config, db *gorm.DB) gin.HandlerFunc {
return func(ctx *gin.Context) {
// Get a list of groups with links
var groups []database.Group
result := db.
Model(&database.Group{}).
Preload("Links").
Find(&groups)
if result.Error != nil {
ShowError(ctx, cfg, result.Error)
return
}
Render(ctx, cfg, http.StatusOK, "index.html.tmpl", gin.H{
"groups": groups,
})
}
}

View file

@ -1,91 +0,0 @@
package views
import (
"fmt"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/ordinary-dev/phoenix/config"
"github.com/ordinary-dev/phoenix/database"
"gorm.io/gorm"
)
func CreateLink(cfg *config.Config, db *gorm.DB) gin.HandlerFunc {
return func(ctx *gin.Context) {
groupID, err := strconv.ParseUint(ctx.PostForm("groupID"), 10, 32)
if err != nil {
ShowError(ctx, cfg, err)
return
}
link := database.Link{
Name: ctx.PostForm("linkName"),
Href: ctx.PostForm("href"),
GroupID: groupID,
}
icon := ctx.PostForm("icon")
if icon == "" {
link.Icon = nil
} else {
link.Icon = &icon
}
if result := db.Create(&link); result.Error != nil {
ShowError(ctx, cfg, result.Error)
return
}
// Redirect to settings.
ctx.Redirect(http.StatusFound, fmt.Sprintf("/settings#link-%v", link.ID))
}
}
func UpdateLink(cfg *config.Config, db *gorm.DB) gin.HandlerFunc {
return func(ctx *gin.Context) {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
if err != nil {
ShowError(ctx, cfg, err)
return
}
var link database.Link
if result := db.First(&link, id); result.Error != nil {
ShowError(ctx, cfg, err)
return
}
link.Name = ctx.PostForm("linkName")
link.Href = ctx.PostForm("href")
icon := ctx.PostForm("icon")
if icon == "" {
link.Icon = nil
} else {
link.Icon = &icon
}
if result := db.Save(&link); result.Error != nil {
ShowError(ctx, cfg, result.Error)
return
}
// Redirect to settings.
ctx.Redirect(http.StatusFound, fmt.Sprintf("/settings#link-%v", link.ID))
}
}
func DeleteLink(cfg *config.Config, db *gorm.DB) gin.HandlerFunc {
return func(ctx *gin.Context) {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
if err != nil {
ShowError(ctx, cfg, err)
return
}
if result := db.Delete(&database.Link{}, id); result.Error != nil {
ShowError(ctx, cfg, result.Error)
return
}
// Redirect to settings.
ctx.Redirect(http.StatusFound, "/settings")
}
}

View file

@ -1,63 +0,0 @@
package views
import (
"github.com/gin-gonic/gin"
"github.com/ordinary-dev/phoenix/config"
"gorm.io/gorm"
)
func GetGinEngine(cfg *config.Config, db *gorm.DB) *gin.Engine {
if cfg.Production {
gin.SetMode(gin.ReleaseMode)
}
engine := gin.New()
engine.Use(gin.Recovery())
if cfg.EnableGinLogger {
engine.Use(gin.Logger())
}
engine.LoadHTMLGlob("templates/*")
engine.Static("/assets", "./assets")
engine.Use(SecurityHeadersMiddleware)
engine.GET("/signin", ShowLoginForm(cfg))
engine.POST("/api/users/signin", AuthorizeUser(db, cfg))
engine.GET("/registration", ShowRegistrationForm(cfg, db))
engine.POST("/api/users", CreateUser(db, cfg))
// This group requires authorization before viewing.
protected := engine.Group("/")
protected.Use(AuthMiddleware(db, cfg))
// Main page
protected.GET("/", ShowMainPage(cfg, db))
protected.GET("/settings", ShowSettings(cfg, db))
// Create new group
protected.POST("/api/groups", CreateGroup(cfg, db))
// Update group
// HTML forms cannot be submitted using PUT or PATCH methods without javascript.
protected.POST("/api/groups/:id/put", UpdateGroup(cfg, db))
// Delete group
// HTML forms cannot be submitted using the DELETE method without javascript.
protected.POST("/api/groups/:id/delete", DeleteGroup(cfg, db))
// Create new link
protected.POST("/api/links", CreateLink(cfg, db))
// Update link.
// HTML forms cannot be submitted using PUT or PATCH methods without javascript.
protected.POST("/api/links/:id/put", UpdateLink(cfg, db))
// Delete link
// HTML forms cannot be submitted using the DELETE method without javascript.
protected.POST("/api/links/:id/delete", DeleteLink(cfg, db))
return engine
}

82
views/middleware/auth.go Normal file
View file

@ -0,0 +1,82 @@
package middleware
import (
"fmt"
"net/http"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/ordinary-dev/phoenix/config"
"github.com/ordinary-dev/phoenix/database"
"github.com/ordinary-dev/phoenix/jwttoken"
"github.com/ordinary-dev/phoenix/views/pages"
)
// Try to find the access token in the request.
// Returns error if the user is not authorized.
// If `nil` is returned instead of an error, it is safe to display protected content.
func ParseToken(r *http.Request) (*jwt.RegisteredClaims, error) {
tokenCookie, err := r.Cookie(jwttoken.TOKEN_COOKIE_NAME)
// Anonymous visitor.
if err != nil {
return nil, err
}
// Check token.
token, err := jwt.ParseWithClaims(tokenCookie.Value, &jwt.RegisteredClaims{}, func(token *jwt.Token) (interface{}, error) {
// Validate the alg.
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return []byte(config.Cfg.SecretKey), nil
})
if err != nil {
return nil, err
}
claims, ok := token.Claims.(*jwt.RegisteredClaims)
if !ok || !token.Valid {
return nil, fmt.Errorf("token is invalid")
}
return claims, nil
}
func RequireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Check if SSO is enabled.
if config.Cfg.HeaderAuth && r.Header.Get("Remote-User") != "" {
next.ServeHTTP(w, r)
return
}
claims, err := ParseToken(r)
// Most likely the user is not authorized.
if err != nil {
if database.CountAdmins() < 1 {
http.Redirect(w, r, "/registration", http.StatusFound)
} else {
http.Redirect(w, r, "/signin", http.StatusFound)
}
return
}
// Create a new token if the old one is about to expire
if time.Now().Add(time.Second * (jwttoken.TOKEN_LIFETIME_IN_SECONDS / 2)).After(claims.ExpiresAt.Time) {
newToken, err := jwttoken.GetJWTToken()
if err != nil {
pages.ShowError(w, http.StatusInternalServerError, err)
return
}
http.SetCookie(w, jwttoken.TokenToCookie(newToken))
}
next.ServeHTTP(w, r)
})
}

View file

@ -0,0 +1,22 @@
package middleware
import (
"net/http"
"time"
log "github.com/sirupsen/logrus"
)
func LoggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
log.WithFields(log.Fields{
"latency": time.Since(start),
"method": r.Method,
"path": r.URL.Path,
}).Info("Request")
})
}

View file

@ -0,0 +1,18 @@
package middleware
import (
"net/http"
)
// Adds several headers to the response to improve security.
// For example, headers prevent embedding a site and passing information about the referrer.
func SecurityHeadersMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Frame-Options", "SAMEORIGIN")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Referrer-Policy", "same-origin")
w.Header().Set("Content-Security-Policy", "script-src 'self'; ")
next.ServeHTTP(w, r)
})
}

18
views/pages/errors.go Normal file
View file

@ -0,0 +1,18 @@
package pages
import (
"net/http"
log "github.com/sirupsen/logrus"
)
func ShowError(w http.ResponseWriter, statusCode int, err error) {
log.WithField("code", statusCode).Error(err)
w.WriteHeader(statusCode)
Render("error.html.tmpl", w, map[string]any{
"title": "Error",
"description": "The request failed.",
"error": err.Error(),
})
}

63
views/pages/groups.go Normal file
View file

@ -0,0 +1,63 @@
package pages
import (
"fmt"
"net/http"
"strconv"
"github.com/ordinary-dev/phoenix/database"
)
func CreateGroup(w http.ResponseWriter, r *http.Request) {
// Save new group to the database.
group := database.Group{
Name: r.FormValue("groupName"),
}
if result := database.DB.Create(&group); result.Error != nil {
ShowError(w, http.StatusInternalServerError, result.Error)
return
}
// This page is called from the settings, return the user back.
http.Redirect(w, r, fmt.Sprintf("/settings#group-%v", group.ID), http.StatusFound)
}
func UpdateGroup(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseUint(r.PathValue("id"), 10, 64)
if err != nil {
ShowError(w, http.StatusBadRequest, err)
return
}
var group database.Group
if result := database.DB.First(&group, id); result.Error != nil {
ShowError(w, http.StatusInternalServerError, result.Error)
return
}
group.Name = r.FormValue("groupName")
if result := database.DB.Save(&group); result.Error != nil {
ShowError(w, http.StatusInternalServerError, result.Error)
return
}
// This page is called from the settings, return the user back.
http.Redirect(w, r, fmt.Sprintf("/settings#group-%v", group.ID), http.StatusFound)
}
func DeleteGroup(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseUint(r.PathValue("id"), 10, 64)
if err != nil {
ShowError(w, http.StatusBadRequest, err)
return
}
if result := database.DB.Delete(&database.Group{}, id); result.Error != nil {
ShowError(w, http.StatusInternalServerError, result.Error)
return
}
// Redirect to settings.
http.Redirect(w, r, "/settings", http.StatusFound)
}

30
views/pages/index.go Normal file
View file

@ -0,0 +1,30 @@
package pages
import (
"net/http"
"github.com/ordinary-dev/phoenix/database"
log "github.com/sirupsen/logrus"
)
func ShowMainPage(w http.ResponseWriter, _ *http.Request) {
// Get a list of groups with links
var groups []database.Group
result := database.DB.
Model(&database.Group{}).
Preload("Links").
Find(&groups)
if result.Error != nil {
ShowError(w, http.StatusInternalServerError, result.Error)
return
}
err := Render("index.html.tmpl", w, map[string]any{
"description": "Self-hosted start page.",
"groups": groups,
})
if err != nil {
log.Error(err)
}
}

82
views/pages/links.go Normal file
View file

@ -0,0 +1,82 @@
package pages
import (
"fmt"
"net/http"
"strconv"
"github.com/ordinary-dev/phoenix/database"
)
func CreateLink(w http.ResponseWriter, r *http.Request) {
groupID, err := strconv.ParseUint(r.FormValue("groupID"), 10, 32)
if err != nil {
ShowError(w, http.StatusBadRequest, err)
return
}
link := database.Link{
Name: r.FormValue("linkName"),
Href: r.FormValue("href"),
GroupID: groupID,
}
icon := r.FormValue("icon")
if icon == "" {
link.Icon = nil
} else {
link.Icon = &icon
}
if result := database.DB.Create(&link); result.Error != nil {
ShowError(w, http.StatusInternalServerError, result.Error)
return
}
// Redirect to settings.
http.Redirect(w, r, fmt.Sprintf("/settings#link-%v", link.ID), http.StatusFound)
}
func UpdateLink(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseUint(r.PathValue("id"), 10, 64)
if err != nil {
ShowError(w, http.StatusBadRequest, err)
return
}
var link database.Link
if result := database.DB.First(&link, id); result.Error != nil {
ShowError(w, http.StatusInternalServerError, result.Error)
return
}
link.Name = r.FormValue("linkName")
link.Href = r.FormValue("href")
icon := r.FormValue("icon")
if icon == "" {
link.Icon = nil
} else {
link.Icon = &icon
}
if result := database.DB.Save(&link); result.Error != nil {
ShowError(w, http.StatusInternalServerError, result.Error)
return
}
// Redirect to settings.
http.Redirect(w, r, fmt.Sprintf("/settings#link-%v", link.ID), http.StatusFound)
}
func DeleteLink(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseUint(r.PathValue("id"), 10, 64)
if err != nil {
ShowError(w, http.StatusBadRequest, err)
return
}
if result := database.DB.Delete(&database.Link{}, id); result.Error != nil {
ShowError(w, http.StatusInternalServerError, result.Error)
return
}
// Redirect to settings.
http.Redirect(w, r, "/settings", http.StatusFound)
}

View file

@ -0,0 +1,50 @@
package pages
import (
"errors"
"net/http"
"github.com/ordinary-dev/phoenix/database"
"github.com/ordinary-dev/phoenix/jwttoken"
)
func ShowRegistrationForm(w http.ResponseWriter, _ *http.Request) {
if database.CountAdmins() > 0 {
ShowError(w, http.StatusBadRequest, errors.New("at least 1 user already exists"))
return
}
Render("auth.html.tmpl", w, map[string]any{
"title": "Create an account",
"description": "To prevent other people from seeing your links, create an account.",
"button": "Create",
"formAction": "/registration",
})
}
func CreateUser(w http.ResponseWriter, r *http.Request) {
if database.CountAdmins() > 0 {
ShowError(w, http.StatusBadRequest, errors.New("at least 1 user already exists"))
return
}
// Try to create a user.
username := r.FormValue("username")
password := r.FormValue("password")
_, err := database.CreateAdmin(username, password)
if err != nil {
ShowError(w, http.StatusInternalServerError, err)
return
}
// Generate access token.
token, err := jwttoken.GetJWTToken()
if err != nil {
ShowError(w, http.StatusInternalServerError, err)
return
}
http.SetCookie(w, jwttoken.TokenToCookie(token))
// Redirect to homepage.
http.Redirect(w, r, "/", http.StatusFound)
}

26
views/pages/settings.go Normal file
View file

@ -0,0 +1,26 @@
package pages
import (
"net/http"
"github.com/ordinary-dev/phoenix/database"
)
func ShowSettings(w http.ResponseWriter, _ *http.Request) {
// Get a list of groups with links
var groups []database.Group
result := database.DB.
Model(&database.Group{}).
Preload("Links").
Find(&groups)
if result.Error != nil {
ShowError(w, http.StatusInternalServerError, result.Error)
return
}
Render("settings.html.tmpl", w, map[string]any{
"title": "Settings",
"groups": groups,
})
}

43
views/pages/signin.go Normal file
View file

@ -0,0 +1,43 @@
package pages
import (
"net/http"
log "github.com/sirupsen/logrus"
"github.com/ordinary-dev/phoenix/database"
"github.com/ordinary-dev/phoenix/jwttoken"
)
func ShowSignInForm(w http.ResponseWriter, _ *http.Request) {
err := Render("auth.html.tmpl", w, map[string]any{
"title": "Sign in",
"description": "Authorization is required to view this page.",
"button": "Sign in",
"formAction": "/signin",
})
if err != nil {
log.Error(err)
}
}
func AuthorizeUser(w http.ResponseWriter, r *http.Request) {
// Check credentials.
username := r.FormValue("username")
password := r.FormValue("password")
_, err := database.AuthorizeAdmin(username, password)
if err != nil {
ShowError(w, http.StatusUnauthorized, err)
return
}
// Generate an access token.
token, err := jwttoken.GetJWTToken()
if err != nil {
ShowError(w, http.StatusInternalServerError, err)
return
}
http.SetCookie(w, jwttoken.TokenToCookie(token))
http.Redirect(w, r, "/", http.StatusFound)
}

70
views/pages/templates.go Normal file
View file

@ -0,0 +1,70 @@
package pages
import (
"html/template"
"io"
"os"
"path"
log "github.com/sirupsen/logrus"
"github.com/ordinary-dev/phoenix/config"
)
var (
// Preloaded templates.
// The key is the file name.
templates = make(map[string]*template.Template)
)
// Preload all templates into `Templates` map.
func LoadTemplates() error {
// Fragments are reusable parts of templates.
fragments, err := os.ReadDir("templates/fragments")
if err != nil {
return err
}
var fragmentPaths []string
for _, f := range fragments {
fragmentPaths = append(
fragmentPaths,
path.Join("templates/fragments", f.Name()),
)
}
// Load pages.
files, err := os.ReadDir("templates")
if err != nil {
return err
}
for _, f := range files {
if f.IsDir() {
continue
}
templatePaths := []string{path.Join("templates", f.Name())}
templatePaths = append(templatePaths, fragmentPaths...)
tmpl, err := template.ParseFiles(templatePaths...)
if err != nil {
return err
}
templates[f.Name()] = tmpl
log.Infof("Template %v was loaded", f.Name())
}
return nil
}
func Render(template string, wr io.Writer, data map[string]any) error {
data["fontFamily"] = config.Cfg.FontFamily
if _, ok := data["title"]; !ok {
data["title"] = config.Cfg.Title
}
return templates[template].Execute(wr, data)
}

View file

@ -1,13 +0,0 @@
package views
import (
"github.com/gin-gonic/gin"
"github.com/ordinary-dev/phoenix/config"
)
// Fill in the necessary parameters from the settings and output html.
func Render(ctx *gin.Context, cfg *config.Config, status int, templatePath string, params map[string]any) {
params["WebsiteTitle"] = cfg.Title
params["FontFamily"] = cfg.FontFamily
ctx.HTML(status, templatePath, params)
}

62
views/routes.go Normal file
View file

@ -0,0 +1,62 @@
package views
import (
"net/http"
"github.com/ordinary-dev/phoenix/views/middleware"
"github.com/ordinary-dev/phoenix/views/pages"
)
// Create and configure an HTTP server.
//
// Unfortunately, I haven't found a way to use PUT and DELETE methods without JavaScript.
// POST is used instead.
func GetHttpServer() (*http.Server, error) {
if err := pages.LoadTemplates(); err != nil {
return nil, err
}
mux := http.NewServeMux()
mux.Handle("/assets/", http.StripPrefix(
"/assets",
http.FileServer(http.Dir("assets")),
))
mux.HandleFunc("GET /signin", pages.ShowSignInForm)
mux.HandleFunc("POST /signin", pages.AuthorizeUser)
mux.HandleFunc("GET /registration", pages.ShowRegistrationForm)
mux.HandleFunc("POST /registration", pages.CreateUser)
protectedMux := http.NewServeMux()
mux.Handle("/", middleware.RequireAuth(protectedMux))
protectedMux.HandleFunc("GET /", pages.ShowMainPage)
protectedMux.HandleFunc("GET /settings", pages.ShowSettings)
// Groups.
// Create group.
protectedMux.HandleFunc("POST /groups", pages.CreateGroup)
// Update group.
protectedMux.HandleFunc("POST /groups/{id}/update", pages.UpdateGroup)
// Delete group.
protectedMux.HandleFunc("POST /groups/{id}/delete", pages.DeleteGroup)
// Links.
// Create link.
protectedMux.HandleFunc("POST /links", pages.CreateLink)
// Update link.
protectedMux.HandleFunc("POST /links/{id}/update", pages.UpdateLink)
// Delete link.
protectedMux.HandleFunc("POST /links/{id}/delete", pages.DeleteLink)
return &http.Server{
Addr: ":8080",
Handler: middleware.LoggingMiddleware(
middleware.SecurityHeadersMiddleware(mux),
),
}, nil
}

View file

@ -1,14 +0,0 @@
package views
import (
"github.com/gin-gonic/gin"
)
// Adds several headers to the response to improve security.
// For example, headers prevent embedding a site and passing information about the referrer.
func SecurityHeadersMiddleware(c *gin.Context) {
c.Writer.Header().Set("X-Frame-Options", "SAMEORIGIN")
c.Writer.Header().Set("X-Content-Type-Options", "nosniff")
c.Writer.Header().Set("Referrer-Policy", "same-origin")
c.Writer.Header().Set("Content-Security-Policy", "script-src 'self'; ")
}

View file

@ -1,30 +0,0 @@
package views
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/ordinary-dev/phoenix/config"
"github.com/ordinary-dev/phoenix/database"
"gorm.io/gorm"
)
func ShowSettings(cfg *config.Config, db *gorm.DB) gin.HandlerFunc {
return func(ctx *gin.Context) {
// Get a list of groups with links
var groups []database.Group
result := db.
Model(&database.Group{}).
Preload("Links").
Find(&groups)
if result.Error != nil {
ShowError(ctx, cfg, result.Error)
return
}
Render(ctx, cfg, http.StatusOK, "settings.html.tmpl", gin.H{
"groups": groups,
})
}
}