13 Commits

15 changed files with 123 additions and 58 deletions

View File

@ -19,7 +19,7 @@ fine with getting your hands dirty, but I plan on having it ready to go for more
- 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.json)
- Minimal dependencies (just standard library, postgres driver, and experimental package for bcrypt) - Minimal dependencies (just standard library, postgres driver, and x/crypto for bcrypt)
<hr> <hr>
@ -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 and you alone are responsible for the security and everything else regarding your application. - You 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 {
Interval time.Duration
Funcs []func(app *App) Funcs []func(app *App)
Interval time.Duration
} }
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{
{Interval: time.Second, Funcs: app.ScheduledTasks.EverySecond}, {Funcs: app.ScheduledTasks.EverySecond, Interval: time.Second},
{Interval: time.Minute, Funcs: app.ScheduledTasks.EveryMinute}, {Funcs: app.ScheduledTasks.EveryMinute, Interval: time.Minute},
{Interval: time.Hour, Funcs: app.ScheduledTasks.EveryHour}, {Funcs: app.ScheduledTasks.EveryHour, Interval: time.Hour},
{Interval: 24 * time.Hour, Funcs: app.ScheduledTasks.EveryDay}, {Funcs: app.ScheduledTasks.EveryDay, Interval: 24 * time.Hour},
{Interval: 7 * 24 * time.Hour, Funcs: app.ScheduledTasks.EveryWeek}, {Funcs: app.ScheduledTasks.EveryWeek, Interval: 7 * 24 * time.Hour},
{Interval: 30 * 24 * time.Hour, Funcs: app.ScheduledTasks.EveryMonth}, {Funcs: app.ScheduledTasks.EveryMonth, Interval: 30 * 24 * time.Hour},
{Interval: 365 * 24 * time.Hour, Funcs: app.ScheduledTasks.EveryYear}, {Funcs: app.ScheduledTasks.EveryYear, Interval: 365 * 24 * time.Hour},
} }
var wg sync.WaitGroup var wg sync.WaitGroup

View File

@ -24,6 +24,7 @@ type Configuration struct {
Template struct { Template struct {
BaseName string `json:"BaseTemplateName"` BaseName string `json:"BaseTemplateName"`
ContentPath string `json:"ContentPath"`
} }
} }

View File

@ -22,7 +22,7 @@ func (g *Get) ShowHome(w http.ResponseWriter, _ *http.Request) {
Test: "Hello World!", Test: "Hello World!",
} }
templating.RenderTemplate(g.App, w, "templates/pages/home.html", data) templating.RenderTemplate(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(g.App, w, "templates/pages/register.html", data) templating.RenderTemplate(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(g.App, w, "templates/pages/login.html", data) templating.RenderTemplate(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,7 +9,8 @@ import (
"reflect" "reflect"
) )
// 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 // 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
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()
@ -23,6 +24,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
// Create column if dummy for migration is NOT zero value
fieldValue := valueOfStruct.Field(i).Interface()
if !reflect.ValueOf(fieldValue).IsZero() {
if fieldName != "Id" && fieldName != "id" { if fieldName != "Id" && fieldName != "id" {
err := createColumn(app, tableName, fieldName, fieldType.Type.Name()) err := createColumn(app, tableName, fieldName, fieldType.Type.Name())
if err != nil { if err != nil {
@ -30,6 +35,7 @@ func Migrate(app *app.App, anyStruct interface{}) error {
} }
} }
} }
}
return nil return nil
} }

View File

@ -12,6 +12,7 @@
"HttpPort": "8090" "HttpPort": "8090"
}, },
"Template": { "Template": {
"BaseTemplateName": "templates/base.html" "BaseTemplateName": "templates/base.html",
"ContentPath": "templates"
} }
} }

4
go.mod
View File

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

4
go.sum
View File

@ -1,4 +1,4 @@
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.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI=
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=

View File

@ -6,6 +6,7 @@ import (
"GoWeb/database" "GoWeb/database"
"GoWeb/models" "GoWeb/models"
"GoWeb/routes" "GoWeb/routes"
"GoWeb/templating"
"context" "context"
"embed" "embed"
"errors" "errors"
@ -67,6 +68,13 @@ 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\""

View File

@ -2,6 +2,8 @@ package models
import ( import (
"GoWeb/app" "GoWeb/app"
"crypto/sha256"
"encoding/hex"
"log/slog" "log/slog"
"net/http" "net/http"
"time" "time"
@ -68,7 +70,12 @@ 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) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) // Get sha256 hash of password then get bcrypt hash to store
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
@ -95,7 +102,12 @@ func AuthenticateUser(app *app.App, w http.ResponseWriter, username string, pass
return Session{}, err return Session{}, err
} }
err = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) // Get sha256 hash of password then check bcrypt
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 rel="stylesheet" href="/static/css/style.css"> <link href="/static/css/style.css" rel="stylesheet">
</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" type="text" placeholder="John"><br><br> <input id="username" name="username" placeholder="John" type="text"><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" type="checkbox" name="remember"><br><br> <input id="remember" name="remember" type="checkbox"><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" type="text" placeholder="John"><br><br> <input id="username" name="username" placeholder="John" type="text"><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,45 +2,82 @@ package templating
import ( import (
"GoWeb/app" "GoWeb/app"
"fmt"
"html/template" "html/template"
"io/fs"
"log/slog" "log/slog"
"net/http" "net/http"
) )
// RenderTemplate renders and serves a template from the embedded filesystem optionally with given data var templates = make(map[string]*template.Template) // This is only used here, does not need to be in app.App
func RenderTemplate(app *app.App, w http.ResponseWriter, contentPath string, data any) {
templatePath := app.Config.Template.BaseName
templateContent, err := app.Res.ReadFile(templatePath) func BuildPages(app *app.App) error {
basePath := app.Config.Template.BaseName
baseContent, err := app.Res.ReadFile(basePath)
if err != nil { if err != nil {
slog.Error(err.Error()) return fmt.Errorf("error reading base file: %w", err)
http.Error(w, err.Error(), 500)
return
} }
t, err := template.New(templatePath).Parse(string(templateContent)) base, err := template.New(basePath).Parse(string(baseContent)) // Sets filepath as name and parses content
if err != nil { if err != nil {
slog.Error(err.Error()) return fmt.Errorf("error parsing base file: %w", err)
http.Error(w, err.Error(), 500)
return
} }
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) content, err := app.Res.ReadFile(contentPath)
if err != nil { 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, err.Error(), 500) http.Error(w, "Template not found", 404)
return return
} }
t, err = t.Parse(string(content)) err := t.Execute(w, data) // Execute prebuilt template with dynamic data
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