31 Commits

Author SHA1 Message Date
37391190fb Merge branch 'master' into file_uploads 2023-05-05 12:19:55 -05:00
6da7d408f9 Add .gitattributes to force LF line endings 2023-05-05 12:19:17 -05:00
max
1fb8fdef81 Merge branch 'master' into file_uploads 2023-05-04 09:00:53 -05:00
max
e993bcf317 Update dependency versions 2023-05-04 09:00:35 -05:00
max
baef0cbe78 Fix a couple deprecated calls and handle errors 2023-05-04 08:40:35 -05:00
d0da1a9114 File Uploading feature by tfasano1 2023-05-04 08:26:44 -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
22 changed files with 485 additions and 80 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

60
README.md Normal file
View File

@ -0,0 +1,60 @@
# 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
- CSRF protection
- Minimal user login/registration + sessions
- Config file handling
- Scheduled tasks
- Entire website compiles into a single binary (~10mb) (excluding env.json)
- 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. Run `go get` to install dependencies
3. Copy env_example.json to env.json and fill in the values
4. Run `go run main.go` to start the server
5. Start building your app!
## 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
}

75
app/schedule.go Normal file
View File

@ -0,0 +1,75 @@
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{}) {
// Run every time the server starts
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},
}
// Set up task runners
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)
}
// Wait for all goroutines to finish
wg.Wait()
// Close channels
for _, runner := range runners {
close(runner)
}
}

View File

@ -25,6 +25,11 @@ type Configuration struct {
Template struct {
BaseName string `json:"BaseTemplateName"`
}
Upload struct {
BaseName string `json:"UploadDirectoryName"`
MaxSize int64 `json:"MaxUploadSize"`
}
}
// LoadConfig loads and returns a configuration struct

View File

@ -61,6 +61,13 @@ func (getController *GetController) ShowLogin(w http.ResponseWriter, r *http.Req
templating.RenderTemplate(getController.App, w, "templates/pages/login.html", data)
}
func (getController *GetController) ShowFile(w http.ResponseWriter, r *http.Request) {
// GET /uploads?name=file.jpg
// will serve file.jpg
name := r.URL.Query().Get("name")
http.ServeFile(w, r, getController.App.Config.Upload.BaseName+name)
}
func (getController *GetController) Logout(w http.ResponseWriter, r *http.Request) {
models.LogoutUser(getController.App, w, r)
http.Redirect(w, r, "/", http.StatusFound)

View File

@ -4,8 +4,11 @@ import (
"GoWeb/app"
"GoWeb/models"
"GoWeb/security"
"io"
"log"
"mime/multipart"
"net/http"
"os"
"time"
)
@ -24,13 +27,14 @@ func (postController *PostController) Login(w http.ResponseWriter, r *http.Reque
username := r.FormValue("username")
password := r.FormValue("password")
remember := r.FormValue("remember") == "on"
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)
_, err = models.AuthenticateUser(postController.App, w, username, password, remember)
if err != nil {
log.Println("Error authenticating user")
log.Println(err)
@ -68,3 +72,60 @@ func (postController *PostController) Register(w http.ResponseWriter, r *http.Re
http.Redirect(w, r, "/login", http.StatusFound)
}
func (postController *PostController) FileUpload(w http.ResponseWriter, r *http.Request) {
max := postController.App.Config.Upload.MaxSize
err := r.ParseMultipartForm(max)
if err != nil {
return
}
// FormFile returns the first file for the given key `file`
// it also returns the FileHeader, so we can get the Filename,
// the Header and the size of the file
file, handler, err := r.FormFile("file")
if err != nil {
log.Println("Error Retrieving the File")
log.Println(err)
return
}
defer func(file multipart.File) {
err := file.Close()
if err != nil {
log.Println(err)
}
}(file)
if handler.Size > max {
log.Println("User tried uploading a file which is too large.")
http.Redirect(w, r, "/", http.StatusRequestHeaderFieldsTooLarge)
return
}
// Create a temporary file within upload directory
tempFile, err := os.Create(postController.App.Config.Upload.BaseName + handler.Filename)
if err != nil {
log.Println(err)
http.Redirect(w, r, "/", http.StatusNotAcceptable)
}
defer func(tempFile *os.File) {
err := tempFile.Close()
if err != nil {
log.Println(err)
}
}(tempFile)
// read all the contents of our uploaded file into a
// byte array
fileBytes, err := io.ReadAll(file)
if err != nil {
log.Println(err)
}
_, err = tempFile.Write(fileBytes)
if err != nil {
log.Println(err)
}
http.Redirect(w, r, "/", http.StatusFound)
}

View File

@ -13,5 +13,9 @@
},
"Template": {
"BaseTemplateName": "templates/base.html"
},
"Upload": {
"UploadDirectoryName": "goweb-uploads/",
"MaxUploadSize": 10485760
}
}
}

