├── .gitignore ├── README.md ├── cmd ├── gowe-user │ └── adduser.go └── gowe │ └── web.go ├── context └── context.go ├── model └── user.go ├── static └── index.html └── template ├── edit.html ├── list.html └── login.html /.gitignore: -------------------------------------------------------------------------------- 1 | gowe.db -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # If you had not figured it out... 2 | 3 | This is a pretty old project and you should use different and better web frameworks and techniques. 4 | 5 | Gorilla is not maintained any more. This project still showcases the Go style of light weight, no fluff web applications. If you are tired of heavy frameworks for small projects and microservices, Go is still pretty rad and I still enjoy building little web apps with it. You need some more discipline and abstraction on larger more complex web apps, in my opinion, but Go still lets you naturally evolve and lego that together. 6 | 7 | July 2024 8 | 9 | # An example Go web application 10 | 11 | You have been hacking on Go and want to use it for a web application. You want a simple, opinionated, idiomatic (mostly!), and easy to understand Go application that has batteries included: Authentication, sessions, databases, middleware, etc. You are tired of piecing together things from various snippets and poring over other Go code bases. Look no further! 12 | 13 | This is a simple Go web application. It is a skeleton and example that can quickly be customized. It is not a framework. It is not a library. It is meant to be cloned and hacked on, getting an app up and running very quickly. Why? Mostly so I can use this for my own purposes, but it is a really nice starting point. 14 | 15 | Inspired by common Go patterns. Some code borrowed from GoPhish (in particular, the Context package, simple middleware, context handling, and bits of pieces of the Login code). Boiled down to its essence and simplified. This project is ready for hacking. 16 | 17 | No fancy HTML. Everything in one place, easy to see with minimal abstraction. 18 | 19 | You write code. You build it. You get a deploy anywhere on almost any platform web application in one binary. 20 | 21 | ## Demonstrated simply in this app 22 | 23 | * Context passing 24 | * Cookie based session store 25 | * Native templates 26 | * Storm (an ORM based on BoltDB) 27 | * Gorilla packages (mux, session) 28 | * Middleware 29 | * Sessions (flashing messages, authentication) 30 | * Authenticated routes 31 | * Simple old school CRUD interface for Users 32 | 33 | All less than 375 lines of Go. 34 | 35 | ## About Storm and BoltDB 36 | 37 | Bolt is a really nice database. Storm is one of the easiest to use "ORMs" you will find. If you want to get hacking quickly give it a try. If you don't like it throw it out and use something else. 38 | 39 | ## Quick Tour 40 | 41 | There are two commands `gowe` and `gowe-user`. 42 | 43 | Use `gowe-user` to add a new user. 44 | 45 | Use `gowe` to launch gowebexample. 46 | 47 | To install try something like: 48 | 49 | `go get github.com/bitexploder/gowebexample/...` 50 | 51 | Cd to the `gowebexample` directory (where `static` and `template` dirs are) 52 | 53 | Then run: 54 | 55 | `gowe-user -username admin -password admin -name "First Last"` 56 | 57 | `gowe` 58 | 59 | Now visit: 60 | 61 | `http://127.0.0.1:8080` 62 | 63 | And login with username admin password admin. 64 | 65 | That's it! 66 | 67 | ## Implementation Notes 68 | 69 | Everything but the user model and context package lives in: `cmd/gowe/web.go` 70 | 71 | There is a lot left as an exercise to the reader (including tightening up security settings, such as login error messages, cookie settings, and other low hanging fruit.) 72 | 73 | Bcrypt as used is a nice way to store password hashes. The authentication mechanism and authenticated routes should be reasonable sound. 74 | 75 | If you need TLS (you probably do!), just generate a cert and key and modify ListenAndServer to use ListenAndServeTLS. 76 | 77 | If you want to edit and update passwords checkout gowe-user for how to use the bcrypt package and add it to the `EditUserHandler` 78 | 79 | 80 | ## Thoughts on running in production 81 | 82 | All logging goes to stdout. Use tee and output redirection to put it in a file. 83 | 84 | # License 85 | 86 | ~~~~~ 87 | Gowebexample - An example Go application 88 | 89 | The MIT License (MIT) 90 | 91 | Copyright (c) 2017 Jeremy Allen 92 | 93 | Permission is hereby granted, free of charge, to any person obtaining a copy 94 | of this software ("An example Go application") and associated documentation files (the "Software"), to deal 95 | in the Software without restriction, including without limitation the rights 96 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 97 | copies of the Software, and to permit persons to whom the Software is 98 | furnished to do so, subject to the following conditions: 99 | 100 | The above copyright notice and this permission notice shall be included in 101 | all copies or substantial portions of the Software. 102 | 103 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 104 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 105 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 106 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 107 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 108 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 109 | THE SOFTWARE. 110 | 111 | 112 | 113 | (Context and bits and pieces here and there) 114 | Copyright (c) 2013 - 2017 Jordan Wright 115 | 116 | Permission is hereby granted, free of charge, to any person obtaining a copy 117 | of this software ("Gophish Community Edition") and associated documentation files (the "Software"), to deal 118 | in the Software without restriction, including without limitation the rights 119 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 120 | copies of the Software, and to permit persons to whom the Software is 121 | furnished to do so, subject to the following conditions... 122 | ~~~~~ 123 | 124 | 125 | 126 | 127 | -------------------------------------------------------------------------------- /cmd/gowe-user/adduser.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | 7 | "github.com/asdine/storm" 8 | "github.com/bitexploder/gowebexample/model" 9 | "golang.org/x/crypto/bcrypt" 10 | ) 11 | 12 | func main() { 13 | 14 | dbPath := flag.String("dbpath", "gowe.db", "database path") 15 | name := flag.String("name", "First Last", "firstname lastname") 16 | password := flag.String("password", "", "a password...") 17 | username := flag.String("username", "", "a username...") 18 | flag.Parse() 19 | 20 | db, err := storm.Open(*dbPath) 21 | if *username == "" || *password == "" { 22 | fmt.Println("Need username and password") 23 | return 24 | } 25 | 26 | u := model.User{} 27 | 28 | u, err = model.GetUserByUsername(db, *username) 29 | if err == nil { 30 | fmt.Println("Updating user and promoting to admin") 31 | } else { 32 | u.Username = *username 33 | u.Name = *name 34 | u.IsAdmin = true 35 | 36 | fmt.Println("Creating user and promoting to admin") 37 | } 38 | u.IsAdmin = true 39 | 40 | hash, err := bcrypt.GenerateFromPassword([]byte(*password), 10) 41 | if err != nil { 42 | fmt.Printf("err: %s\n", err) 43 | } 44 | u.PassHash = string(hash) 45 | fmt.Printf("%+v\n", u) 46 | if err != nil { 47 | fmt.Printf("err: %s\n", err) 48 | return 49 | } 50 | err = model.UpdateUser(db, u) 51 | if err != nil { 52 | fmt.Printf("err: %s\n") 53 | return 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /cmd/gowe/web.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "flag" 6 | "fmt" 7 | "html/template" 8 | "log" 9 | "net/http" 10 | 11 | "github.com/bitexploder/gowebexample/context" 12 | "github.com/bitexploder/gowebexample/model" 13 | 14 | "github.com/asdine/storm" 15 | "github.com/gorilla/mux" 16 | "github.com/gorilla/sessions" 17 | "golang.org/x/crypto/bcrypt" 18 | "strconv" 19 | ) 20 | 21 | var Db *storm.DB 22 | 23 | var store = sessions.NewCookieStore( 24 | []byte("You probably want to change this"), 25 | []byte("Seriously, I mean it. Change it.")) 26 | 27 | ///// HELPERS 28 | func loadTmpl(path string, data interface{}) (string, error) { 29 | tmpl, err := template.ParseFiles(path) 30 | if err != nil { 31 | log.Errorf("Error parsing template: %s", path) 32 | return "", err 33 | } 34 | 35 | var buf bytes.Buffer 36 | err = tmpl.Execute(&buf, data) 37 | if err != nil { 38 | log.Println(err) 39 | } 40 | 41 | return buf.String(), err 42 | } 43 | 44 | ///// HTTP Handlers 45 | func HomeHandler(config Config) http.HandlerFunc { 46 | return func(w http.ResponseWriter, r *http.Request) { 47 | s, _ := loadTmpl(config.StaticDir+"/index.html", nil) 48 | fmt.Fprint(w, s) 49 | } 50 | } 51 | 52 | func ListUsersHandler(config Config) http.HandlerFunc { 53 | return func(w http.ResponseWriter, r *http.Request) { 54 | users, err := model.GetUsers(Db) 55 | if err != nil { 56 | log.Fprintf(w, "err: %+v\n") 57 | return 58 | } 59 | 60 | s, err := loadTmpl(config.TemplateDir+"/list.html", users) 61 | if err != nil { 62 | fmt.Printf("error loading template: %s\n", err) 63 | http.Error(w, err.Error(), 500) 64 | return 65 | } 66 | 67 | fmt.Fprint(w, s) 68 | } 69 | } 70 | 71 | func intVar(vars map[string]string, k string) int64 { 72 | var vv int64 73 | if v, ok := vars[k]; ok { 74 | vv, _ = strconv.ParseInt(v, 0, 32) 75 | } 76 | return vv 77 | } 78 | 79 | func EditUserHandler(config Config) http.HandlerFunc { 80 | return func(w http.ResponseWriter, r *http.Request) { 81 | var err error 82 | vars := mux.Vars(r) 83 | id := int(intVar(vars, "id")) 84 | user := model.User{} 85 | 86 | if id != 0 { 87 | user, err = model.GetUser(Db, id) 88 | if err != nil { 89 | fmt.Fprintf(w, "err: %s\n", err) 90 | return 91 | } 92 | } 93 | 94 | if r.Method == "POST" { 95 | r.ParseForm() 96 | user.Name = r.Form["name"][0] 97 | user.Email = r.Form["email"][0] 98 | user.Username = r.Form["username"][0] 99 | 100 | err = model.UpdateUser(Db, user) 101 | if err != nil { 102 | fmt.Fprintf(w, "err: %s\n", err) 103 | } 104 | http.Redirect(w, r, "/users", 301) 105 | 106 | } 107 | 108 | s, err := loadTmpl(config.TemplateDir+"/edit.html", user) 109 | if err != nil { 110 | log.Printf("error loading template: %s\n", err) 111 | http.Error(w, err.Error(), 500) 112 | return 113 | } 114 | fmt.Fprint(w, s) 115 | } 116 | } 117 | 118 | func DeleteUserHandler(config Config) http.HandlerFunc { 119 | return func(w http.ResponseWriter, r *http.Request) { 120 | var err error 121 | vars := mux.Vars(r) 122 | id := int(intVar(vars, "id")) 123 | 124 | err = model.DeleteUser(Db, id) 125 | if err != nil { 126 | fmt.Fprintf(w, "err: %s", err) 127 | return 128 | } 129 | 130 | fmt.Fprintf(w, "Deleting user: %d", id) 131 | } 132 | } 133 | 134 | ///// Authentication 135 | func LoginHandler(config Config) http.HandlerFunc { 136 | return func(w http.ResponseWriter, r *http.Request) { 137 | session := context.Get(r, "session").(*sessions.Session) 138 | 139 | loginTmpl := config.TemplateDir + "/login.html" 140 | 141 | params := struct { 142 | Flashes []interface{} 143 | }{} 144 | 145 | if r.Method == "GET" { 146 | params.Flashes = session.Flashes() 147 | s, err := loadTmpl(loginTmpl, params) 148 | if err != nil { 149 | log.Printf("error loading template: %s\n", err) 150 | http.Error(w, err.Error(), 500) 151 | return 152 | } 153 | session.Save(r, w) 154 | fmt.Fprint(w, s) 155 | 156 | } 157 | 158 | if r.Method == "POST" { 159 | r.ParseForm() 160 | username := r.Form["username"][0] 161 | password := r.Form["password"][0] 162 | u, err := model.GetUserByUsername(Db, username) 163 | if err != nil { 164 | session.AddFlash("err: " + err.Error()) 165 | err = session.Save(r, w) 166 | if err != nil { 167 | log.Printf("error saving session: %s\n", err) 168 | } 169 | http.Redirect(w, r, "/login", 301) 170 | return 171 | } 172 | 173 | err = bcrypt.CompareHashAndPassword([]byte(u.PassHash), []byte(password)) 174 | if err != nil { 175 | session.AddFlash("err: " + err.Error()) 176 | err = session.Save(r, w) 177 | if err != nil { 178 | log.Printf("error saving session: %s\n", err) 179 | } 180 | 181 | http.Redirect(w, r, "/login", 301) 182 | return 183 | } 184 | 185 | session.Values["id"] = u.ID 186 | err = session.Save(r, w) 187 | if err != nil { 188 | log.Printf("error saving session: %s\n", err) 189 | } 190 | http.Redirect(w, r, "/", 301) 191 | } 192 | } 193 | } 194 | 195 | func LogoutHandler(config Config) http.HandlerFunc { 196 | return func(w http.ResponseWriter, r *http.Request) { 197 | session := context.Get(r, "session").(*sessions.Session) 198 | delete(session.Values, "id") 199 | session.Save(r, w) 200 | http.Redirect(w, r, "/login", 301) 201 | } 202 | } 203 | 204 | ///// MIDDLEWARE 205 | func Use(handler http.HandlerFunc, mid ...func(http.Handler) http.HandlerFunc) http.HandlerFunc { 206 | for _, m := range mid { 207 | handler = m(handler) 208 | } 209 | return handler 210 | } 211 | 212 | func ContextManager(h http.Handler) http.HandlerFunc { 213 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 214 | session, err := store.Get(r, "gowe") 215 | if err != nil { 216 | log.Printf("ContextManager: err: %s\n", err) 217 | return 218 | } 219 | 220 | r = context.Set(r, "session", session) 221 | 222 | if id, ok := session.Values["id"]; ok { 223 | u, err := model.GetUser(Db, id.(int)) 224 | if err != nil { 225 | r = context.Set(r, "user", nil) 226 | } else { 227 | r = context.Set(r, "user", u) 228 | } 229 | } else { 230 | r = context.Set(r, "user", nil) 231 | } 232 | 233 | h.ServeHTTP(w, r) 234 | 235 | context.Clear(r) 236 | }) 237 | } 238 | 239 | func RequireLogin(h http.Handler) http.HandlerFunc { 240 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 241 | if u := context.Get(r, "user"); u != nil { 242 | h.ServeHTTP(w, r) 243 | } else { 244 | http.Redirect(w, r, "/login", 302) 245 | } 246 | }) 247 | } 248 | 249 | func Logger(h http.Handler) http.HandlerFunc { 250 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 251 | log.Printf("%s", r.URL) 252 | h.ServeHTTP(w, r) 253 | }) 254 | } 255 | 256 | //////////////// 257 | type Config struct { 258 | Listen string 259 | DbPath string 260 | StaticDir string 261 | TemplateDir string 262 | } 263 | 264 | func Web(config Config) { 265 | router := mux.NewRouter() 266 | router.HandleFunc("/", Use(HomeHandler(config), RequireLogin)) 267 | router.HandleFunc("/login", LoginHandler(config)) 268 | router.HandleFunc("/logout", Use(LogoutHandler(config), RequireLogin)) 269 | router.HandleFunc("/users", Use(ListUsersHandler(config), RequireLogin)) 270 | router.HandleFunc("/edit/{id:[0-9]+}", Use(EditUserHandler(config), RequireLogin)) 271 | router.HandleFunc("/edit/{id:[0-9]+}/delete", Use(DeleteUserHandler(config), RequireLogin)) 272 | 273 | router.PathPrefix("/static/").Handler( 274 | http.StripPrefix("/static/", http.FileServer( 275 | http.Dir(config.StaticDir)))) // Is this Lisp? 276 | 277 | h := Use(router.ServeHTTP, Logger, ContextManager) 278 | 279 | log.Printf("Listening on %s\n", config.Listen) 280 | http.ListenAndServe(config.Listen, h) 281 | } 282 | 283 | func main() { 284 | listen := flag.String("listen", "127.0.0.1:8080", "Listen address and port") 285 | dbPath := flag.String("dbpath", "gowe.db", "Database path") 286 | staticDir := flag.String("static", "static", "Static files to serve") 287 | templateDir := flag.String("template", "template", "Template file directory") 288 | flag.Parse() 289 | 290 | c := Config{ 291 | Listen: *listen, 292 | DbPath: *dbPath, 293 | StaticDir: *staticDir, 294 | TemplateDir: *templateDir, 295 | } 296 | 297 | var err error 298 | Db, err = storm.Open(c.DbPath) 299 | if err != nil { 300 | log.Printf("err: %s\n", err) 301 | return 302 | } 303 | 304 | defer Db.Close() 305 | 306 | Web(c) 307 | } 308 | -------------------------------------------------------------------------------- /context/context.go: -------------------------------------------------------------------------------- 1 | package context 2 | 3 | import ( 4 | "net/http" 5 | 6 | "context" 7 | ) 8 | 9 | func Get(r *http.Request, key interface{}) interface{} { 10 | return r.Context().Value(key) 11 | } 12 | 13 | func Set(r *http.Request, key, val interface{}) *http.Request { 14 | if val == nil { 15 | return r 16 | } 17 | 18 | return r.WithContext(context.WithValue(r.Context(), key, val)) 19 | } 20 | 21 | func Clear(r *http.Request) { 22 | return 23 | } 24 | -------------------------------------------------------------------------------- /model/user.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "github.com/asdine/storm" 5 | "time" 6 | ) 7 | 8 | type User struct { 9 | ID int `storm:"id,increment"` 10 | Created time.Time 11 | Name string 12 | Email string 13 | Username string 14 | PassHash string 15 | IsAdmin bool 16 | } 17 | 18 | func GetUser(db *storm.DB, userId int) (User, error) { 19 | var user User 20 | err := db.One("ID", userId, &user) 21 | return user, err 22 | } 23 | 24 | func GetUserByUsername(db *storm.DB, username string) (User, error) { 25 | var user User 26 | err := db.One("Username", username, &user) 27 | return user, err 28 | } 29 | 30 | func GetUsers(db *storm.DB) ([]User, error) { 31 | var users []User 32 | err := db.All(&users) 33 | return users, err 34 | } 35 | 36 | func CreateUser(db *storm.DB, user User) error { 37 | err := db.Save(&user) 38 | return err 39 | } 40 | 41 | func UpdateUser(db *storm.DB, user User) error { 42 | err := db.Save(&user) 43 | return err 44 | } 45 | 46 | func DeleteUser(db *storm.DB, userId int) error { 47 | u, err := GetUser(db, userId) 48 | if err != nil { 49 | return err 50 | } 51 | err = db.DeleteStruct(&u) 52 | return err 53 | } 54 | -------------------------------------------------------------------------------- /static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /template/edit.html: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 |
Go Home
9 |
10 |
Name
11 |
12 |
Email
13 |
14 |
Username
15 |
16 | 17 |
 
18 |
19 |
20 | 21 | 22 | -------------------------------------------------------------------------------- /template/list.html: -------------------------------------------------------------------------------- 1 | 2 | 14 | 15 |
Go Home
16 |
User Count: {{ . | len }}
17 |
Add User
18 | 19 | 20 | 21 | 24 | 27 | 30 | 33 | 36 | 39 | 40 | {{ range $key := . }} 41 | 42 | 45 | 48 | 51 | 54 | 57 | 60 | 61 | {{ end }} 62 |
22 |   23 | 25 | Name 26 | 28 | Email 29 | 31 | Username 32 | 34 | Created 35 | 37 | Delete 38 |
43 | Edit 44 | 46 | [{{ $key.ID}}] {{ $key.Name }} 47 | 49 | {{ $key.Email }} 50 | 52 | {{ $key.Username }} 53 | 55 | {{ $key.Created }} 56 | 58 | DELETE 59 |
63 | 64 | 65 | -------------------------------------------------------------------------------- /template/login.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 |
5 | 10 |
11 |
12 | Username:
13 |
14 | Password:
15 |
16 |
17 | 18 |
19 |
20 | 21 | 22 | --------------------------------------------------------------------------------