├── static
└── css
│ └── input.css
├── tailwind.config.js
├── go.mod
├── package.json
├── .gitignore
├── migrations
└── 001_create_users_table.sql
├── internal
├── auth
│ ├── token.go
│ └── middleware.go
├── templates
│ ├── edit_user_form.templ
│ ├── components.templ
│ ├── users.templ
│ ├── login.templ
│ ├── error_components.templ
│ ├── layout.templ
│ ├── userdetails.templ
│ ├── home.templ
│ ├── profile.templ
│ └── register.templ
├── models
│ └── user.go
├── app
│ └── app.go
├── routes
│ └── routes.go
└── handlers
│ ├── authentication.go
│ └── handlers.go
├── .air.toml
├── go.sum
├── cmd
└── server
│ └── main.go
├── README.md
└── Makefile copy
/static/css/input.css:
--------------------------------------------------------------------------------
1 | @import "tailwindcss";
2 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | content: ["./internal/templates/**/*.{html,templ,go}"],
4 | theme: {
5 | extend: {},
6 | },
7 | plugins: [],
8 | }
9 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/lordaris/gotth-boilerplate
2 |
3 | go 1.23.6
4 |
5 | require (
6 | github.com/a-h/templ v0.3.833
7 | github.com/go-chi/chi/v5 v5.2.1
8 | github.com/jmoiron/sqlx v1.4.0
9 | github.com/joho/godotenv v1.5.1
10 | github.com/lib/pq v1.10.9
11 | golang.org/x/crypto v0.35.0
12 | )
13 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "gotth-boilerplate",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "index.js",
6 | "scripts": {
7 | "test": "echo \"Error: no test specified\" && exit 1"
8 | },
9 | "keywords": [],
10 | "author": "",
11 | "license": "ISC",
12 | "type": "commonjs",
13 | "dependencies": {
14 | "@tailwindcss/cli": "^4.0.7",
15 | "tailwindcss": "^4.0.7"
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Binaries and temporaries
2 | /tmp/
3 | *.exe
4 | *.exe~
5 | *.dll
6 | *.so
7 | *.dylib
8 | Makefile
9 | .env
10 |
11 | # Test output
12 | *.test
13 | *.out
14 |
15 | # Dependencies
16 | /vendor/
17 | /node_modules/
18 |
19 | # Generated files
20 | /static/css/output.css
21 | *_templ.go
22 |
23 | # Logs
24 | *.log
25 |
26 | # Editor configuration
27 | .vscode/
28 | .idea/
29 |
30 | # System files
31 | .DS_Store
32 |
--------------------------------------------------------------------------------
/migrations/001_create_users_table.sql:
--------------------------------------------------------------------------------
1 | -- Up
2 | CREATE TABLE IF NOT EXISTS users (
3 | id SERIAL PRIMARY KEY,
4 | username VARCHAR(100) NOT NULL,
5 | password_hash VARCHAR(100) NOT NULL,
6 | created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
7 | );
8 |
9 | CREATE TABLE IF NOT EXISTS tokens (
10 | id SERIAL PRIMARY KEY,
11 | user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
12 | token_hash BYTEA NOT NULL,
13 | plaintext_token VARCHAR(255) NOT NULL,
14 | expiry TIMESTAMP WITH TIME ZONE NOT NULL,
15 | created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
16 | );
17 |
18 | CREATE INDEX IF NOT EXISTS tokens_token_hash_idx ON tokens(token_hash);
19 | CREATE INDEX IF NOT EXISTS tokens_user_id_idx ON tokens(user_id);
20 |
21 | -- Down
22 | --DROP TABLE IF EXISTS tokens;
23 | --DROP TABLE IF EXISTS users;
24 |
25 | -- INSERT INTO users (name, email) VALUES
26 | -- ('User 1', 'user1@example.com'),
27 | -- ('User 2', 'user2@example.com'),
28 | -- ('User 3', 'user3@example.com');
29 |
30 |
--------------------------------------------------------------------------------
/internal/auth/token.go:
--------------------------------------------------------------------------------
1 | package auth
2 |
3 | import (
4 | "crypto/rand"
5 | "crypto/sha256"
6 | "encoding/base64"
7 | "errors"
8 | "time"
9 | )
10 |
11 | const (
12 | TokenLength = 32 // Length in bytes
13 | )
14 |
15 | func GenerateToken(expiry time.Duration) (string, []byte, time.Time, error) {
16 | tokenBytes := make([]byte, TokenLength)
17 |
18 | _, err := rand.Read(tokenBytes)
19 | if err != nil {
20 | return "", nil, time.Time{}, err
21 | }
22 |
23 | plaintextToken := base64.URLEncoding.EncodeToString(tokenBytes)
24 |
25 | // Hash the token
26 | hash := sha256.Sum256([]byte(plaintextToken))
27 | tokenHash := hash[:]
28 |
29 | expiryTime := time.Now().Add(expiry)
30 |
31 | return plaintextToken, tokenHash, expiryTime, nil
32 | }
33 |
34 | func ValidateTokenPlaintext(plaintextToken string) error {
35 | _, err := base64.URLEncoding.DecodeString(plaintextToken)
36 | if err != nil {
37 | return errors.New("invalid token format")
38 | }
39 | return nil
40 | }
41 |
42 | func HashToken(plaintextToken string) []byte {
43 | hash := sha256.Sum256([]byte(plaintextToken))
44 | return hash[:]
45 | }
46 |
--------------------------------------------------------------------------------
/.air.toml:
--------------------------------------------------------------------------------
1 | root = "."
2 | testdata_dir = "testdata"
3 | tmp_dir = "tmp"
4 |
5 | [build]
6 | args_bin = []
7 | bin = "./tmp/main"
8 | cmd = "templ generate && go build -o ./tmp/main ./cmd/server"
9 | delay = 1000
10 | exclude_dir = ["assets", "tmp", "vendor", "testdata", "node_modules"]
11 | exclude_file = []
12 | exclude_regex = ["_test.go", "_templ\\.go$"]
13 | exclude_unchanged = false
14 | follow_symlink = false
15 | full_bin = ""
16 | include_dir = []
17 | include_ext = ["go", "tpl", "tmpl", "html", "templ"]
18 | include_file = []
19 | kill_delay = "0s"
20 | log = "build-errors.log"
21 | poll = false
22 | poll_interval = 0
23 | post_cmd = []
24 | pre_cmd = []
25 | rerun = false
26 | rerun_delay = 500
27 | send_interrupt = false
28 | stop_on_error = false
29 |
30 | [color]
31 | app = ""
32 | build = "yellow"
33 | main = "magenta"
34 | runner = "green"
35 | watcher = "cyan"
36 |
37 | [log]
38 | main_only = false
39 | silent = false
40 | time = false
41 |
42 | [misc]
43 | clean_on_exit = false
44 |
45 | [proxy]
46 | app_port = 0
47 | enabled = false
48 | proxy_port = 0
49 |
50 | [screen]
51 | clear_on_rebuild = true
52 | keep_scroll = true
53 |
--------------------------------------------------------------------------------
/internal/templates/edit_user_form.templ:
--------------------------------------------------------------------------------
1 | package templates
2 |
3 | import (
4 | "fmt"
5 | "github.com/lordaris/gotth-boilerplate/internal/models"
6 | )
7 |
8 | templ EditUserForm(user models.User) {
9 | @Layout(fmt.Sprintf("Edit User: %s", user.Username)) {
10 |
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/internal/templates/components.templ:
--------------------------------------------------------------------------------
1 | package templates
2 |
3 | templ Alert(alertType string, message string) {
4 |
7 | }
8 |
9 | templ FormInput(id string, name string, label string, inputType string, required bool, value string) {
10 |
11 |
12 |
13 |
14 | }
15 |
16 | templ Button(buttonType string, text string, classes string) {
17 |
20 | }
21 |
22 | templ LoadingSpinner() {
23 |
26 | }
27 |
--------------------------------------------------------------------------------
/internal/models/user.go:
--------------------------------------------------------------------------------
1 | package models
2 |
3 | import (
4 | "errors"
5 | "time"
6 |
7 | "golang.org/x/crypto/bcrypt"
8 | )
9 |
10 | type User struct {
11 | ID int `db:"id" json:"id"`
12 | Username string `db:"username" json:"username"`
13 | PasswordHash string `db:"password_hash" json:"-"`
14 | CreatedAt time.Time `db:"created_at" json:"created_at"`
15 | }
16 |
17 | type Token struct {
18 | ID int `db:"id" json:"-"`
19 | UserID int `db:"user_id" json:"-"`
20 | TokenHash []byte `db:"token_hash" json:"-"`
21 | PlaintextToken string `db:"plaintext_token" json:"-"`
22 | Expiry time.Time `db:"expiry" json:"expiry"`
23 | CreatedAt time.Time `db:"created_at" json:"-"`
24 | }
25 |
26 | func (u *User) SetPassword(password string) error {
27 | if len(password) < 8 {
28 | return errors.New("password must be at least 8 characters long")
29 | }
30 |
31 | hash, err := bcrypt.GenerateFromPassword([]byte(password), 12)
32 | if err != nil {
33 | return err
34 | }
35 |
36 | u.PasswordHash = string(hash)
37 | return nil
38 | }
39 |
40 | func (u *User) CheckPassword(password string) (bool, error) {
41 | err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password))
42 | if err != nil {
43 | if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) {
44 | return false, nil
45 | }
46 | return false, err
47 | }
48 | return true, nil
49 | }
50 |
--------------------------------------------------------------------------------
/internal/templates/users.templ:
--------------------------------------------------------------------------------
1 | package templates
2 |
3 | import (
4 | "fmt"
5 | "github.com/lordaris/gotth-boilerplate/internal/models"
6 | )
7 |
8 | templ UsersList(users []models.User) {
9 |
10 |
Users List
11 | if len(users) == 0 {
12 |
15 | } else {
16 |
17 | for _, user := range users {
18 |
19 |
20 |
21 | { string([]rune(user.Username)[0]) }
22 |
23 |
24 |
{ user.Username}
25 |
26 |
27 |
28 |
33 |
34 |
35 | }
36 |
37 |
38 |
Select a user to view details
39 |
40 | }
41 |
42 | }
43 |
--------------------------------------------------------------------------------
/internal/app/app.go:
--------------------------------------------------------------------------------
1 | package app
2 |
3 | import (
4 | "fmt"
5 | "log"
6 |
7 | "github.com/jmoiron/sqlx"
8 | _ "github.com/lib/pq"
9 | )
10 |
11 | type Config struct {
12 | DB struct {
13 | Host string
14 | Port string
15 | User string
16 | Password string
17 | Name string
18 | }
19 | Server struct {
20 | Port string
21 | }
22 | }
23 |
24 | // Application holds dependencies and configuration
25 | type Application struct {
26 | Config Config
27 | DB *sqlx.DB
28 | }
29 |
30 | // NewApplication creates a new Application instance
31 | func NewApplication(cfg Config) *Application {
32 | return &Application{
33 | Config: cfg,
34 | }
35 | }
36 |
37 | // ConnectToDatabase establishes connection to PostgreSQL
38 | func (app *Application) ConnectToDatabase() error {
39 | dsn := fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%s sslmode=disable TimeZone=UTC",
40 | app.Config.DB.Host,
41 | app.Config.DB.User,
42 | app.Config.DB.Password,
43 | app.Config.DB.Name,
44 | app.Config.DB.Port,
45 | )
46 | log.Printf("Connecting to database: %s@%s:%s/%s",
47 | app.Config.DB.User,
48 | app.Config.DB.Host,
49 | app.Config.DB.Port,
50 | app.Config.DB.Name)
51 |
52 | db, err := sqlx.Connect("postgres", dsn)
53 | if err != nil {
54 | return err
55 | }
56 |
57 | // Test the connection
58 | err = db.Ping()
59 | if err != nil {
60 | return err
61 | }
62 |
63 | app.DB = db
64 | log.Println("Successfully connected to database")
65 | return nil
66 | }
67 |
--------------------------------------------------------------------------------
/internal/templates/login.templ:
--------------------------------------------------------------------------------
1 | package templates
2 |
3 | templ LoginPage() {
4 | @Layout("Login") {
5 |
6 |
Login
7 |
37 |
38 |
39 | Not registered? Sign up
40 |
41 |
42 |
43 | }
44 | }
45 |
46 | templ LoginError(message string) {
47 |
50 | }
51 |
52 |
53 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
2 | filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
3 | github.com/a-h/templ v0.3.833 h1:L/KOk/0VvVTBegtE0fp2RJQiBm7/52Zxv5fqlEHiQUU=
4 | github.com/a-h/templ v0.3.833/go.mod h1:cAu4AiZhtJfBjMY0HASlyzvkrtjnHWPeEsyGK2YYmfk=
5 | github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
6 | github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
7 | github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
8 | github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
9 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
10 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
11 | github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
12 | github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
13 | github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
14 | github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
15 | github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
16 | github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
17 | github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
18 | github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
19 | golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
20 | golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
21 |
--------------------------------------------------------------------------------
/internal/templates/error_components.templ:
--------------------------------------------------------------------------------
1 | package templates
2 |
3 | import "fmt"
4 |
5 | templ Error404() {
6 | @Layout("404 not found") {
7 |
8 |
404
9 |
Not found
10 |
The page you're trying to reach doesn't exist or it has been moved
11 |
15 | Go back to main
16 |
17 |
18 | }
19 | }
20 |
21 | templ Error500() {
22 | @Layout("Server error") {
23 |
24 |
500
25 |
Server error
26 |
27 | I'm sorry, there have been an error in the server. Please try again later.
28 |
29 |
33 | Go back to main
34 |
35 |
36 | }
37 | }
38 |
39 | templ ErrorPage(statusCode int, message string) {
40 | @Layout("Error") {
41 |
42 |
{ fmt.Sprint(statusCode) }
43 |
Error
44 |
{ message }
45 |
49 | Volver al inicio
50 |
51 |
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/internal/templates/layout.templ:
--------------------------------------------------------------------------------
1 | package templates
2 |
3 | import "github.com/lordaris/gotth-boilerplate/internal/auth"
4 |
5 | templ Layout(title string) {
6 |
7 |
8 |
9 |
10 |
11 |
12 | { title }
13 |
14 |
15 |
16 |
17 |
18 |
19 | @Navbar()
20 |
21 | { children... }
22 |
23 | @Footer()
24 |
25 |
26 |
27 |
28 | }
29 |
30 | templ Navbar() {
31 |
48 | }
49 |
50 | templ Footer() {
51 |
56 | }
57 |
--------------------------------------------------------------------------------
/cmd/server/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "flag"
5 | "fmt"
6 | "log"
7 | "net/http"
8 | "os"
9 |
10 | "github.com/lordaris/gotth-boilerplate/internal/app"
11 | "github.com/lordaris/gotth-boilerplate/internal/routes"
12 |
13 | "github.com/joho/godotenv"
14 | )
15 |
16 | func main() {
17 | // Load environment variables from .env file
18 | err := godotenv.Load(".env")
19 | if err != nil {
20 | log.Println("Warning: .env file not found, using environment variables")
21 | }
22 |
23 | // Setup configuration
24 | var cfg app.Config
25 | flag.StringVar(&cfg.DB.Host, "db-host", getEnvOrDefault("DB_HOST", "localhost"), "PostgreSQL host")
26 | flag.StringVar(&cfg.DB.Port, "db-port", getEnvOrDefault("DB_PORT", "5432"), "PostgreSQL port")
27 | flag.StringVar(&cfg.DB.User, "db-user", getEnvOrDefault("DB_USER", "postgres"), "PostgreSQL user")
28 | flag.StringVar(&cfg.DB.Password, "db-password", getEnvOrDefault("DB_PASSWORD", "postgres"), "PostgreSQL password")
29 | flag.StringVar(&cfg.DB.Name, "db-name", getEnvOrDefault("DB_NAME", "gotth-boilerplate"), "PostgreSQL database name")
30 | flag.StringVar(&cfg.Server.Port, "port", getEnvOrDefault("PORT", "8080"), "Server port")
31 | flag.Parse()
32 |
33 | // Initialize application
34 | application := app.NewApplication(cfg)
35 |
36 | // Connect to PostgreSQL
37 | if err := application.ConnectToDatabase(); err != nil {
38 | log.Fatal("Could not connect to PostgreSQL: ", err)
39 | }
40 |
41 | // Setup router
42 | router := routes.SetupRouter(application)
43 |
44 | // Start server
45 | port := cfg.Server.Port
46 | fmt.Printf("Server started at http://localhost:%s\n", port)
47 | log.Fatal(http.ListenAndServe(":"+port, router))
48 | }
49 |
50 | func getEnvOrDefault(key, defaultValue string) string {
51 | value := os.Getenv(key)
52 | if value == "" {
53 | return defaultValue
54 | }
55 | return value
56 | }
57 |
--------------------------------------------------------------------------------
/internal/templates/userdetails.templ:
--------------------------------------------------------------------------------
1 | package templates
2 |
3 | import (
4 | "fmt"
5 | "github.com/lordaris/gotth-boilerplate/internal/models"
6 | )
7 |
8 | templ UserDetails(user models.User) {
9 | @Layout("User Details") {
10 |
11 |
12 |
13 |
{ user.Username}
14 |
{ user.Username }
15 |
16 |
Active
17 |
18 |
19 |
User Information
20 |
21 |
ID
22 |
{ fmt.Sprint(user.ID) }
23 |
Joined
24 |
February 20, 2025
25 |
Role
26 |
Standard User
27 |
28 |
29 |
30 |
38 |
46 |
55 |
56 |
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/internal/templates/home.templ:
--------------------------------------------------------------------------------
1 | package templates
2 |
3 | import (
4 | "github.com/lordaris/gotth-boilerplate/internal/auth"
5 | "github.com/lordaris/gotth-boilerplate/internal/models"
6 | )
7 |
8 | templ Home(user models.User) {
9 | @Layout("Home") {
10 |
11 |
12 |
Welcome to my GotTH boilerplate
13 | by Armando Peña L.
14 |
15 | a template to quickstart your web development using Go,temple, Tailwind and HTMX
16 |
17 |
18 |
Content:
19 |
20 | - User creation and authentication
21 | - HTMX navigation without reloading
22 | - Design using tailwindcss
23 |
24 |
25 |
26 |
27 |
How to use it
28 |
29 |
This template includes some cases of use of HTMX, using a stateful authentication system and a database
30 |
31 | Just explore the code and play with this site. You can adapt the code to your needs.
32 |
33 |
34 |
35 | if auth.GetUserFromContext(ctx) != nil {
36 |
41 | } else {
42 |
52 | }
53 |
54 |
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/internal/templates/profile.templ:
--------------------------------------------------------------------------------
1 | package templates
2 |
3 | import "github.com/lordaris/gotth-boilerplate/internal/models"
4 | import "time"
5 | import "fmt"
6 | import "github.com/lordaris/gotth-boilerplate/internal/auth"
7 |
8 | templ ProfilePage(user *models.User) {
9 | @Layout("Profile") {
10 |
11 |
12 |
My profile
13 |
14 |
15 |
16 |
17 | { string(user.Username[0]) }
18 |
19 |
20 |
{ user.Username }
21 |
Registered since { formatDate(user.CreatedAt) }
22 |
23 |
24 |
25 |
Account information
26 |
27 |
28 |
- User ID
29 | - { fmt.Sprintf("%d", user.ID) }
30 |
31 |
32 |
- Username
33 | - { user.Username }
34 |
35 |
36 |
- Sign up date
37 | - { user.CreatedAt.Format("02/01/2006") }
38 |
39 |
40 |
41 |
42 |
43 | if auth.GetUserFromContext(ctx) != nil {
44 |
45 |
Logout
46 |
47 | }
48 |
49 |
50 |
51 | }
52 | }
53 |
54 | func formatDate(date time.Time) string {
55 | return date.Format("02 de January de 2006")
56 | }
57 |
--------------------------------------------------------------------------------
/internal/templates/register.templ:
--------------------------------------------------------------------------------
1 | package templates
2 |
3 | templ RegisterPage() {
4 | @Layout("Sign up") {
5 |
6 |
Sign up
7 |
50 |
51 |
52 | Already registered? Login
53 |
54 |
55 |
56 | }
57 | }
58 |
59 | templ RegisterError(message string) {
60 |
63 | }
64 |
--------------------------------------------------------------------------------
/internal/routes/routes.go:
--------------------------------------------------------------------------------
1 | package routes
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/lordaris/gotth-boilerplate/internal/app"
7 | "github.com/lordaris/gotth-boilerplate/internal/auth"
8 | "github.com/lordaris/gotth-boilerplate/internal/handlers"
9 |
10 | "github.com/go-chi/chi/v5"
11 | "github.com/go-chi/chi/v5/middleware"
12 | )
13 |
14 | // SetupRouter configures and returns the application router
15 | func SetupRouter(app *app.Application) *chi.Mux {
16 | r := chi.NewRouter()
17 |
18 | // Middleware
19 | r.Use(middleware.Logger)
20 | r.Use(middleware.Recoverer)
21 | r.Use(middleware.RealIP)
22 |
23 | authMiddleware := auth.NewMiddleware(app)
24 |
25 | // Use cookie-based auth by checking for auth_token cookie
26 | r.Use(func(next http.Handler) http.Handler {
27 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
28 | cookie, err := r.Cookie("auth_token")
29 | if err == nil {
30 | // Add Authorization header if cookie exists
31 | r.Header.Set("Authorization", "Bearer "+cookie.Value)
32 | }
33 | next.ServeHTTP(w, r)
34 | })
35 | })
36 |
37 | // Apply authentication middleware to parse the auth token
38 | r.Use(authMiddleware.Authenticate)
39 |
40 | // Create handlers
41 | h := handlers.NewHandlers(app)
42 |
43 | // Static files
44 | fileServer := http.FileServer(http.Dir("./static"))
45 | r.Handle("/static/*", http.StripPrefix("/static/", fileServer))
46 |
47 | // Public routes
48 | r.Group(func(r chi.Router) {
49 | r.Get("/", h.Home())
50 | r.Get("/login", h.LoginForm())
51 | r.Post("/login", h.Login())
52 | r.Get("/register", h.RegisterForm())
53 | r.Post("/register", h.Register())
54 | r.Get("/logout", h.Logout())
55 | })
56 |
57 | // User management routes
58 | r.Group(func(r chi.Router) {
59 | // These routes require authentication
60 | r.Use(authMiddleware.RequireAuth)
61 | r.Get("/profile", h.ProfileHandler())
62 |
63 | // User management
64 | // r.Get("/users", h.GetUsersHandler())
65 | // r.Post("/users", h.CreateUserHandler())
66 | // r.Get("/users/{id}", h.GetUserDetailHandler())
67 | // r.Get("/users/{id}/edit", h.EditUserHandler())
68 | // r.Put("/users/{id}", h.UpdateUserHandler())
69 | // r.Delete("/users/{id}", h.DeleteUserHandler())
70 | })
71 |
72 | return r
73 | }
74 |
--------------------------------------------------------------------------------
/internal/auth/middleware.go:
--------------------------------------------------------------------------------
1 | package auth
2 |
3 | import (
4 | "context"
5 | "net/http"
6 | "strings"
7 | "time"
8 |
9 | "github.com/lordaris/gotth-boilerplate/internal/app"
10 | "github.com/lordaris/gotth-boilerplate/internal/models"
11 | )
12 |
13 | type contextKey string
14 |
15 | const UserContextKey = contextKey("user")
16 |
17 | type Middleware struct {
18 | App *app.Application
19 | }
20 |
21 | func NewMiddleware(app *app.Application) *Middleware {
22 | return &Middleware{App: app}
23 | }
24 |
25 | func (m *Middleware) Authenticate(next http.Handler) http.Handler {
26 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
27 | db := m.App.DB
28 | // Add Vary header to ensure cached responses are based on the Authorization header
29 | w.Header().Add("Vary", "Authorization")
30 |
31 | authHeader := r.Header.Get("Authorization")
32 |
33 | if authHeader == "" {
34 | next.ServeHTTP(w, r)
35 | return
36 | }
37 |
38 | headerParts := strings.Split(authHeader, " ")
39 | if len(headerParts) != 2 || headerParts[0] != "Bearer" {
40 | http.Error(w, "Invalid authentication token", http.StatusUnauthorized)
41 | return
42 | }
43 |
44 | token := headerParts[1]
45 |
46 | if err := ValidateTokenPlaintext(token); err != nil {
47 | http.Error(w, err.Error(), http.StatusUnauthorized)
48 | return
49 | }
50 |
51 | tokenHash := HashToken(token)
52 |
53 | var userToken models.Token
54 | var user models.User
55 |
56 | query := `
57 | SELECT id, user_id, expiry
58 | FROM tokens
59 | WHERE token_hash = $1
60 | AND expiry > $2
61 | `
62 | err := db.Get(&userToken, query, tokenHash, time.Now())
63 | if err != nil {
64 | http.Error(w, "Invalid authentication token", http.StatusUnauthorized)
65 | return
66 | }
67 |
68 | query = `
69 | SELECT id, username, password_hash, created_at
70 | FROM users
71 | WHERE id = $1
72 | `
73 | err = db.Get(&user, query, userToken.UserID)
74 | if err != nil {
75 | http.Error(w, "Invalid user", http.StatusUnauthorized)
76 | return
77 | }
78 |
79 | ctx := context.WithValue(r.Context(), UserContextKey, &user)
80 | r = r.WithContext(ctx)
81 |
82 | next.ServeHTTP(w, r)
83 | })
84 | }
85 |
86 | func (m *Middleware) RequireAuth(next http.Handler) http.Handler {
87 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
88 | user := GetUserFromContext(r.Context())
89 | if user == nil {
90 | http.Error(w, "Authentication required", http.StatusUnauthorized)
91 | return
92 | }
93 |
94 | next.ServeHTTP(w, r)
95 | })
96 | }
97 |
98 | func GetUserFromContext(ctx context.Context) *models.User {
99 | user, ok := ctx.Value(UserContextKey).(*models.User)
100 | if !ok {
101 | return nil
102 | }
103 | return user
104 | }
105 |
106 | func (m *Middleware) CleanupExpiredTokens() error {
107 | db := m.App.DB
108 | query := `DELETE FROM tokens WHERE expiry < $1`
109 | _, err := db.Exec(query, time.Now())
110 | return err
111 | }
112 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # GOTTH Stack stateful authentication boilerplate
2 |
3 | A web application boilerplate with stateful authentication using the GOTTH (golang, templ, tailwind, HTMX) stack:
4 |
5 | - **Go**
6 | - **Chi** Lightweight and idiomatic router for Go
7 | - **Templ** - Type-safe templates for Go
8 | - **Tailwind CSS** - CSS framework
9 | - **HTMX** - Modern frontend interactivity without JavaScript frameworks
10 | - **PostgreSQL + SQLx** - Database and SQL handling
11 | - **Air** - Live reload for development
12 |
13 | ## Requirements
14 |
15 | Ensure you have the following installed before starting:
16 |
17 | - **Go 1.20+**
18 | - **Node.js and npm**
19 | - **PostgreSQL**
20 |
21 | ## Installation
22 |
23 | 1. **Clone the repository**
24 |
25 | ```bash
26 | git clone https://github.com/lordaris/gotth-boilerplate
27 | cd gotth-boilerplate
28 | ```
29 |
30 | 2. **Install Go dependencies**
31 |
32 | ```bash
33 | go mod tidy
34 | ```
35 |
36 | 3. **Install Node dependencies**
37 |
38 | ```bash
39 | npm install
40 | ```
41 |
42 | 4. **Configure the PostgreSQL database**
43 | - Set up your database and update the connection details in the environment configuration.
44 | - Create a .env file in the main folder and add the following content:
45 |
46 | ```bash
47 | # Database Configuration
48 | DB_HOST=localhost
49 | DB_PORT=5432
50 | DB_USER=postgres
51 | DB_PASSWORD=postgres
52 | DB_NAME=gotth_boilerplate
53 |
54 | # Server Configuration
55 | PORT=8080
56 | ```
57 |
58 | This .env file is essential for storing sensitive configuration values and should not be committed to version control.
59 |
60 | 5. **Run database migrations**
61 |
62 | ```bash
63 | make db-migrate
64 | ```
65 |
66 | 6. **Start the development server**
67 |
68 | ```bash
69 | make dev
70 | ```
71 |
72 | 7. Visit `http://localhost:8080` in your browser.
73 |
74 | ## Makefile Commands
75 |
76 | This project includes a `Makefile` to simplify common operations, just remove the "copy" text from the file name and modify the project and database variables to your own:
77 |
78 | ```bash
79 | # Install required tools
80 | make install-tools
81 |
82 | # Start development environment (Air + Tailwind)
83 | make dev
84 |
85 | # Database operations
86 | make db-create # Create the database
87 | make db-migrate # Run migrations
88 | make db-reset # Reset the database
89 |
90 | # Generate Templ files
91 | make generate-templ
92 |
93 | # See all available commands
94 | make help
95 | ```
96 |
97 | Stateful Login System
98 |
99 | In this boilerplate, the login system has been upgraded to use a stateful approach, meaning sessions are maintained with cookies on the client-side. Upon logging in, a session token is stored in an HTTP-only cookie, which is used for subsequent requests to authenticate the user. This is a more secure approach compared to stateless systems like JWTs because the session token remains on the client and is tied to a session on the server-side.
100 | Improvements with State Management:
101 |
102 | Session management: Instead of relying on tokens passed with each request, the server maintains sessions tied to cookies.
103 | Logout functionality: Logs out the user by clearing the session cookie, ensuring no leftover sessions are active.
104 | Redirect after logout: After logging out, users are automatically redirected to the home page or the login page as configured.
105 |
106 | Session Flow:
107 | Login: When a user logs in, the server creates a session and stores the session ID in an HTTP-only cookie.
108 | Authentication: For every request, the server retrieves the session cookie to authenticate the user.
109 | Logout: The session cookie is cleared, and the user is logged out.
110 |
111 |
112 | ## Improvement Areas & Future Plans
113 |
114 | - **Better HTMX usage**: As I'm still learning HTMX, the implementation isn't perfect. Expect improvements in the future.
115 | - **General refinements**: UI, error handling, and overall experience will be enhanced over time.
116 | - Add CRUD elements
117 |
118 | For now, if you modify a `templ` file, run:
119 |
120 | ```bash
121 | make generate-templ
122 | ```
123 |
124 | and restart the server using:
125 |
126 | ```bash
127 | make run # or make dev
128 | ```
129 |
130 | ---
131 |
132 | Feel free to improve on this template and make it your own. Happy coding!
133 |
--------------------------------------------------------------------------------
/Makefile copy:
--------------------------------------------------------------------------------
1 | # Project variables
2 | APP_NAME = gotth-boilerplate
3 | MAIN_PATH = ./cmd/server
4 | BUILD_DIR = ./tmp
5 | BINARY_NAME = $(BUILD_DIR)/$(APP_NAME)
6 |
7 | # Database variables
8 | DB_USER ?= postgres
9 | DB_PASSWORD ?= postgres
10 | DB_NAME ?= gotth_boilerplate
11 | DB_HOST ?= localhost
12 | DB_PORT ?= 5432
13 | DB_URL ?= postgres://$(DB_USER):$(DB_PASSWORD)@$(DB_HOST):$(DB_PORT)/$(DB_NAME)?sslmode=disable
14 |
15 | # Templ variables
16 | TEMPL_SRC_DIR = ./internal/templates
17 | TEMPL_FILES = $(shell find $(TEMPL_SRC_DIR) -name "*.templ")
18 |
19 | # Tailwind variables
20 | TAILWIND_INPUT = ./static/css/input.css
21 | TAILWIND_OUTPUT = ./static/css/output.css
22 |
23 | .PHONY: all build run clean dev dev-server dev-tailwind dev-all install-tools db-create db-migrate db-reset generate-templ test help
24 |
25 | # Default target
26 | all: build
27 |
28 | # Build the application
29 | build: generate-templ
30 | go build -o $(BINARY_NAME) $(MAIN_PATH)
31 |
32 | # Run the compiled application
33 | run: build
34 | $(BINARY_NAME)
35 |
36 | # Clean generated files
37 | clean:
38 | rm -rf $(BUILD_DIR)
39 | rm -f $(TAILWIND_OUTPUT)
40 | find . -name '*_templ.go' -delete
41 |
42 | # Start server with Air (hot reload)
43 | dev-server:
44 | air
45 |
46 | # Start Tailwind in development mode (watch)
47 | dev-tailwind:
48 | npx @tailwindcss/cli -i $(TAILWIND_INPUT) -o $(TAILWIND_OUTPUT) --watch
49 |
50 | # Build Tailwind for production
51 | build-tailwindpostgres:
52 | npx tailwindcss -i $(TAILWIND_INPUT) -o $(TAILWIND_OUTPUT) --minify
53 |
54 | # Start both services in parallel (using foreman if available, or an alternative solution)
55 | dev-all:
56 | @if command -v foreman > /dev/null; then \
57 | echo "web: air" > Procfile && \
58 | echo "css: npx tailwindcss -i $(TAILWIND_INPUT) -o $(TAILWIND_OUTPUT) --watch" >> Procfile && \
59 | foreman start; \
60 | else \
61 | echo "Starting services in separate terminals..."; \
62 | $(MAKE) dev-server & $(MAKE) dev-tailwind; \
63 | fi
64 |
65 | # Convenient alias for dev-all
66 | dev: dev-all
67 |
68 | # Install necessary tools
69 | install-tools:
70 | go install github.com/air-verse/air@latest
71 | go install github.com/a-h/templ/cmd/templ@latest
72 | go get -u github.com/go-chi/chi/v5
73 | npm install
74 |
75 | # Generate Go files from Templ templates
76 | generate-templ: $(TEMPL_FILES)
77 | templ generate
78 |
79 | # Create database
80 | db-create:
81 | @echo "Creating database $(DB_NAME)..."
82 | @PGPASSWORD=$(DB_PASSWORD) psql -h $(DB_HOST) -p $(DB_PORT) -U $(DB_USER) -d postgres -c "CREATE DATABASE $(DB_NAME);" || echo "Database already exists"
83 |
84 | # Run migrations
85 | db-migrate:
86 | @echo "Running migrations..."
87 | @for file in ./migrations/*_*.sql; do \
88 | echo "Applying $$file..."; \
89 | PGPASSWORD=$(DB_PASSWORD) psql -h $(DB_HOST) -p $(DB_PORT) -U $(DB_USER) -d $(DB_NAME) -f $$file; \
90 | done
91 |
92 | # Reset database (drop + create + migrate)
93 | db-reset:
94 | @echo "Resetting database $(DB_NAME)..."
95 | @PGPASSWORD=$(DB_PASSWORD) psql -h $(DB_HOST) -p $(DB_PORT) -U $(DB_USER) -c "DROP DATABASE IF EXISTS $(DB_NAME);" || true
96 | @$(MAKE) db-create
97 | @$(MAKE) db-migrate
98 |
99 | # Run tests
100 | test:
101 | go test -v ./...
102 |
103 | # Help
104 | help:
105 | @echo "Makefile Help for $(APP_NAME)"
106 | @echo ""
107 | @echo "Available commands:"
108 | @echo " make build - Build the application"
109 | @echo " make run - Run the compiled application"
110 | @echo " make clean - Clean generated files"
111 | @echo " make dev - Start the complete development environment"
112 | @echo " make dev-server - Start only the server with Air (hot reload)"
113 | @echo " make dev-tailwind - Start only the Tailwind compiler (watch mode)"
114 | @echo " make build-tailwind - Build Tailwind for production (minified)"
115 | @echo " make install-tools - Install necessary tools"
116 | @echo " make generate-templ - Generate Go files from Templ templates"
117 | @echo " make db-create - Create the database"
118 | @echo " make db-migrate - Run migrations"
119 | @echo " make db-reset - Reset the database (drop+create+migrate)"
120 | @echo " make test - Run tests"
121 | @echo ""
122 | @echo "Environment variables you can configure:"
123 | @echo " DB_USER=user - PostgreSQL user (default: postgres)"
124 | @echo " DB_PASSWORD=password - PostgreSQL password (default: postgres)"
125 | @echo " DB_NAME=name - Database name (default: github.com/lordaris/gotth-boilerplate)"
126 | @echo " DB_HOST=host - PostgreSQL host (default: localhost)"
127 | @echo " DB_PORT=port - PostgreSQL port (default: 5432)"
128 | @echo ""
129 | @echo "Example: make db-reset DB_USER=myuser DB_PASSWORD=mypassword"
130 |
--------------------------------------------------------------------------------
/internal/handlers/authentication.go:
--------------------------------------------------------------------------------
1 | package handlers
2 |
3 | import (
4 | "log"
5 | "net/http"
6 | "time"
7 |
8 | "github.com/lordaris/gotth-boilerplate/internal/auth"
9 | "github.com/lordaris/gotth-boilerplate/internal/models"
10 |
11 | "github.com/lordaris/gotth-boilerplate/internal/templates"
12 | )
13 |
14 | func (h *Handlers) LoginForm() http.HandlerFunc {
15 | return func(w http.ResponseWriter, r *http.Request) {
16 | user := auth.GetUserFromContext(r.Context())
17 | if user != nil {
18 | http.Redirect(w, r, "/", http.StatusSeeOther)
19 | return
20 | }
21 |
22 | templates.LoginPage().Render(r.Context(), w)
23 | }
24 | }
25 |
26 | func (h *Handlers) RegisterForm() http.HandlerFunc {
27 | return func(w http.ResponseWriter, r *http.Request) {
28 | user := auth.GetUserFromContext(r.Context())
29 | if user != nil {
30 | http.Redirect(w, r, "/", http.StatusSeeOther)
31 | return
32 | }
33 |
34 | templates.RegisterPage().Render(r.Context(), w)
35 | }
36 | }
37 |
38 | func (h *Handlers) Login() http.HandlerFunc {
39 | return func(w http.ResponseWriter, r *http.Request) {
40 | db := h.App.DB
41 |
42 | if err := r.ParseForm(); err != nil {
43 | http.Error(w, "Invalid form data", http.StatusBadRequest)
44 | return
45 | }
46 |
47 | username := r.FormValue("username")
48 | password := r.FormValue("password")
49 |
50 | if username == "" || password == "" {
51 | templates.LoginPage().Render(r.Context(), w)
52 | return
53 | }
54 |
55 | var user models.User
56 | query := `SELECT id, username, password_hash, created_at FROM users WHERE username = $1`
57 | err := db.Get(&user, query, username)
58 | if err != nil {
59 | templates.LoginPage().Render(r.Context(), w)
60 | return
61 | }
62 |
63 | match, err := user.CheckPassword(password)
64 | if err != nil || !match {
65 | templates.LoginPage().Render(r.Context(), w)
66 | return
67 | }
68 |
69 | tokenString, tokenHash, expiryTime, err := auth.GenerateToken(24 * time.Hour)
70 | if err != nil {
71 | http.Error(w, "Server error", http.StatusInternalServerError)
72 | return
73 | }
74 |
75 | query = `
76 | INSERT INTO tokens (user_id, token_hash, plaintext_token, expiry)
77 | VALUES ($1, $2, $3, $4)
78 | `
79 | _, err = db.Exec(query, user.ID, tokenHash, tokenString, expiryTime)
80 | if err != nil {
81 | http.Error(w, "Server error", http.StatusInternalServerError)
82 | return
83 | }
84 |
85 | http.SetCookie(w, &http.Cookie{
86 | Name: "auth_token",
87 | Value: tokenString,
88 | Path: "/",
89 | Expires: expiryTime,
90 | HttpOnly: true,
91 | Secure: r.TLS != nil,
92 | SameSite: http.SameSiteStrictMode,
93 | })
94 |
95 |
96 | if r.Header.Get("HX-Request") == "true" {
97 | w.Header().Set("HX-Redirect", "/")
98 | return
99 | }
100 |
101 | http.Redirect(w, r, "/", http.StatusSeeOther)
102 | }
103 | }
104 |
105 | func (h *Handlers) Register() http.HandlerFunc {
106 | return func(w http.ResponseWriter, r *http.Request) {
107 | db := h.App.DB
108 |
109 | if err := r.ParseForm(); err != nil {
110 | log.Println("Error while parsing the form:", err)
111 | http.Error(w, "Invalid form data", http.StatusBadRequest)
112 | return
113 | }
114 |
115 | username := r.FormValue("username")
116 | password := r.FormValue("password")
117 | confirmPassword := r.FormValue("confirm_password")
118 |
119 | if username == "" || password == "" || password != confirmPassword {
120 | log.Println("Failed validation")
121 | templates.RegisterPage().Render(r.Context(), w)
122 | return
123 | }
124 |
125 | user := models.User{
126 | Username: username,
127 | }
128 |
129 | if err := user.SetPassword(password); err != nil {
130 | log.Println("Error while setting the password:", err)
131 | http.Error(w, err.Error(), http.StatusBadRequest)
132 | return
133 | }
134 |
135 | if db == nil {
136 | log.Println("Error: Database not initialized")
137 | http.Error(w, "Internal server error", http.StatusInternalServerError)
138 | return
139 | }
140 |
141 | query := `
142 | INSERT INTO users (username, password_hash)
143 | VALUES ($1, $2)
144 | RETURNING id, created_at
145 | `
146 |
147 | row := db.QueryRow(query, user.Username, user.PasswordHash)
148 | if err := row.Scan(&user.ID, &user.CreatedAt); err != nil {
149 | log.Println("Error while executing the query:", err)
150 | http.Error(w, "Failed to create the user", http.StatusInternalServerError)
151 | return
152 | }
153 |
154 | log.Println("User successfully registered", user.Username)
155 |
156 | // If HTMX request
157 | if r.Header.Get("HX-Request") == "true" {
158 | w.Header().Set("HX-Redirect", "/login")
159 | return
160 | }
161 |
162 | // Regular form submission
163 | http.Redirect(w, r, "/login", http.StatusSeeOther)
164 | }
165 | }
166 |
167 |
168 | func (h *Handlers) Logout() http.HandlerFunc {
169 | return func(w http.ResponseWriter, r *http.Request) {
170 | db := h.App.DB
171 |
172 | cookie, err := r.Cookie("auth_token")
173 | if err == nil {
174 | tokenHash := auth.HashToken(cookie.Value)
175 | query := `DELETE FROM tokens WHERE token_hash = $1`
176 | _, err := db.Exec(query, tokenHash)
177 | if err != nil {
178 | log.Printf("Error deleting token from DB: %v", err)
179 | }
180 |
181 | http.SetCookie(w, &http.Cookie{
182 | Name: "auth_token",
183 | Value: "",
184 | Path: "/",
185 | MaxAge: -1, // Expire immediately
186 | HttpOnly: true,
187 | Secure: r.TLS != nil,
188 | SameSite: http.SameSiteStrictMode,
189 | })
190 | }
191 |
192 | // If HTMX request, set redirect header
193 | if r.Header.Get("HX-Request") == "true" {
194 | w.Header().Set("HX-Redirect", "/") // Redirect to main
195 | return
196 | }
197 |
198 | http.Redirect(w, r, "/", http.StatusSeeOther)
199 | }
200 | }
201 |
202 |
--------------------------------------------------------------------------------
/internal/handlers/handlers.go:
--------------------------------------------------------------------------------
1 | package handlers
2 |
3 | import (
4 | "net/http"
5 |
6 | // "github.com/go-chi/chi/v5"
7 | "github.com/lordaris/gotth-boilerplate/internal/app"
8 | "github.com/lordaris/gotth-boilerplate/internal/auth"
9 | "github.com/lordaris/gotth-boilerplate/internal/models"
10 | "github.com/lordaris/gotth-boilerplate/internal/templates"
11 | )
12 |
13 | type Handlers struct {
14 | App *app.Application
15 | }
16 |
17 | func NewHandlers(app *app.Application) *Handlers {
18 | return &Handlers{
19 | App: app,
20 | }
21 | }
22 |
23 | func (h *Handlers) Home() http.HandlerFunc {
24 | return func(w http.ResponseWriter, r *http.Request) {
25 | user := auth.GetUserFromContext(r.Context())
26 | if user == nil {
27 | user = &models.User{}
28 | }
29 | templates.Home(*user).Render(r.Context(), w)
30 | }
31 | }
32 | //
33 | func (h *Handlers) ProfileHandler() http.HandlerFunc {
34 | return func(w http.ResponseWriter, r *http.Request) {
35 | user := auth.GetUserFromContext(r.Context())
36 | if user == nil {
37 | user = &models.User{}
38 | }
39 | templates.ProfilePage(user).Render(r.Context(), w)
40 | }
41 | }
42 |
43 | // func (h *Handlers) CreateUserHandler() http.HandlerFunc {
44 | // return func(w http.ResponseWriter, r *http.Request) {
45 | // users := []models.User{}
46 | // err := r.ParseForm()
47 | // if err != nil {
48 | // http.Error(w, "Invalid input", http.StatusBadRequest)
49 | // return
50 | // }
51 | //
52 | // name := r.FormValue("name")
53 | // email := r.FormValue("email")
54 | //
55 | // if name == "" || email == "" {
56 | // http.Error(w, "Missing fields", http.StatusBadRequest)
57 | // return
58 | // }
59 | //
60 | // _, err = h.App.DB.Exec("INSERT INTO users (name, email) VALUES ($1, $2)", name, email)
61 | // if err != nil {
62 | // http.Error(w, "Error creating user", http.StatusInternalServerError)
63 | // return
64 | // }
65 | //
66 | // err = h.App.DB.Select(&users, "SELECT id, name, email FROM users")
67 | // if err != nil {
68 | // http.Error(w, err.Error(), http.StatusInternalServerError)
69 | // return
70 | // }
71 | //
72 | // w.WriteHeader(http.StatusCreated)
73 | // templates.UsersList(users).Render(r.Context(), w)
74 | // }
75 | // }
76 | //
77 | // // GetUsersHandler retrieves users from the database
78 | // func (h *Handlers) GetUsersHandler() http.HandlerFunc {
79 | // return func(w http.ResponseWriter, r *http.Request) {
80 | // users := []models.User{}
81 | // err := h.App.DB.Select(&users, "SELECT id, name, email FROM users")
82 | // if err != nil {
83 | // http.Error(w, err.Error(), http.StatusInternalServerError)
84 | // return
85 | // }
86 | //
87 | // templates.UsersList(users).Render(r.Context(), w)
88 | // }
89 | // }
90 | //
91 | // func (h *Handlers) GetUserDetailHandler() http.HandlerFunc {
92 | // return func(w http.ResponseWriter, r *http.Request) {
93 | // idStr := chi.URLParam(r, "id")
94 | // id, err := strconv.Atoi(idStr)
95 | // if err != nil {
96 | // log.Println("Error converting ID:", err)
97 | // http.Error(w, "Invalid user ID", http.StatusBadRequest)
98 | // return
99 | // }
100 | //
101 | // user := models.User{}
102 | // err = h.App.DB.Get(&user, "SELECT id, name, email FROM users WHERE id = $1", id)
103 | // if err != nil {
104 | // log.Println("Error fetching user from DB:", err)
105 | // http.Error(w, "User not found", http.StatusNotFound)
106 | // return
107 | // }
108 | //
109 | // templates.UserDetails(user).Render(r.Context(), w)
110 | // }
111 | // }
112 | //
113 | // func (h *Handlers) EditUserHandler() http.HandlerFunc {
114 | // return func(w http.ResponseWriter, r *http.Request) {
115 | // idStr := chi.URLParam(r, "id")
116 | // id, err := strconv.Atoi(idStr)
117 | // if err != nil {
118 | // http.Error(w, "Invalid user ID", http.StatusBadRequest)
119 | // return
120 | // }
121 | //
122 | // user := models.User{}
123 | // err = h.App.DB.Get(&user, "SELECT id, name, email FROM users WHERE id = $1", id)
124 | // if err != nil {
125 | // http.Error(w, "User not found", http.StatusNotFound)
126 | // return
127 | // }
128 | //
129 | // templates.EditUserForm(user).Render(r.Context(), w)
130 | // }
131 | // }
132 | //
133 | // func (h *Handlers) UpdateUserHandler() http.HandlerFunc {
134 | // return func(w http.ResponseWriter, r *http.Request) {
135 | // id := chi.URLParam(r, "id")
136 | // err := r.ParseForm()
137 | // if err != nil {
138 | // http.Error(w, "Invalid input", http.StatusBadRequest)
139 | // return
140 | // }
141 | //
142 | // name := r.FormValue("name")
143 | // email := r.FormValue("email")
144 | //
145 | // _, err = h.App.DB.Exec("UPDATE users SET name = $1, email = $2 WHERE id = $3", name, email, id)
146 | // if err != nil {
147 | // http.Error(w, "Error updating user", http.StatusInternalServerError)
148 | // return
149 | // }
150 | // users := []models.User{}
151 | // err = h.App.DB.Select(&users, "SELECT id, name, email FROM users")
152 | // if err != nil {
153 | // http.Error(w, err.Error(), http.StatusInternalServerError)
154 | // return
155 | // }
156 | //
157 | // templates.UsersList(users).Render(r.Context(), w)
158 | // }
159 | // }
160 | //
161 | // func (h *Handlers) DeleteUserHandler() http.HandlerFunc {
162 | // return func(w http.ResponseWriter, r *http.Request) {
163 | // id := chi.URLParam(r, "id")
164 | //
165 | // _, err := h.App.DB.Exec("DELETE FROM users WHERE id = $1", id)
166 | // if err != nil {
167 | // http.Error(w, "Error deleting user", http.StatusInternalServerError)
168 | // return
169 | // }
170 | //
171 | // w.WriteHeader(http.StatusOK)
172 | //
173 | // users := []models.User{}
174 | // err = h.App.DB.Select(&users, "SELECT id, name, email FROM users")
175 | // if err != nil {
176 | // http.Error(w, err.Error(), http.StatusInternalServerError)
177 | // return
178 | // }
179 | //
180 | // templates.UsersList(users).Render(r.Context(), w)
181 | // }
182 | // }
183 |
--------------------------------------------------------------------------------