4
go.mod
View File

@ -3,6 +3,6 @@ module GoWeb
go 1.20
require (
github.com/lib/pq v1.10.7
golang.org/x/crypto v0.6.0
github.com/lib/pq v1.10.9
golang.org/x/crypto v0.8.0
)

8
go.sum
View File

@ -1,4 +1,4 @@
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.6.0 h1:qfktjS5LUO+fFKeJXZ+ikTRijMmljikvG68fpMMruSc=
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
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.8.0 h1:pd9TJtTueMTVQXzk8E2XESSMQDj/U7OUu0PqJqPXQjQ=
golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE=

18
main.go
View File

@ -8,6 +8,7 @@ import (
"GoWeb/routes"
"context"
"embed"
"errors"
"log"
"net/http"
"os"
@ -43,6 +44,14 @@ func main() {
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)
// Create upload directory if it doesn't exist
uploadPath := appLoaded.Config.Upload.BaseName
if _, err := os.Stat(uploadPath); errors.Is(err, os.ErrNotExist) {
if err := os.MkdirAll(uploadPath, os.ModePerm); err != nil {
log.Fatal(err)
}
}
// Connect to database and run migrations
appLoaded.Db = database.ConnectDB(&appLoaded)
if appLoaded.Config.Db.AutoMigrate {
@ -53,6 +62,12 @@ func main() {
}
}
// 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)
@ -70,6 +85,9 @@ func main() {
// 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
log.Println("Interrupt signal received. Shutting down server...")

View File

@ -22,10 +22,11 @@ func RunAllMigrations(app *app.App) error {
}
session := Session{
Id: 1,
UserId: 1,
AuthToken: "migrate",
CreatedAt: time.Now(),
Id: 1,
UserId: 1,
AuthToken: "migrate",
RememberMe: false,
CreatedAt: time.Now(),
}
err = database.Migrate(app, session)
if err != nil {

View File

@ -10,28 +10,32 @@ import (
)
type Session struct {
Id int64
UserId int64
AuthToken string
CreatedAt time.Time
Id int64
UserId int64
AuthToken string
RememberMe bool
CreatedAt time.Time
}
const sessionColumnsNoId = "\"UserId\", \"AuthToken\", \"CreatedAt\""
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) RETURNING \"Id\""
deleteSessionByAuthToken = "DELETE FROM " + sessionTable + " WHERE \"AuthToken\" = $1"
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) (Session, error) {
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
@ -46,11 +50,11 @@ func CreateSession(app *app.App, w http.ResponseWriter, userId int64) (Session,
// If duplicate token found, recursively call function until unique token is generated
if existingAuthToken == true {
log.Println("Duplicate token found in sessions table, generating new token...")
return CreateSession(app, w, userId)
return CreateSession(app, w, userId, remember)
}
// Insert session into database
err = app.Db.QueryRow(insertSession, session.UserId, session.AuthToken, session.CreatedAt).Scan(&session.Id)
err = app.Db.QueryRow(insertSession, session.UserId, session.AuthToken, session.RememberMe, session.CreatedAt).Scan(&session.Id)
if err != nil {
log.Println("Error inserting session into database")
return Session{}, err
@ -60,6 +64,18 @@ func CreateSession(app *app.App, w http.ResponseWriter, userId int64) (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 {
log.Println("Error getting session by auth token")
return Session{}, err
}
return session, nil
}
// Generates a random 64-byte string
func generateAuthToken(app *app.App) string {
// Generate random bytes
@ -75,13 +91,25 @@ func generateAuthToken(app *app.App) string {
// createSessionCookie creates a new session cookie
func createSessionCookie(app *app.App, w http.ResponseWriter, session Session) {
cookie := &http.Cookie{
Name: "session",
Value: session.AuthToken,
Path: "/",
MaxAge: 86400,
HttpOnly: true,
Secure: true,
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)
@ -112,3 +140,22 @@ func DeleteSessionByAuthToken(app *app.App, w http.ResponseWriter, authToken str
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 {
log.Println("Error deleting 30 day expired sessions from database")
log.Println(err)
}
// Delete sessions older than 6 hours
_, err = app.Db.Exec(deleteSessionsOlderThan6Hours)
if err != nil {
log.Println("Error deleting 6 hour expired sessions from database")
log.Println(err)
}
log.Println("Deleted expired sessions from database")
}

View File

@ -23,10 +23,9 @@ const userColumns = "\"Id\", " + userColumnsNoId
const userTable = "public.\"User\""
const (
selectSessionIdByAuthToken = "SELECT \"Id\" FROM public.\"Session\" WHERE \"AuthToken\" = $1"
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\""
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
@ -37,16 +36,13 @@ func GetCurrentUser(app *app.App, r *http.Request) (User, error) {
return User{}, err
}
var userId int64
// Query row by AuthToken
err = app.Db.QueryRow(selectSessionIdByAuthToken, cookie.Value).Scan(&userId)
session, err := GetSessionByAuthToken(app, cookie.Value)
if err != nil {
log.Println("Error querying session row with session: " + cookie.Value)
log.Println("Error getting session by auth token")
return User{}, err
}
return GetUserById(app, userId)
return GetUserById(app, session.UserId)
}
// GetUserById finds a User table row in the database by id and returns a struct representing this row
@ -98,7 +94,7 @@ func CreateUser(app *app.App, username string, password string, createdAt time.T
}
// AuthenticateUser validates the password for the specified user
func AuthenticateUser(app *app.App, w http.ResponseWriter, username string, password string) (Session, error) {
func AuthenticateUser(app *app.App, w http.ResponseWriter, username string, password string, remember bool) (Session, error) {
var user User
// Query row by username
@ -114,7 +110,7 @@ func AuthenticateUser(app *app.App, w http.ResponseWriter, username string, pass
log.Println("Authentication error (incorrect password) for user:" + username)
return Session{}, err
} else {
return CreateSession(app, w, user.Id)
return CreateSession(app, w, user.Id, remember)
}
}
@ -133,14 +129,4 @@ func LogoutUser(app *app.App, w http.ResponseWriter, r *http.Request) {
log.Println("Error deleting session by AuthToken")
return
}
// Delete cookie
cookie = &http.Cookie{
Name: "session",
Value: "",
Path: "/",
MaxAge: -1,
}
http.SetCookie(w, cookie)
}

View File

@ -3,6 +3,7 @@ package routes
import (
"GoWeb/app"
"GoWeb/controllers"
"io/fs"
"log"
"net/http"
)
@ -15,12 +16,21 @@ func GetRoutes(app *app.App) {
}
// Serve static files
http.Handle("/file/", http.FileServer(http.Dir("./static")))
log.Println("Serving static files from: ./static")
staticFS, err := fs.Sub(app.Res, "static")
if err != nil {
log.Println(err)
return
}
staticHandler := http.FileServer(http.FS(staticFS))
http.Handle("/static/", http.StripPrefix("/static/", staticHandler))
log.Println("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)
// Files
http.HandleFunc("/uploads", getController.ShowFile)
}

View File

@ -16,4 +16,5 @@ func PostRoutes(app *app.App) {
// User authentication
http.HandleFunc("/register-handle", postController.Register)
http.HandleFunc("/login-handle", postController.Login)
http.HandleFunc("/upload-handle", postController.FileUpload)
}

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,5 +1,25 @@
{{ define "pageTitle" }}Home{{ end }}
{{ define "file-upload" }}
<form
enctype="multipart/form-data"
action="/upload-handle"
method="post"
>
<input type="file" accept="*/*" name="file" />
<input type="submit" value="upload" />
</form>
{{ end }}
{{ define "content" }}
{{ .Test }}
{{ end }}
<!-- Uncomment below to demo file upload system -->
<!-- {{ template "file-upload" . }} -->
<!-- <p>Upload an image called test.jpg to test the file upload system</p> -->
<!-- <img src="/uploads?name=test.jpg" alt=""> -->
{{ end }}
{{ define "content" }}
{{ end }}

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 }}