77 Commits

Author SHA1 Message Date
308198ee8b Merge branch 'master' into toml_config 2023-09-26 11:32:52 -05:00
d8b1a5c999 Remove unnecessary comparison 2023-09-26 11:32:39 -05:00
ac19e2515a Go mod tidy and update x/crypto 2023-09-17 19:25:38 -05:00
0f59a6eba9 Go mod tidy and update x/crypto 2023-09-17 19:23:57 -05:00
60006b6e4e Use TOML for config 2023-09-04 15:21:26 -05:00
72e9ee3e43 Use TOML for config 2023-09-04 15:20:21 -05:00
bb69c16cdf Update How to use section 2023-09-03 16:18:48 -05:00
e87f14bcbf Make all slog errors uniform (lowercase) 2023-09-03 15:56:35 -05:00
ed712a5344 Clean up error handling, migrate to log/slog, add todo for flash message system in post controller 2023-09-03 15:45:12 -05:00
ee4c9f9199 Clean up error handling, begin migration to log/slog 2023-08-17 21:13:17 -05:00
1d400efa19 Update x/crypto and Go version 2023-08-12 14:30:12 -05:00
b36cbf4b9e Comment cleanup 2023-08-12 14:28:21 -05:00
fa3165d317 Use proper error comparison 2023-08-03 12:13:37 -05:00
7cb36db3c2 Handle errors 2023-08-03 12:13:15 -05:00
eda5344685 Fix spelling 2023-08-03 12:11:01 -05:00
9670b7d717 Better naming of functions and files, delete less than helpful comments 2023-08-03 12:09:40 -05:00
a1438f4fe2 Update README.md 2023-07-31 20:18:11 -05:00
052fa689c7 Merge branch 'middleware' 2023-07-31 18:41:49 -05:00
f1fad7e4e3 Pass in handler to middleware, create definition for MiddlewareFunc 2023-07-31 18:37:54 -05:00
b475da66da Fix nil error for GET requests 2023-07-26 13:09:01 -05:00
d0085ab2c3 Update crypto dependency 2023-07-25 15:36:25 -05:00
58514f4c5f Update client to handle GET (no body) requests, multipart requests, and JSON requests 2023-07-25 15:34:11 -05:00
606f5df45a Refactor name 2023-07-22 23:37:38 -05:00
2a32a1b3ce Add restclient package, and a simple function to send HTTP requests 2023-07-21 16:35:18 -05:00
eb36156c52 Change function name to ProcessGroup 2023-07-21 16:26:43 -05:00
bada24884a Use ungrouped CSRF middleware on register and login POST routes 2023-07-21 11:59:55 -05:00
05397c2b61 Initial middleware implementation for CSRF and update comment 2023-07-21 11:59:01 -05:00
3d80b95f55 Initial wrapper implementation 2023-07-07 18:05:17 -05:00
6da7d408f9 Add .gitattributes to force LF line endings 2023-05-05 12:19:17 -05:00
max
e993bcf317 Update dependency versions 2023-05-04 09:00:35 -05:00
9b231a73d6 Update README.md 2023-04-07 21:32:39 -05:00
34acd0fa8d Remove old session query 2023-04-07 21:27:14 -05:00
71d3bd77d0 Add ability to get session given an AuthToken, fix GetCurrentUser() 2023-04-07 21:23:46 -05:00
1451abcca4 Formatting 2023-04-06 12:01:29 -05:00
max
53a780343f Fix scheduler by adding a wait group 2023-04-06 09:55:56 -05:00
max
8e4c5e3268 Fix wrong query for clearing 6-hour old sessions 2023-04-06 09:35:53 -05:00
max
f18f512fea Properly set the name of the checkbox for parsing 2023-04-06 09:31:12 -05:00
max
58328fe505 Fix some SQL errors 2023-04-06 09:30:53 -05:00
max
10e7830349 Remember me checkbox on login form 2023-04-06 08:57:17 -05:00
max
5f7e674d32 Add remember me functionality, handle both types of sessions appropriately 2023-04-06 08:56:48 -05:00
max
ec9c1a8fb5 Initial clear old sessions implementation 2023-04-04 14:37:36 -05:00
max
242029f2e5 Initial task scheduler implementation 2023-04-04 14:37:23 -05:00
b1c65f2ab1 Remove erroneous SetCookie (leftover from redundant remove) 2023-03-27 15:05:11 -05:00
max
965139ea18 Remove redundant session cookie clear 2023-03-16 08:40:50 -05:00
cf8aea5115 Update README.md 2023-03-06 21:34:12 -06:00
c510646c84 Make username text placeholder instead of value 2023-03-06 21:27:05 -06:00
a4366c7395 Add more to .gitignore 2023-03-06 21:23:56 -06:00
073dfafb28 Change log message 2023-03-06 21:10:09 -06:00
3fa5cf46d2 Update experimental crypto library 2023-03-06 21:08:56 -06:00
bd8b015f44 Update README.md 2023-03-06 21:02:41 -06:00
5a1cd77676 Update README.md 2023-03-06 13:10:50 -06:00
012906eee2 Update README.md 2023-03-06 13:00:11 -06:00
2a705483d9 Add README.md 2023-03-06 12:58:58 -06:00
be2c3ae178 Add default theme and apply to pages 2023-03-06 12:44:20 -06:00
f32223f12c Fix static file handling for the embedded filesystem 2023-03-06 12:43:54 -06:00
eff740072d Decouple SQL queries from logic 2023-03-05 15:46:43 -06:00
75d8996cf9 Fix some queries, comments, and error logging 2023-02-28 15:02:21 -06:00
ac2b5262fd Remove print 2023-02-28 14:57:15 -06:00
b9ac6fbd5f Add session migration 2023-02-28 14:55:09 -06:00
baa8eb2b93 Move to a session based system for AuthTokens 2023-02-28 14:54:55 -06:00
402c514970 Add checks to skip table and column creation if they already exist 2023-02-17 19:01:59 -06:00
89d1b96400 Change CreatedAt and UpdatedAt to type Time and update migrations.go accordingly 2023-02-17 18:55:27 -06:00
2b46385126 Fix time.Time matching to timestamp postgres type (reflection just gives "Time") 2023-02-17 18:52:15 -06:00
0a77813360 Fix postgres type matching 2023-02-17 18:47:29 -06:00
f7eb852c66 Gracefully shut down server when interrupt signal is received and remove panic when creating log directory 2023-02-17 18:25:14 -06:00
5ae84c1995 Remove unneeded comments 2023-02-15 19:13:05 -06:00
3336bd0b3f Remove default condition 2023-02-15 19:10:50 -06:00
max
f2a7336283 Fix user queries and a logical error in GetCurrentUser 2023-02-14 09:43:02 -06:00
max
204971d40a AutoMigrate changed to DbAutoMigrate to match correctly 2023-02-14 08:31:13 -06:00
fcd6477ec3 Migration implementation, auto migrate when starting program 2023-02-13 23:41:45 -06:00
bbbf14bdc7 Fix example config to have AutoMigrate be a proper boolean type in JSON 2023-02-13 23:32:16 -06:00
eb1c2daa6a Add AuthToken to user struct, and update SQL statements to match struct fields 2023-02-13 23:30:12 -06:00
cb786a6a56 Prepare config option to enable auto migrations 2023-02-13 23:28:36 -06:00
b962bbdd88 Fix import order 2023-02-13 22:28:11 -06:00
a2077131a7 Update experimental crypto library 2023-02-08 19:55:10 -06:00
edccb95be3 Remove unnecessary assignment 2023-02-08 19:47:08 -06:00
9e4216301d Move models to its own package 2023-02-08 19:39:53 -06:00
35 changed files with 1079 additions and 514 deletions

