Compare commits

..

4 Commits

Author SHA1 Message Date
Maximilian
308198ee8b Merge branch 'master' into toml_config 2023-09-26 11:32:52 -05:00
Maximilian
ac19e2515a Go mod tidy and update x/crypto 2023-09-17 19:25:38 -05:00
Maximilian
60006b6e4e Use TOML for config 2023-09-04 15:21:26 -05:00
tfasano1
72e9ee3e43 Use TOML for config 2023-09-04 15:20:21 -05:00
16 changed files with 103 additions and 176 deletions

View File

@ -18,8 +18,8 @@ fine with getting your hands dirty, but I plan on having it ready to go for more
- Minimal user login/registration + sessions - Minimal user login/registration + sessions
- Config file handling - Config file handling
- Scheduled tasks - Scheduled tasks
- Entire website compiles into a single binary (~10mb) (excluding env.json) - Entire website compiles into a single binary (~10mb) (excluding env.toml)
- Minimal dependencies (just standard library, postgres driver, and x/crypto for bcrypt) - Minimal dependencies (just standard library, postgres driver, and experimental package for bcrypt)
<hr> <hr>
@ -41,7 +41,7 @@ fine with getting your hands dirty, but I plan on having it ready to go for more
1. Clone 1. Clone
2. Delete the git folder, so you can start tracking in your own repo 2. Delete the git folder, so you can start tracking in your own repo
3. Run `go get` to install dependencies 3. Run `go get` to install dependencies
4. Copy env_example.json to env.json and fill in the values 4. Copy env_example.toml to env.toml and fill in the values
5. Run `go run main.go` to start the server 5. Run `go run main.go` to start the server
6. Rename the occurences of "GoWeb" to your app name 6. Rename the occurences of "GoWeb" to your app name
7. Start building your app! 7. Start building your app!
@ -59,7 +59,7 @@ fine with getting your hands dirty, but I plan on having it ready to go for more
### License and disclaimer 😤 ### License and disclaimer 😤
- You are free to use this project under the terms of the MIT license. See LICENSE for more details. - You are free to use this project under the terms of the MIT license. See LICENSE for more details.
- You are responsible for the security and everything else regarding your application. - You and you alone are responsible for the security and everything else regarding your application.
- It is not required, but I ask that when you use this project you give me credit by linking to this repository. - It is not required, but I ask that when you use this project you give me credit by linking to this repository.
- I also ask that when releasing self-hosted or other end-user applications that you release it under - I also ask that when releasing self-hosted or other end-user applications that you release it under
the [GPLv3](https://www.gnu.org/licenses/gpl-3.0.html) license. This too is not required, but I would appreciate it. the [GPLv3](https://www.gnu.org/licenses/gpl-3.0.html) license. This too is not required, but I would appreciate it.

View File

@ -17,8 +17,8 @@ type Scheduled struct {
} }
type Task struct { type Task struct {
Funcs []func(app *App)
Interval time.Duration Interval time.Duration
Funcs []func(app *App)
} }
func RunScheduledTasks(app *App, poolSize int, stop <-chan struct{}) { func RunScheduledTasks(app *App, poolSize int, stop <-chan struct{}) {
@ -27,13 +27,13 @@ func RunScheduledTasks(app *App, poolSize int, stop <-chan struct{}) {
} }
tasks := []Task{ tasks := []Task{
{Funcs: app.ScheduledTasks.EverySecond, Interval: time.Second}, {Interval: time.Second, Funcs: app.ScheduledTasks.EverySecond},
{Funcs: app.ScheduledTasks.EveryMinute, Interval: time.Minute}, {Interval: time.Minute, Funcs: app.ScheduledTasks.EveryMinute},
{Funcs: app.ScheduledTasks.EveryHour, Interval: time.Hour}, {Interval: time.Hour, Funcs: app.ScheduledTasks.EveryHour},
{Funcs: app.ScheduledTasks.EveryDay, Interval: 24 * time.Hour}, {Interval: 24 * time.Hour, Funcs: app.ScheduledTasks.EveryDay},
{Funcs: app.ScheduledTasks.EveryWeek, Interval: 7 * 24 * time.Hour}, {Interval: 7 * 24 * time.Hour, Funcs: app.ScheduledTasks.EveryWeek},
{Funcs: app.ScheduledTasks.EveryMonth, Interval: 30 * 24 * time.Hour}, {Interval: 30 * 24 * time.Hour, Funcs: app.ScheduledTasks.EveryMonth},
{Funcs: app.ScheduledTasks.EveryYear, Interval: 365 * 24 * time.Hour}, {Interval: 365 * 24 * time.Hour, Funcs: app.ScheduledTasks.EveryYear},
} }
var wg sync.WaitGroup var wg sync.WaitGroup

View File

@ -1,54 +1,44 @@
package config package config
import ( import (
"encoding/json"
"flag" "flag"
"log/slog" "github.com/BurntSushi/toml"
"os" "os"
) )
type Configuration struct { type Configuration struct {
Db struct { Db struct {
Ip string `json:"DbIp"` Ip string `toml:"DbIp"`
Port string `json:"DbPort"` Port string `toml:"DbPort"`
Name string `json:"DbName"` Name string `toml:"DbName"`
User string `json:"DbUser"` User string `toml:"DbUser"`
Password string `json:"DbPassword"` Password string `toml:"DbPassword"`
AutoMigrate bool `json:"DbAutoMigrate"` AutoMigrate bool `toml:"DbAutoMigrate"`
} }
Listen struct { Listen struct {
Ip string `json:"HttpIp"` Ip string `toml:"HttpIp"`
Port string `json:"HttpPort"` Port string `toml:"HttpPort"`
} }
Template struct { Template struct {
BaseName string `json:"BaseTemplateName"` BaseName string `toml:"BaseTemplateName"`
ContentPath string `json:"ContentPath"`
} }
} }
// LoadConfig loads and returns a configuration struct // LoadConfig loads and returns a configuration struct
func LoadConfig() Configuration { func LoadConfig() Configuration {
c := flag.String("c", "env.json", "Path to the json configuration file") c := flag.String("c", "env.toml", "Path to the toml configuration file")
flag.Parse() flag.Parse()
file, err := os.Open(*c) file, err := os.ReadFile(*c)
if err != nil { if err != nil {
panic("unable to open JSON config file: " + err.Error()) panic("Unable to read TOML config file: " + err.Error())
} }
defer func(file *os.File) { var Config Configuration
err := file.Close() _, err = toml.Decode(string(file), &Config)
if err != nil {
slog.Error("unable to close JSON config file: ", err)
}
}(file)
decoder := json.NewDecoder(file)
Config := Configuration{}
err = decoder.Decode(&Config)
if err != nil { if err != nil {
panic("unable to decode JSON config file: " + err.Error()) panic("Unable to decode TOML config file: " + err.Error())
} }
return Config return Config

View File

@ -22,7 +22,7 @@ func (g *Get) ShowHome(w http.ResponseWriter, _ *http.Request) {
Test: "Hello World!", Test: "Hello World!",
} }
templating.RenderTemplate(w, "templates/pages/home.html", data) templating.RenderTemplate(g.App, w, "templates/pages/home.html", data)
} }
func (g *Get) ShowRegister(w http.ResponseWriter, r *http.Request) { func (g *Get) ShowRegister(w http.ResponseWriter, r *http.Request) {
@ -39,7 +39,7 @@ func (g *Get) ShowRegister(w http.ResponseWriter, r *http.Request) {
CsrfToken: CsrfToken, CsrfToken: CsrfToken,
} }
templating.RenderTemplate(w, "templates/pages/register.html", data) templating.RenderTemplate(g.App, w, "templates/pages/register.html", data)
} }
func (g *Get) ShowLogin(w http.ResponseWriter, r *http.Request) { func (g *Get) ShowLogin(w http.ResponseWriter, r *http.Request) {
@ -56,7 +56,7 @@ func (g *Get) ShowLogin(w http.ResponseWriter, r *http.Request) {
CsrfToken: CsrfToken, CsrfToken: CsrfToken,
} }
templating.RenderTemplate(w, "templates/pages/login.html", data) templating.RenderTemplate(g.App, w, "templates/pages/login.html", data)
} }
func (g *Get) Logout(w http.ResponseWriter, r *http.Request) { func (g *Get) Logout(w http.ResponseWriter, r *http.Request) {

View File

@ -9,8 +9,7 @@ import (
"reflect" "reflect"
) )
// Migrate given a dummy object of any type, it will create a table with the same name // Migrate given a dummy object of any type, it will create a table with the same name as the type and create columns with the same name as the fields of the object
// as the type and create columns with the same name as the fields of the object
func Migrate(app *app.App, anyStruct interface{}) error { func Migrate(app *app.App, anyStruct interface{}) error {
valueOfStruct := reflect.ValueOf(anyStruct) valueOfStruct := reflect.ValueOf(anyStruct)
typeOfStruct := valueOfStruct.Type() typeOfStruct := valueOfStruct.Type()
@ -24,15 +23,10 @@ func Migrate(app *app.App, anyStruct interface{}) error {
for i := 0; i < valueOfStruct.NumField(); i++ { for i := 0; i < valueOfStruct.NumField(); i++ {
fieldType := typeOfStruct.Field(i) fieldType := typeOfStruct.Field(i)
fieldName := fieldType.Name fieldName := fieldType.Name
if fieldName != "Id" && fieldName != "id" {
// Create column if dummy for migration is NOT zero value err := createColumn(app, tableName, fieldName, fieldType.Type.Name())
fieldValue := valueOfStruct.Field(i).Interface() if err != nil {
if !reflect.ValueOf(fieldValue).IsZero() { return err
if fieldName != "Id" && fieldName != "id" {
err := createColumn(app, tableName, fieldName, fieldType.Type.Name())
if err != nil {
return err
}
} }
} }
} }

View File

@ -1,18 +0,0 @@
{
"Db": {
"DbIp": "127.0.0.1",
"DbPort": "5432",
"DbName": "database",
"DbUser": "user",
"DbPassword": "password",
"DbAutoMigrate": true
},
"Listen": {
"HttpIp": "127.0.0.1",
"HttpPort": "8090"
},
"Template": {
"BaseTemplateName": "templates/base.html",
"ContentPath": "templates"
}
}

14
env_example.toml Normal file
View File

@ -0,0 +1,14 @@
[Db]
DbIp = "127.0.0.1"
DbPort = "5432"
DbName = "test"
DbUser = "postgres"
DbPassword = "postgres"
DbAutoMigrate = true
[Listen]
HttpIp = "127.0.0.1"
HttpPort = "8090"
[Template]
BaseTemplateName = "templates/base.html"

6
go.mod
View File

@ -1,8 +1,10 @@
module GoWeb module GoWeb
go 1.22 go 1.21
require ( require (
github.com/lib/pq v1.10.9 github.com/lib/pq v1.10.9
golang.org/x/crypto v0.24.0 golang.org/x/crypto v0.13.0
) )
require github.com/BurntSushi/toml v1.3.2

6
go.sum
View File

@ -1,4 +1,6 @@
github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8=
github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI= golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck=
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=

View File

@ -6,7 +6,6 @@ import (
"GoWeb/database" "GoWeb/database"
"GoWeb/models" "GoWeb/models"
"GoWeb/routes" "GoWeb/routes"
"GoWeb/templating"
"context" "context"
"embed" "embed"
"errors" "errors"
@ -68,13 +67,6 @@ func main() {
routes.Get(&appLoaded) routes.Get(&appLoaded)
routes.Post(&appLoaded) routes.Post(&appLoaded)
// Prepare templates
err = templating.BuildPages(&appLoaded)
if err != nil {
slog.Error("error building templates: " + err.Error())
os.Exit(1)
}
// Start server // Start server
server := &http.Server{Addr: appLoaded.Config.Listen.Ip + ":" + appLoaded.Config.Listen.Port} server := &http.Server{Addr: appLoaded.Config.Listen.Ip + ":" + appLoaded.Config.Listen.Port}
go func() { go func() {

View File

@ -17,7 +17,7 @@ type Session struct {
CreatedAt time.Time CreatedAt time.Time
} }
const sessionColumnsNoId = "\"UserId\", \"AuthToken\", \"RememberMe\", \"CreatedAt\"" const sessionColumnsNoId = "\"UserId\", \"AuthToken\",\"RememberMe\", \"CreatedAt\""
const sessionColumns = "\"Id\", " + sessionColumnsNoId const sessionColumns = "\"Id\", " + sessionColumnsNoId
const sessionTable = "public.\"Session\"" const sessionTable = "public.\"Session\""
@ -62,7 +62,7 @@ func CreateSession(app *app.App, w http.ResponseWriter, userId int64, remember b
return session, nil return session, nil
} }
func SessionByAuthToken(app *app.App, authToken string) (Session, error) { func GetSessionByAuthToken(app *app.App, authToken string) (Session, error) {
session := Session{} session := Session{}
err := app.Db.QueryRow(selectSessionByAuthToken, authToken).Scan(&session.Id, &session.UserId, &session.AuthToken, &session.RememberMe, &session.CreatedAt) err := app.Db.QueryRow(selectSessionByAuthToken, authToken).Scan(&session.Id, &session.UserId, &session.AuthToken, &session.RememberMe, &session.CreatedAt)

View File

@ -2,8 +2,6 @@ package models
import ( import (
"GoWeb/app" "GoWeb/app"
"crypto/sha256"
"encoding/hex"
"log/slog" "log/slog"
"net/http" "net/http"
"time" "time"
@ -29,23 +27,23 @@ const (
insertUser = "INSERT INTO " + userTable + " (" + userColumnsNoId + ") VALUES ($1, $2, $3, $4) RETURNING \"Id\"" insertUser = "INSERT INTO " + userTable + " (" + userColumnsNoId + ") VALUES ($1, $2, $3, $4) RETURNING \"Id\""
) )
// CurrentUser finds the currently logged-in user by session cookie // GetCurrentUser finds the currently logged-in user by session cookie
func CurrentUser(app *app.App, r *http.Request) (User, error) { func GetCurrentUser(app *app.App, r *http.Request) (User, error) {
cookie, err := r.Cookie("session") cookie, err := r.Cookie("session")
if err != nil { if err != nil {
return User{}, err return User{}, err
} }
session, err := SessionByAuthToken(app, cookie.Value) session, err := GetSessionByAuthToken(app, cookie.Value)
if err != nil { if err != nil {
return User{}, err return User{}, err
} }
return UserById(app, session.UserId) return GetUserById(app, session.UserId)
} }
// UserById finds a User table row in the database by id and returns a struct representing this row // GetUserById finds a User table row in the database by id and returns a struct representing this row
func UserById(app *app.App, id int64) (User, error) { func GetUserById(app *app.App, id int64) (User, error) {
user := User{} user := User{}
err := app.Db.QueryRow(selectUserById, id).Scan(&user.Id, &user.Username, &user.Password, &user.CreatedAt, &user.UpdatedAt) err := app.Db.QueryRow(selectUserById, id).Scan(&user.Id, &user.Username, &user.Password, &user.CreatedAt, &user.UpdatedAt)
@ -56,8 +54,8 @@ func UserById(app *app.App, id int64) (User, error) {
return user, nil return user, nil
} }
// UserByUsername finds a User table row in the database by username and returns a struct representing this row // GetUserByUsername finds a User table row in the database by username and returns a struct representing this row
func UserByUsername(app *app.App, username string) (User, error) { func GetUserByUsername(app *app.App, username string) (User, error) {
user := User{} user := User{}
err := app.Db.QueryRow(selectUserByUsername, username).Scan(&user.Id, &user.Username, &user.Password, &user.CreatedAt, &user.UpdatedAt) err := app.Db.QueryRow(selectUserByUsername, username).Scan(&user.Id, &user.Username, &user.Password, &user.CreatedAt, &user.UpdatedAt)
@ -70,12 +68,7 @@ func UserByUsername(app *app.App, username string) (User, error) {
// CreateUser creates a User table row in the database // CreateUser creates a User table row in the database
func CreateUser(app *app.App, username string, password string, createdAt time.Time, updatedAt time.Time) (User, error) { func CreateUser(app *app.App, username string, password string, createdAt time.Time, updatedAt time.Time) (User, error) {
// Get sha256 hash of password then get bcrypt hash to store hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
hash256 := sha256.New()
hash256.Write([]byte(password))
hashSum := hash256.Sum(nil)
hashString := hex.EncodeToString(hashSum)
hash, err := bcrypt.GenerateFromPassword([]byte(hashString), bcrypt.DefaultCost)
if err != nil { if err != nil {
slog.Error("error hashing password: " + err.Error()) slog.Error("error hashing password: " + err.Error())
return User{}, err return User{}, err
@ -89,7 +82,7 @@ func CreateUser(app *app.App, username string, password string, createdAt time.T
return User{}, err return User{}, err
} }
return UserById(app, lastInsertId) return GetUserById(app, lastInsertId)
} }
// AuthenticateUser validates the password for the specified user // AuthenticateUser validates the password for the specified user
@ -102,12 +95,7 @@ func AuthenticateUser(app *app.App, w http.ResponseWriter, username string, pass
return Session{}, err return Session{}, err
} }
// Get sha256 hash of password then check bcrypt err = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password))
hash256 := sha256.New()
hash256.Write([]byte(password))
hashSum := hash256.Sum(nil)
hashString := hex.EncodeToString(hashSum)
err = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(hashString))
if err != nil { // Failed to validate password, doesn't match if err != nil { // Failed to validate password, doesn't match
slog.Info("incorrect password:" + username) slog.Info("incorrect password:" + username)
return Session{}, err return Session{}, err

View File

@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>SiteName - {{ template "pageTitle" }}</title> <title>SiteName - {{ template "pageTitle" }}</title>
<link href="/static/css/style.css" rel="stylesheet"> <link rel="stylesheet" href="/static/css/style.css">
</head> </head>
<body> <body>
{{ template "content" . }} {{ template "content" . }}

View File

@ -7,11 +7,11 @@
<input name="csrf_token" type="hidden" value="{{ .CsrfToken }}"> <input name="csrf_token" type="hidden" value="{{ .CsrfToken }}">
<label for="username">Username:</label><br> <label for="username">Username:</label><br>
<input id="username" name="username" placeholder="John" type="text"><br><br> <input id="username" name="username" type="text" placeholder="John"><br><br>
<label for="password">Password:</label><br> <label for="password">Password:</label><br>
<input id="password" name="password" type="password"><br><br> <input id="password" name="password" type="password"><br><br>
<label for="remember">Remember Me:</label> <label for="remember">Remember Me:</label>
<input id="remember" name="remember" type="checkbox"><br><br> <input id="remember" type="checkbox" name="remember"><br><br>
<input type="submit" value="Submit"> <input type="submit" value="Submit">
</form> </form>
</div> </div>

View File

@ -7,7 +7,7 @@
<input name="csrf_token" type="hidden" value="{{ .CsrfToken }}"> <input name="csrf_token" type="hidden" value="{{ .CsrfToken }}">
<label for="username">Username:</label><br> <label for="username">Username:</label><br>
<input id="username" name="username" placeholder="John" type="text"><br><br> <input id="username" name="username" type="text" placeholder="John"><br><br>
<label for="password">Password:</label><br> <label for="password">Password:</label><br>
<input id="password" name="password" type="password"><br><br> <input id="password" name="password" type="password"><br><br>
<input type="submit" value="Submit"> <input type="submit" value="Submit">

View File

@ -2,82 +2,45 @@ package templating
import ( import (
"GoWeb/app" "GoWeb/app"
"fmt"
"html/template" "html/template"
"io/fs"
"log/slog" "log/slog"
"net/http" "net/http"
) )
var templates = make(map[string]*template.Template) // This is only used here, does not need to be in app.App // RenderTemplate renders and serves a template from the embedded filesystem optionally with given data
func RenderTemplate(app *app.App, w http.ResponseWriter, contentPath string, data any) {
templatePath := app.Config.Template.BaseName
func BuildPages(app *app.App) error { templateContent, err := app.Res.ReadFile(templatePath)
basePath := app.Config.Template.BaseName
baseContent, err := app.Res.ReadFile(basePath)
if err != nil { if err != nil {
return fmt.Errorf("error reading base file: %w", err)
}
base, err := template.New(basePath).Parse(string(baseContent)) // Sets filepath as name and parses content
if err != nil {
return fmt.Errorf("error parsing base file: %w", err)
}
readFilesRecursively := func(fsys fs.FS, root string) ([]string, error) {
var files []string
err := fs.WalkDir(fsys, root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return fmt.Errorf("error walking the path %q: %w", path, err)
}
if !d.IsDir() {
files = append(files, path)
}
return nil
})
return files, err
}
// Get all file paths in the directory tree
filePaths, err := readFilesRecursively(app.Res, app.Config.Template.ContentPath)
if err != nil {
return fmt.Errorf("error reading files recursively: %w", err)
}
for _, contentPath := range filePaths { // Create a new template base + content for each page
content, err := app.Res.ReadFile(contentPath)
if err != nil {
return fmt.Errorf("error reading content file %s: %w", contentPath, err)
}
t, err := base.Clone()
if err != nil {
return fmt.Errorf("error cloning base template: %w", err)
}
_, err = t.Parse(string(content))
if err != nil {
return fmt.Errorf("error parsing content: %w", err)
}
templates[contentPath] = t
}
return nil
}
func RenderTemplate(w http.ResponseWriter, contentPath string, data any) {
t, ok := templates[contentPath]
if !ok {
err := fmt.Errorf("template not found for path: %s", contentPath)
slog.Error(err.Error()) slog.Error(err.Error())
http.Error(w, "Template not found", 404) http.Error(w, err.Error(), 500)
return return
} }
err := t.Execute(w, data) // Execute prebuilt template with dynamic data t, err := template.New(templatePath).Parse(string(templateContent))
if err != nil {
slog.Error(err.Error())
http.Error(w, err.Error(), 500)
return
}
content, err := app.Res.ReadFile(contentPath)
if err != nil {
slog.Error(err.Error())
http.Error(w, err.Error(), 500)
return
}
t, err = t.Parse(string(content))
if err != nil {
slog.Error(err.Error())
http.Error(w, err.Error(), 500)
return
}
err = t.Execute(w, data)
if err != nil { if err != nil {
err = fmt.Errorf("error executing template: %w", err)
slog.Error(err.Error()) slog.Error(err.Error())
http.Error(w, err.Error(), 500) http.Error(w, err.Error(), 500)
return return