1
.gitattributes vendored Normal file
View File

@ -0,0 +1 @@
* text=auto eol=lf

24
.gitignore vendored
View File

@ -1,4 +1,26 @@
# GoWeb specific
env.json
logs/
*.log
/.idea
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
# Dependency directories
vendor/
# Go workspace file
go.work
# IDE files
/.idea

65
README.md Normal file
View File

@ -0,0 +1,65 @@
# GoWeb 🌐
GoWeb is a simple Go web framework that aims to only use the standard library. The overall file structure and
development flow is inspired by larger frameworks like Laravel. It is partially ready for smaller projects if you are
fine with getting your hands dirty, but I plan on having it ready to go for more serious projects when it hits version
2.0.
<hr>
## Current features 🚀
- Routing/controllers
- Templating
- Simple database migration system
- Built in REST client
- CSRF protection
- Middleware
- Minimal user login/registration + sessions
- Config file handling
- Scheduled tasks
- Entire website compiles into a single binary (~10mb) (excluding env.toml)
- Minimal dependencies (just standard library, postgres driver, and experimental package for bcrypt)
<hr>
## When to use 🙂
- You need to build a dynamic web application with persistent data
- You need to build a dynamic website using Go and need a good starting point
- You need to build an API in Go and don't know where to start
- Pretty much any use-case where you would use Laravel, Django, or Flask
## When not to use 🙃
- You need a static website (see [Hugo](https://gohugo.io/))
- You need a simple blog (see [Hugo](https://gohugo.io/))
- You need a simple site for your projects' documentation (see [Hugo](https://gohugo.io/))
## How to use 🤔
1. Clone
2. Delete the git folder, so you can start tracking in your own repo
3. Run `go get` to install dependencies
4. Copy env_example.toml to env.toml and fill in the values
5. Run `go run main.go` to start the server
6. Rename the occurences of "GoWeb" to your app name
7. Start building your app!
8. When you see useful changes to GoWeb you'd like in your project copy them over
## How to contribute 👨‍💻
- Open an issue on GitHub if you find a bug or have a feature request.
- [Email](mailto:contact@mpatterson.xyz) me a patch if you want to contribute code.
- Please include a good description of what the patch does and why it is needed, also include how you want to be
credited in the commit message.
<hr>
### License and disclaimer 😤
- 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.
- 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
the [GPLv3](https://www.gnu.org/licenses/gpl-3.0.html) license. This too is not required, but I would appreciate it.

View File

@ -8,7 +8,8 @@ import (
// App contains and supplies available configurations and connections
type App struct {
Config config.Configuration // Configuration file
Db *sql.DB // Database connection
Res *embed.FS // Resources from the embedded filesystem
Config config.Configuration // Configuration file
Db *sql.DB // Database connection
Res *embed.FS // Resources from the embedded filesystem
ScheduledTasks Scheduled // Scheduled contains a struct of all scheduled functions
}

71
app/schedule.go Normal file
View File

@ -0,0 +1,71 @@
package app
import (
"sync"
"time"
)
type Scheduled struct {
EveryReboot []func(app *App)
EverySecond []func(app *App)
EveryMinute []func(app *App)
EveryHour []func(app *App)
EveryDay []func(app *App)
EveryWeek []func(app *App)
EveryMonth []func(app *App)
EveryYear []func(app *App)
}
type Task struct {
Interval time.Duration
Funcs []func(app *App)
}
func RunScheduledTasks(app *App, poolSize int, stop <-chan struct{}) {
for _, f := range app.ScheduledTasks.EveryReboot {
f(app)
}
tasks := []Task{
{Interval: time.Second, Funcs: app.ScheduledTasks.EverySecond},
{Interval: time.Minute, Funcs: app.ScheduledTasks.EveryMinute},
{Interval: time.Hour, Funcs: app.ScheduledTasks.EveryHour},
{Interval: 24 * time.Hour, Funcs: app.ScheduledTasks.EveryDay},
{Interval: 7 * 24 * time.Hour, Funcs: app.ScheduledTasks.EveryWeek},
{Interval: 30 * 24 * time.Hour, Funcs: app.ScheduledTasks.EveryMonth},
{Interval: 365 * 24 * time.Hour, Funcs: app.ScheduledTasks.EveryYear},
}
var wg sync.WaitGroup
runners := make([]chan bool, len(tasks))
for i, task := range tasks {
runner := make(chan bool, poolSize)
runners[i] = runner
wg.Add(1)
go func(task Task, runner chan bool) {
defer wg.Done()
ticker := time.NewTicker(task.Interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
for _, f := range task.Funcs {
runner <- true
go func(f func(app *App)) {
defer func() { <-runner }()
f(app)
}(f)
}
case <-stop:
return
}
}
}(task, runner)
}
wg.Wait()
for _, runner := range runners {
close(runner)
}
}

View File

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

65
controllers/get.go Normal file
View File

@ -0,0 +1,65 @@
package controllers
import (
"GoWeb/app"
"GoWeb/models"
"GoWeb/security"
"GoWeb/templating"
"net/http"
)
// Get is a wrapper struct for the App struct
type Get struct {
App *app.App
}
func (g *Get) ShowHome(w http.ResponseWriter, _ *http.Request) {
type dataStruct struct {
Test string
}
data := dataStruct{
Test: "Hello World!",
}
templating.RenderTemplate(g.App, w, "templates/pages/home.html", data)
}
func (g *Get) ShowRegister(w http.ResponseWriter, r *http.Request) {
type dataStruct struct {
CsrfToken string
}
CsrfToken, err := security.GenerateCsrfToken(w, r)
if err != nil {
return
}
data := dataStruct{
CsrfToken: CsrfToken,
}
templating.RenderTemplate(g.App, w, "templates/pages/register.html", data)
}
func (g *Get) ShowLogin(w http.ResponseWriter, r *http.Request) {
type dataStruct struct {
CsrfToken string
}
CsrfToken, err := security.GenerateCsrfToken(w, r)
if err != nil {
return
}
data := dataStruct{
CsrfToken: CsrfToken,
}
templating.RenderTemplate(g.App, w, "templates/pages/login.html", data)
}
func (g *Get) Logout(w http.ResponseWriter, r *http.Request) {
models.LogoutUser(g.App, w, r)
http.Redirect(w, r, "/", http.StatusFound)
}

View File

@ -1,67 +0,0 @@
package controllers
import (
"GoWeb/app"
"GoWeb/database/models"
"GoWeb/security"
"GoWeb/templating"
"net/http"
)
// GetController is a wrapper struct for the App struct
type GetController struct {
App *app.App
}
func (getController *GetController) ShowHome(w http.ResponseWriter, _ *http.Request) {
type dataStruct struct {
Test string
}
data := dataStruct{
Test: "Hello World!",
}
templating.RenderTemplate(getController.App, w, "templates/pages/home.html", data)
}
func (getController *GetController) ShowRegister(w http.ResponseWriter, r *http.Request) {
type dataStruct struct {
CsrfToken string
}
// Create csrf token
CsrfToken, err := security.GenerateCsrfToken(w, r)
if err != nil {
return
}
data := dataStruct{
CsrfToken: CsrfToken,
}
templating.RenderTemplate(getController.App, w, "templates/pages/register.html", data)
}
func (getController *GetController) ShowLogin(w http.ResponseWriter, r *http.Request) {
type dataStruct struct {
CsrfToken string
}
// Create csrf token
CsrfToken, err := security.GenerateCsrfToken(w, r)
if err != nil {
return
}
data := dataStruct{
CsrfToken: CsrfToken,
}
templating.RenderTemplate(getController.App, w, "templates/pages/login.html", data)
}
func (getController *GetController) Logout(w http.ResponseWriter, r *http.Request) {
models.LogoutUser(getController.App, w, r)
http.Redirect(w, r, "/", http.StatusFound)
}

52
controllers/post.go Normal file
View File

@ -0,0 +1,52 @@
package controllers
import (
"GoWeb/app"
"GoWeb/models"
"log/slog"
"net/http"
"time"
)
// Post is a wrapper struct for the App struct
type Post struct {
App *app.App
}
func (p *Post) Login(w http.ResponseWriter, r *http.Request) {
username := r.FormValue("username")
password := r.FormValue("password")
remember := r.FormValue("remember") == "on"
if username == "" || password == "" {
http.Redirect(w, r, "/login", http.StatusUnauthorized)
}
_, err := models.AuthenticateUser(p.App, w, username, password, remember)
if err != nil {
http.Redirect(w, r, "/login", http.StatusUnauthorized)
return
}
http.Redirect(w, r, "/", http.StatusFound)
}
func (p *Post) Register(w http.ResponseWriter, r *http.Request) {
username := r.FormValue("username")
password := r.FormValue("password")
createdAt := time.Now()
updatedAt := time.Now()
if username == "" || password == "" {
http.Redirect(w, r, "/register", http.StatusUnauthorized)
}
_, err := models.CreateUser(p.App, username, password, createdAt, updatedAt)
if err != nil {
// TODO: if err == bcrypt.ErrPasswordTooLong display error to user, this will require a flash message system with cookies
slog.Error("error creating user: " + err.Error())
http.Redirect(w, r, "/register", http.StatusInternalServerError)
}
http.Redirect(w, r, "/login", http.StatusFound)
}

View File

@ -1,70 +0,0 @@
package controllers
import (
"GoWeb/app"
"GoWeb/database/models"
"GoWeb/security"
"log"
"net/http"
"time"
)
// PostController is a wrapper struct for the App struct
type PostController struct {
App *app.App
}
func (postController *PostController) Login(w http.ResponseWriter, r *http.Request) {
// Validate csrf token
_, err := security.VerifyCsrfToken(r)
if err != nil {
log.Println("Error verifying csrf token")
return
}
username := r.FormValue("username")
password := r.FormValue("password")
if username == "" || password == "" {
log.Println("Tried to login user with empty username or password")
http.Redirect(w, r, "/login", http.StatusFound)
}
_, err = models.AuthenticateUser(postController.App, w, username, password)
if err != nil {
log.Println("Error authenticating user")
log.Println(err)
http.Redirect(w, r, "/login", http.StatusFound)
return
}
http.Redirect(w, r, "/", http.StatusFound)
}
func (postController *PostController) Register(w http.ResponseWriter, r *http.Request) {
// Validate csrf token
_, err := security.VerifyCsrfToken(r)
if err != nil {
log.Println("Error verifying csrf token")
return
}
username := r.FormValue("username")
password := r.FormValue("password")
createdAt := time.Now()
updatedAt := time.Now()
if username == "" || password == "" {
log.Println("Tried to create user with empty username or password")
http.Redirect(w, r, "/register", http.StatusFound)
}
_, err = models.CreateUser(postController.App, username, password, createdAt, updatedAt)
if err != nil {
log.Println("Error creating user")
log.Println(err)
return
}
http.Redirect(w, r, "/login", http.StatusFound)
}

View File

@ -4,31 +4,27 @@ import (
"GoWeb/app"
"database/sql"
"fmt"
"log"
_ "github.com/lib/pq"
"log/slog"
)
// ConnectDB returns a new database connection
func ConnectDB(app *app.App) *sql.DB {
// Set connection parameters from config
// Connect returns a new database connection
func Connect(app *app.App) *sql.DB {
postgresConfig := fmt.Sprintf("host=%s port=%s user=%s "+
"password=%s dbname=%s sslmode=disable",
app.Config.Db.Ip, app.Config.Db.Port, app.Config.Db.User, app.Config.Db.Password, app.Config.Db.Name)
// Create connection
db, err := sql.Open("postgres", postgresConfig)
if err != nil {
panic(err)
}
// Test connection
err = db.Ping()
if err != nil {
panic(err)
}
log.Println("Connected to database successfully on " + app.Config.Db.Ip + ":" + app.Config.Db.Port + " using database " + app.Config.Db.Name)
slog.Info("connected to database successfully on " + app.Config.Db.Ip + ":" + app.Config.Db.Port + " using database " + app.Config.Db.Name)
return db
}

119
database/migrate.go Normal file
View File

@ -0,0 +1,119 @@
package database
import (
"GoWeb/app"
"errors"
"fmt"
"github.com/lib/pq"
"log/slog"
"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
func Migrate(app *app.App, anyStruct interface{}) error {
valueOfStruct := reflect.ValueOf(anyStruct)
typeOfStruct := valueOfStruct.Type()
tableName := typeOfStruct.Name()
err := createTable(app, tableName)
if err != nil {
return err
}
for i := 0; i < valueOfStruct.NumField(); i++ {
fieldType := typeOfStruct.Field(i)
fieldName := fieldType.Name
if fieldName != "Id" && fieldName != "id" {
err := createColumn(app, tableName, fieldName, fieldType.Type.Name())
if err != nil {
return err
}
}
}
return nil
}
// createTable creates a table with the given name if it doesn't exist, it is assumed that id will be the primary key
func createTable(app *app.App, tableName string) error {
var tableExists bool
err := app.Db.QueryRow("SELECT EXISTS (SELECT 1 FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace WHERE c.relname ~ $1 AND pg_catalog.pg_table_is_visible(c.oid))", "^"+tableName+"$").Scan(&tableExists)
if err != nil {
slog.Error("error checking if table exists: " + tableName)
return err
}
if tableExists {
slog.Info("table already exists: " + tableName)
return nil
} else {
sanitizedTableQuery := fmt.Sprintf("CREATE TABLE IF NOT EXISTS \"%s\" (\"Id\" serial primary key)", tableName)
_, err := app.Db.Query(sanitizedTableQuery)
if err != nil {
slog.Error("error creating table: " + tableName)
return err
}
slog.Info("table created successfully: " + tableName)
return nil
}
}
// createColumn creates a column with the given name and type if it doesn't exist
func createColumn(app *app.App, tableName, columnName, columnType string) error {
var columnExists bool
err := app.Db.QueryRow("SELECT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = $1 AND column_name = $2)", tableName, columnName).Scan(&columnExists)
if err != nil {
slog.Error("error checking if column exists: " + columnName + " in table: " + tableName)
return err
}
if columnExists {
slog.Info("column already exists: " + columnName + " in table: " + tableName)
return nil
} else {
postgresType, err := getPostgresType(columnType)
if err != nil {
slog.Error("error creating column: " + columnName + " in table: " + tableName + " with type: " + postgresType)
return err
}
sanitizedTableName := pq.QuoteIdentifier(tableName)
query := fmt.Sprintf("ALTER TABLE %s ADD COLUMN IF NOT EXISTS \"%s\" %s", sanitizedTableName, columnName, postgresType)
_, err = app.Db.Query(query)
if err != nil {
slog.Error("error creating column: " + columnName + " in table: " + tableName + " with type: " + postgresType)
return err
}
slog.Info("column created successfully:", columnName)
return nil
}
}
// Given a type in Go, return the corresponding type in Postgres
func getPostgresType(goType string) (string, error) {
switch goType {
case "int", "int32", "uint", "uint32":
return "integer", nil
case "int64", "uint64":
return "bigint", nil
case "int16", "int8", "uint16", "uint8", "byte":
return "smallint", nil
case "string":
return "text", nil
case "float64":
return "double precision", nil
case "bool":
return "boolean", nil
case "Time":
return "timestamp", nil
case "[]byte":
return "bytea", nil
}
return "", errors.New("Unknown type: " + goType)
}

View File

@ -1,224 +0,0 @@
package models
import (
"GoWeb/app"
"crypto/rand"
"database/sql"
"encoding/hex"
"log"
"math"
"net/http"
"strconv"
"time"
"golang.org/x/crypto/bcrypt"
)
type User struct {
Id int64
Username string
Password string
CreatedAt string
UpdatedAt string
}
// GetCurrentUser finds the currently logged-in user by session cookie
func GetCurrentUser(app *app.App, r *http.Request) (User, error) {
cookie, err := r.Cookie("session")
if err != nil {
log.Println("Error getting session cookie")
log.Println(err)
return User{}, err
}
var userId int64
// Query row by session cookie
err = app.Db.QueryRow("SELECT user_id FROM sessions WHERE session = $1", cookie.Value).Scan(&userId)
if err != nil {
log.Println("Error querying session row with session: " + cookie.Value)
return User{}, err
}
return GetUserById(app, userId)
}
// GetUserById finds a users table row in the database by id and returns a struct representing this row
func GetUserById(app *app.App, id int64) (User, error) {
user := User{}
// Query row by id
row, err := app.Db.Query("SELECT id, username, password, created_at, updated_at FROM users WHERE id = $1", id)
if err != nil {
log.Println("Error querying user row with id: " + strconv.FormatInt(id, 10))
return User{}, err
}
defer func(row *sql.Rows) {
err := row.Close()
if err != nil {
log.Println("Error closing database row")
log.Println(err)
}
}(row)
// Feed row data into user struct
row.Next()
err = row.Scan(&user.Id, &user.Username, &user.Password, &user.CreatedAt, &user.UpdatedAt)
if err != nil {
log.Println("Error reading queried row from database")
log.Println(err)
return User{}, err
}
return user, nil
}
// CreateUser creates a users table row in the database
func CreateUser(app *app.App, username string, password string, createdAt time.Time, updatedAt time.Time) (User, error) {
// Hash password
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
log.Println("Error hashing password when creating user")
return User{}, err
}
var lastInsertId int64
sqlStatement := "INSERT INTO users (username, password, created_at, updated_at) VALUES ($1, $2, $3, $4) RETURNING id"
err = app.Db.QueryRow(sqlStatement, username, string(hash), createdAt, updatedAt).Scan(&lastInsertId)
if err != nil {
log.Println("Error creating user row")
log.Println(err)
return User{}, err
}
return GetUserById(app, lastInsertId)
}
// AuthenticateUser validates the password for the specified user if it matches a session cookie is created and returned
func AuthenticateUser(app *app.App, w http.ResponseWriter, username string, password string) (string, error) {
var hashedPassword []byte
// Query row by username, scan password column
err := app.Db.QueryRow("SELECT password FROM users WHERE username = $1", username).Scan(&hashedPassword)
if err != nil {
log.Println("Unable to find row with username: " + username)
log.Println(err)
return "", err
}
// Validate password
err = bcrypt.CompareHashAndPassword(hashedPassword, []byte(password))
if err != nil { // Failed to validate password, doesn't match
log.Println("Authentication error (incorrect password) for user:" + username)
log.Println(err)
return "", err
} else {
return createSessionCookie(app, w, username)
}
}
// createSessionCookie creates a new session token and cookie and returns the token value
func createSessionCookie(app *app.App, w http.ResponseWriter, username string) (string, error) {
// Generate random 64 character string (alpha-numeric)
buff := make([]byte, int(math.Ceil(float64(64)/2)))
_, err := rand.Read(buff)
if err != nil {
log.Println("Error creating random buffer for session token value")
log.Println(err)
return "", err
}
str := hex.EncodeToString(buff)
token := str[:64]
// If the auth_token column for any user matches the token, set existingAuthToken to true
var existingAuthToken bool
err = app.Db.QueryRow("SELECT EXISTS(SELECT 1 FROM users WHERE auth_token = $1)", token).Scan(&existingAuthToken)
if err != nil {
log.Println("Error checking for existing auth token")
log.Println(err)
return "", err
}
// If duplicate token found, recursively call function until unique token is generated
if existingAuthToken == true {
log.Println("Duplicate token found in sessions table")
return createSessionCookie(app, w, username)
}
// Store token in auth_token column of the users table
sqlStatement := "UPDATE users SET auth_token = $1 WHERE username = $2"
_, err = app.Db.Exec(sqlStatement, token, username)
if err != nil {
log.Println("Error setting auth_token column in users table")
log.Println(err)
return "", err
}
// Create session cookie, containing token
cookie := &http.Cookie{
Name: "session",
Value: token,
Path: "/",
MaxAge: 86400,
HttpOnly: true,
Secure: true,
}
http.SetCookie(w, cookie)
return token, nil
}
// ValidateSessionCookie validates the session cookie and returns the username of the user if valid
func ValidateSessionCookie(app *app.App, r *http.Request) (string, error) {
// Get cookie from request
cookie, err := r.Cookie("session")
if err != nil {
log.Println("Error getting cookie from request")
log.Println(err)
return "", err
}
// Query row by token
var username string
err = app.Db.QueryRow("SELECT username FROM users WHERE auth_token = $1", cookie.Value).Scan(&username)
if err != nil {
log.Println("Error querying row by token")
log.Println(err)
return "", err
}
return username, nil
}
// LogoutUser deletes the session cookie and token from the database
func LogoutUser(app *app.App, w http.ResponseWriter, r *http.Request) {
// Get cookie from request
cookie, err := r.Cookie("session")
if err != nil {
log.Println("Error getting cookie from request")
log.Println(err)
return
}
// Set token to empty string
sqlStatement := "UPDATE users SET auth_token = $1 WHERE auth_token = $2"
_, err = app.Db.Exec(sqlStatement, "", cookie.Value)
if err != nil {
log.Println("Error setting auth_token column in users table")
log.Println(err)
return
}
// Delete cookie
cookie = &http.Cookie{
Name: "session",
Value: "",
Path: "/",
MaxAge: -1,
}
http.SetCookie(w, cookie)
}

View File

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

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"

8
go.mod
View File

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

10
go.sum
View File

@ -1,4 +1,6 @@
github.com/lib/pq v1.10.7 h1:p7ZhMD+KsSRozJr34udlUrhboJwWAgCg34+/ZZNvZZw=
github.com/lib/pq v1.10.7/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
golang.org/x/crypto v0.1.0 h1:MDRAIl0xIo9Io2xV565hzXHw3zVseKrJKodhohM5CjU=
golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw=
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/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=

65
main.go
View File

@ -4,11 +4,16 @@ import (
"GoWeb/app"
"GoWeb/config"
"GoWeb/database"
"GoWeb/models"
"GoWeb/routes"
"context"
"embed"
"log"
"errors"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
@ -29,26 +34,62 @@ func main() {
if _, err := os.Stat("logs"); os.IsNotExist(err) {
err := os.Mkdir("logs", 0755)
if err != nil {
panic("Failed to create log directory")
panic("failed to create log directory: " + err.Error())
}
}
// Create log file and set output
file, err := os.OpenFile("logs/"+time.Now().Format("2006-01-02")+".log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
log.SetOutput(file)
file, err := os.OpenFile("logs/"+time.Now().Format("2006-01-02")+".log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
panic("error creating log file: " + err.Error())
}
// Connect to database
appLoaded.Db = database.ConnectDB(&appLoaded)
logger := slog.New(slog.NewTextHandler(file, nil))
slog.SetDefault(logger) // Set structured logger globally
// Connect to database and run migrations
appLoaded.Db = database.Connect(&appLoaded)
if appLoaded.Config.Db.AutoMigrate {
err = models.RunAllMigrations(&appLoaded)
if err != nil {
slog.Error("error running migrations: " + err.Error())
os.Exit(1)
}
}
// Assign and run scheduled tasks
appLoaded.ScheduledTasks = app.Scheduled{
EveryReboot: []func(app *app.App){models.ScheduledSessionCleanup},
EveryMinute: []func(app *app.App){models.ScheduledSessionCleanup},
}
// Define Routes
routes.GetRoutes(&appLoaded)
routes.PostRoutes(&appLoaded)
routes.Get(&appLoaded)
routes.Post(&appLoaded)
// Start server
log.Println("Starting server and listening on " + appLoaded.Config.Listen.Ip + ":" + appLoaded.Config.Listen.Port)
err = http.ListenAndServe(appLoaded.Config.Listen.Ip+":"+appLoaded.Config.Listen.Port, nil)
server := &http.Server{Addr: appLoaded.Config.Listen.Ip + ":" + appLoaded.Config.Listen.Port}
go func() {
slog.Info("starting server and listening on " + appLoaded.Config.Listen.Ip + ":" + appLoaded.Config.Listen.Port)
err := server.ListenAndServe()
if err != nil && !errors.Is(err, http.ErrServerClosed) {
slog.Error("could not listen on %s: %v\n", appLoaded.Config.Listen.Ip+":"+appLoaded.Config.Listen.Port, err)
os.Exit(1)
}
}()
// Wait for interrupt signal and shut down the server
interrupt := make(chan os.Signal, 1)
signal.Notify(interrupt, os.Interrupt, syscall.SIGTERM)
stop := make(chan struct{})
go app.RunScheduledTasks(&appLoaded, 100, stop)
<-interrupt
slog.Info("interrupt signal received. Shutting down server...")
err = server.Shutdown(context.Background())
if err != nil {
log.Println(err)
return
slog.Error("could not gracefully shutdown the server: %v\n", err)
os.Exit(1)
}
}

21
middleware/csrf.go Normal file
View File

@ -0,0 +1,21 @@
package middleware
import (
"GoWeb/security"
"log/slog"
"net/http"
)
// Csrf validates the CSRF token and returns the handler function if it succeeded
func Csrf(f func(w http.ResponseWriter, r *http.Request)) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
_, err := security.VerifyCsrfToken(r)
if err != nil {
slog.Info("error verifying csrf token")
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
f(w, r)
}
}

5
middleware/groups.go Normal file
View File

@ -0,0 +1,5 @@
package middleware
import "net/http"
type MiddlewareFunc func(f func(w http.ResponseWriter, r *http.Request)) func(w http.ResponseWriter, r *http.Request)

14
middleware/wrapper.go Normal file
View File

@ -0,0 +1,14 @@
package middleware
import "net/http"
// ProcessGroup is a wrapper function for the http.HandleFunc function
// that takes the function you want to execute (f) and the middleware you want
// to execute (m) this should be used when processing multiple groups of middleware at a time
func ProcessGroup(f func(w http.ResponseWriter, r *http.Request), m []MiddlewareFunc) func(w http.ResponseWriter, r *http.Request) {
for _, middleware := range m {
_ = middleware(f)
}
return f
}

37
models/migrations.go Normal file
View File

@ -0,0 +1,37 @@
package models
import (
"GoWeb/app"
"GoWeb/database"
"time"
)
// RunAllMigrations defines the structs that should be represented in the database
func RunAllMigrations(app *app.App) error {
// Declare new dummy user for reflection
user := User{
Id: 1, // Id is handled automatically, but it is added here to show it will be skipped during column creation
Username: "migrate",
Password: "migrate",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
err := database.Migrate(app, user)
if err != nil {
return err
}
session := Session{
Id: 1,
UserId: 1,
AuthToken: "migrate",
RememberMe: false,
CreatedAt: time.Now(),
}
err = database.Migrate(app, session)
if err != nil {
return err
}
return nil
}

153
models/session.go Normal file
View File

@ -0,0 +1,153 @@
package models
import (
"GoWeb/app"
"crypto/rand"
"encoding/hex"
"log/slog"
"net/http"
"time"
)
type Session struct {
Id int64
UserId int64
AuthToken string
RememberMe bool
CreatedAt time.Time
}
const sessionColumnsNoId = "\"UserId\", \"AuthToken\",\"RememberMe\", \"CreatedAt\""
const sessionColumns = "\"Id\", " + sessionColumnsNoId
const sessionTable = "public.\"Session\""
const (
selectSessionByAuthToken = "SELECT " + sessionColumns + " FROM " + sessionTable + " WHERE \"AuthToken\" = $1"
selectAuthTokenIfExists = "SELECT EXISTS(SELECT 1 FROM " + sessionTable + " WHERE \"AuthToken\" = $1)"
insertSession = "INSERT INTO " + sessionTable + " (" + sessionColumnsNoId + ") VALUES ($1, $2, $3, $4) RETURNING \"Id\""
deleteSessionByAuthToken = "DELETE FROM " + sessionTable + " WHERE \"AuthToken\" = $1"
deleteSessionsOlderThan30Days = "DELETE FROM " + sessionTable + " WHERE \"CreatedAt\" < NOW() - INTERVAL '30 days'"
deleteSessionsOlderThan6Hours = "DELETE FROM " + sessionTable + " WHERE \"CreatedAt\" < NOW() - INTERVAL '6 hours' AND \"RememberMe\" = false"
)
// CreateSession creates a new session for a user
func CreateSession(app *app.App, w http.ResponseWriter, userId int64, remember bool) (Session, error) {
session := Session{}
session.UserId = userId
session.AuthToken = generateAuthToken(app)
session.RememberMe = remember
session.CreatedAt = time.Now()
// If the AuthToken column for any user matches the token, set existingAuthToken to true
var existingAuthToken bool
err := app.Db.QueryRow(selectAuthTokenIfExists, session.AuthToken).Scan(&existingAuthToken)
if err != nil {
slog.Error("error checking for existing auth token" + err.Error())
return Session{}, err
}
// If duplicate token found, recursively call function until unique token is generated
if existingAuthToken {
slog.Warn("duplicate token found in sessions table, generating new token...")
return CreateSession(app, w, userId, remember)
}
err = app.Db.QueryRow(insertSession, session.UserId, session.AuthToken, session.RememberMe, session.CreatedAt).Scan(&session.Id)
if err != nil {
slog.Error("error inserting session into database")
return Session{}, err
}
createSessionCookie(app, w, session)
return session, nil
}
func GetSessionByAuthToken(app *app.App, authToken string) (Session, error) {
session := Session{}
err := app.Db.QueryRow(selectSessionByAuthToken, authToken).Scan(&session.Id, &session.UserId, &session.AuthToken, &session.RememberMe, &session.CreatedAt)
if err != nil {
return Session{}, err
}
return session, nil
}
// generateAuthToken generates a random 64-byte string
func generateAuthToken(app *app.App) string {
b := make([]byte, 64)
_, err := rand.Read(b)
if err != nil {
slog.Error("error generating random bytes for auth token")
}
return hex.EncodeToString(b)
}
// createSessionCookie creates a new session cookie
func createSessionCookie(app *app.App, w http.ResponseWriter, session Session) {
cookie := &http.Cookie{}
if session.RememberMe {
cookie = &http.Cookie{
Name: "session",
Value: session.AuthToken,
Path: "/",
MaxAge: 2592000 * 1000, // 30 days in ms
HttpOnly: true,
Secure: true,
}
} else {
cookie = &http.Cookie{
Name: "session",
Value: session.AuthToken,
Path: "/",
MaxAge: 21600 * 1000, // 6 hours in ms
HttpOnly: true,
Secure: true,
}
}
http.SetCookie(w, cookie)
}
// deleteSessionCookie deletes the session cookie
func deleteSessionCookie(app *app.App, w http.ResponseWriter) {
cookie := &http.Cookie{
Name: "session",
Value: "",
Path: "/",
MaxAge: -1,
}
http.SetCookie(w, cookie)
}
// DeleteSessionByAuthToken deletes a session from the database by AuthToken
func DeleteSessionByAuthToken(app *app.App, w http.ResponseWriter, authToken string) error {
_, err := app.Db.Exec(deleteSessionByAuthToken, authToken)
if err != nil {
slog.Error("error deleting session from database")
return err
}
deleteSessionCookie(app, w)
return nil
}
// ScheduledSessionCleanup deletes expired sessions from the database
func ScheduledSessionCleanup(app *app.App) {
// Delete sessions older than 30 days (remember me sessions)
_, err := app.Db.Exec(deleteSessionsOlderThan30Days)
if err != nil {
slog.Error("error deleting 30 day expired sessions from database" + err.Error())
}
// Delete sessions older than 6 hours
_, err = app.Db.Exec(deleteSessionsOlderThan6Hours)
if err != nil {
slog.Error("error deleting 6 hour expired sessions from database" + err.Error())
}
slog.Info("deleted expired sessions from database")
}

118
models/user.go Normal file
View File

@ -0,0 +1,118 @@
package models
import (
"GoWeb/app"
"log/slog"
"net/http"
"time"
"golang.org/x/crypto/bcrypt"
)
type User struct {
Id int64
Username string
Password string
CreatedAt time.Time
UpdatedAt time.Time
}
const userColumnsNoId = "\"Username\", \"Password\", \"CreatedAt\", \"UpdatedAt\""
const userColumns = "\"Id\", " + userColumnsNoId
const userTable = "public.\"User\""
const (
selectUserById = "SELECT " + userColumns + " FROM " + userTable + " WHERE \"Id\" = $1"
selectUserByUsername = "SELECT " + userColumns + " FROM " + userTable + " WHERE \"Username\" = $1"
insertUser = "INSERT INTO " + userTable + " (" + userColumnsNoId + ") VALUES ($1, $2, $3, $4) RETURNING \"Id\""
)
// GetCurrentUser finds the currently logged-in user by session cookie
func GetCurrentUser(app *app.App, r *http.Request) (User, error) {
cookie, err := r.Cookie("session")
if err != nil {
return User{}, err
}
session, err := GetSessionByAuthToken(app, cookie.Value)
if err != nil {
return User{}, err
}
return GetUserById(app, session.UserId)
}
// GetUserById finds a User table row in the database by id and returns a struct representing this row
func GetUserById(app *app.App, id int64) (User, error) {
user := User{}
err := app.Db.QueryRow(selectUserById, id).Scan(&user.Id, &user.Username, &user.Password, &user.CreatedAt, &user.UpdatedAt)
if err != nil {
return User{}, err
}
return user, nil
}
// GetUserByUsername finds a User table row in the database by username and returns a struct representing this row
func GetUserByUsername(app *app.App, username string) (User, error) {
user := User{}
err := app.Db.QueryRow(selectUserByUsername, username).Scan(&user.Id, &user.Username, &user.Password, &user.CreatedAt, &user.UpdatedAt)
if err != nil {
return User{}, err
}
return user, nil
}
// 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) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
slog.Error("error hashing password: " + err.Error())
return User{}, err
}
var lastInsertId int64
err = app.Db.QueryRow(insertUser, username, string(hash), createdAt, updatedAt).Scan(&lastInsertId)
if err != nil {
slog.Error("error creating user row: " + err.Error())
return User{}, err
}
return GetUserById(app, lastInsertId)
}
// AuthenticateUser validates the password for the specified user
func AuthenticateUser(app *app.App, w http.ResponseWriter, username string, password string, remember bool) (Session, error) {
var user User
err := app.Db.QueryRow(selectUserByUsername, username).Scan(&user.Id, &user.Username, &user.Password, &user.CreatedAt, &user.UpdatedAt)
if err != nil {
slog.Info("user not found: " + username)
return Session{}, err
}
err = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password))
if err != nil { // Failed to validate password, doesn't match
slog.Info("incorrect password:" + username)
return Session{}, err
} else {
return CreateSession(app, w, user.Id, remember)
}
}
// LogoutUser deletes the session cookie and AuthToken from the database
func LogoutUser(app *app.App, w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("session")
if err != nil {
return
}
err = DeleteSessionByAuthToken(app, w, cookie.Value)
if err != nil {
return
}
}

65
restclient/client.go Normal file
View File

@ -0,0 +1,65 @@
package restclient
import (
"bytes"
"encoding/json"
"mime/multipart"
"net/http"
)
// SendRequest sends an HTTP request to a URL and includes the specified headers and body.
// A body can be nil for GET requests, a map[string]string for multipart/form-data requests,
// or a struct for JSON requests
func SendRequest(url string, method string, headers map[string]string, body interface{}) (http.Response, error) {
var reqBody *bytes.Buffer
var contentType string
switch v := body.(type) {
case nil:
reqBody = bytes.NewBuffer([]byte(""))
case map[string]string:
reqBody = &bytes.Buffer{}
writer := multipart.NewWriter(reqBody)
for key, value := range v {
err := writer.WriteField(key, value)
if err != nil {
return http.Response{}, err
}
}
err := writer.Close()
if err != nil {
return http.Response{}, err
}
contentType = writer.FormDataContentType()
default:
jsonBody, err := json.Marshal(body)
if err != nil {
return http.Response{}, err
}
reqBody = bytes.NewBuffer(jsonBody)
contentType = "application/json"
}
req, err := http.NewRequest(method, url, reqBody)
if err != nil {
return http.Response{}, err
}
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
for key, value := range headers {
req.Header.Add(key, value)
}
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return http.Response{}, err
}
return *resp, nil
}

33
routes/get.go Normal file
View File

@ -0,0 +1,33 @@
package routes
import (
"GoWeb/app"
"GoWeb/controllers"
"io/fs"
"log/slog"
"net/http"
)
// Get defines all project get routes
func Get(app *app.App) {
// Get controller struct initialize
getController := controllers.Get{
App: app,
}
// Serve static files
staticFS, err := fs.Sub(app.Res, "static")
if err != nil {
slog.Error(err.Error())
return
}
staticHandler := http.FileServer(http.FS(staticFS))
http.Handle("/static/", http.StripPrefix("/static/", staticHandler))
slog.Info("serving static files from embedded file system /static")
// Pages
http.HandleFunc("/", getController.ShowHome)
http.HandleFunc("/login", getController.ShowLogin)
http.HandleFunc("/register", getController.ShowRegister)
http.HandleFunc("/logout", getController.Logout)
}

View File

@ -1,26 +0,0 @@
package routes
import (
"GoWeb/app"
"GoWeb/controllers"
"log"
"net/http"
)
// GetRoutes defines all project get routes
func GetRoutes(app *app.App) {
// Get controller struct initialize
getController := controllers.GetController{
App: app,
}
// Serve static files
http.Handle("/file/", http.FileServer(http.Dir("./static")))
log.Println("Serving static files from: ./static")
// Pages
http.HandleFunc("/", getController.ShowHome)
http.HandleFunc("/login", getController.ShowLogin)
http.HandleFunc("/register", getController.ShowRegister)
http.HandleFunc("/logout", getController.Logout)
}

20
routes/post.go Normal file
View File

@ -0,0 +1,20 @@
package routes
import (
"GoWeb/app"
"GoWeb/controllers"
"GoWeb/middleware"
"net/http"
)
// Post defines all project post routes
func Post(app *app.App) {
// Post controller struct initialize
postController := controllers.Post{
App: app,
}
// User authentication
http.HandleFunc("/register-handle", middleware.Csrf(postController.Register))
http.HandleFunc("/login-handle", middleware.Csrf(postController.Login))
}

View File

@ -1,19 +0,0 @@
package routes
import (
"GoWeb/app"
"GoWeb/controllers"
"net/http"
)
// PostRoutes defines all project post routes
func PostRoutes(app *app.App) {
// Post controller struct initialize
postController := controllers.PostController{
App: app,
}
// User authentication
http.HandleFunc("/register-handle", postController.Register)
http.HandleFunc("/login-handle", postController.Login)
}

View File

@ -3,25 +3,22 @@ package security
import (
"crypto/rand"
"encoding/hex"
"log"
"log/slog"
"math"
"net/http"
)
// GenerateCsrfToken generates a csrf token and assigns it to a cookie for double submit cookie csrf protection
func GenerateCsrfToken(w http.ResponseWriter, _ *http.Request) (string, error) {
// Generate random 64 character string (alpha-numeric)
buff := make([]byte, int(math.Ceil(float64(64)/2)))
_, err := rand.Read(buff)
if err != nil {
log.Println("Error creating random buffer for csrf token value")
log.Println(err)
slog.Error("error creating random buffer for csrf token value" + err.Error())
return "", err
}
str := hex.EncodeToString(buff)
token := str[:64]
// Create session cookie, containing token
cookie := &http.Cookie{
Name: "csrf_token",
Value: token,
@ -38,18 +35,14 @@ func GenerateCsrfToken(w http.ResponseWriter, _ *http.Request) (string, error) {
// VerifyCsrfToken verifies the csrf token
func VerifyCsrfToken(r *http.Request) (bool, error) {
// Get csrf cookie
cookie, err := r.Cookie("csrf_token")
if err != nil {
log.Println("Error getting csrf_token cookie")
log.Println(err)
slog.Info("unable to get csrf_token cookie" + err.Error())
return false, err
}
// Get csrf token from form
token := r.FormValue("csrf_token")
// Compare csrf cookie and csrf token
if cookie.Value == token {
return true, nil
}

75
static/css/style.css Normal file
View File

@ -0,0 +1,75 @@
body {
font-family: Arial, sans-serif;
background-color: lightblue;
color: #333;
margin: 0;
}
.container {
display: flex;
justify-content: center;
align-items: center;
width: 80%;
padding: 20px;
margin: 0 auto;
}
.footer-container {
display: flex;
justify-content: center;
align-items: center;
height: 80px;
background-color: lightblue;
}
footer {
color: #0077be;
font-size: 14px;
}
form label {
display: block;
font-weight: bold;
margin-bottom: 5px;
}
form input[type="text"],
form input[type="password"] {
padding: 10px;
font-size: 16px;
border-radius: 5px;
border: none;
margin-bottom: 10px;
width: 100%;
box-sizing: border-box;
}
form input[type="submit"] {
display: inline-block;
padding: 10px 20px;
background-color: #0077be;
color: #fff;
border-radius: 5px;
text-decoration: none;
border: none;
cursor: pointer;
}
form input[type="submit"]:hover {
background-color: #005fa3;
}
h1, h2, h3, h4, h5, h6 {
font-weight: bold;
color: #333;
text-align: center;
}
a {
color: #0077be;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}

View File

@ -3,11 +3,14 @@
<head>
<meta charset="UTF-8">
<title>SiteName - {{ template "pageTitle" }}</title>
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
{{ template "content" . }}
<div class="footer-container">
<footer>
<p>SiteName - Powered by GoWeb!</p>
</footer>
</div>
</body>
<footer>
<p>SiteName - Powered by Go!</p>
</footer>
</html>
</html>

View File

@ -1,13 +1,18 @@
{{ define "pageTitle" }}Login{{ end }}
{{ define "content" }}
<form action="/login-handle" method="post">
<input name="csrf_token" type="hidden" value="{{ .CsrfToken }}">
<h1>Login</h1>
<div class="container">
<form action="/login-handle" method="post">
<input name="csrf_token" type="hidden" value="{{ .CsrfToken }}">
<label for="username">Username:</label><br>
<input id="username" name="username" type="text" value="John"><br><br>
<label for="password">Password:</label><br>
<input id="password" name="password" type="password"><br><br>
<input type="submit" value="Submit">
</form>
<label for="username">Username:</label><br>
<input id="username" name="username" type="text" placeholder="John"><br><br>
<label for="password">Password:</label><br>
<input id="password" name="password" type="password"><br><br>
<label for="remember">Remember Me:</label>
<input id="remember" type="checkbox" name="remember"><br><br>
<input type="submit" value="Submit">
</form>
</div>
{{ end }}

View File

@ -1,13 +1,16 @@
{{ define "pageTitle" }}Register{{ end }}
{{ define "content" }}
<form action="/register-handle" method="post">
<input name="csrf_token" type="hidden" value="{{ .CsrfToken }}">
<h1>Register</h1>
<div class="container">
<form action="/register-handle" method="post">
<input name="csrf_token" type="hidden" value="{{ .CsrfToken }}">
<label for="username">Username:</label><br>
<input id="username" name="username" type="text" value="John"><br><br>
<label for="password">Password:</label><br>
<input id="password" name="password" type="password"><br><br>
<input type="submit" value="Submit">
</form>
<label for="username">Username:</label><br>
<input id="username" name="username" type="text" placeholder="John"><br><br>
<label for="password">Password:</label><br>
<input id="password" name="password" type="password"><br><br>
<input type="submit" value="Submit">
</form>
</div>
{{ end }}

View File

@ -3,7 +3,7 @@ package templating
import (
"GoWeb/app"
"html/template"
"log"
"log/slog"
"net/http"
)
@ -13,35 +13,35 @@ func RenderTemplate(app *app.App, w http.ResponseWriter, contentPath string, dat
templateContent, err := app.Res.ReadFile(templatePath)
if err != nil {
log.Println(err)
slog.Error(err.Error())
http.Error(w, err.Error(), 500)
return
}
t, err := template.New(templatePath).Parse(string(templateContent))
if err != nil {
log.Println(err)
slog.Error(err.Error())
http.Error(w, err.Error(), 500)
return
}
content, err := app.Res.ReadFile(contentPath)
if err != nil {
log.Println(err)
slog.Error(err.Error())
http.Error(w, err.Error(), 500)
return
}
t, err = t.Parse(string(content))
if err != nil {
log.Println(err)
slog.Error(err.Error())
http.Error(w, err.Error(), 500)
return
}
err = t.Execute(w, data)
if err != nil {
log.Println(err)
slog.Error(err.Error())
http.Error(w, err.Error(), 500)
return
}