From 0bc80e3cd7360ce40c6785c9cb9141526ebd36f9 Mon Sep 17 00:00:00 2001 From: Drew Bednar Date: Sun, 12 Jan 2025 08:12:23 -0500 Subject: [PATCH 01/12] Actually following lets-go book --- .air.toml | 52 +++++++++++++ .gitignore | 1 + README.md | 2 + cmd/ratchetd/main.go | 10 ++- error.go => internal/error.go | 0 error_test.go => internal/error_test.go | 0 internal/server.go | 97 +++++++++++++++++++++++++ user.go => internal/user.go | 0 user_test.go => internal/user_test.go | 0 ui/html/base.go.tmpl | 19 +++++ ui/html/pages/home.go.tmpl | 6 ++ ui/html/partials/nav.go.tmpl | 5 ++ 12 files changed, 189 insertions(+), 3 deletions(-) create mode 100644 .air.toml rename error.go => internal/error.go (100%) rename error_test.go => internal/error_test.go (100%) create mode 100644 internal/server.go rename user.go => internal/user.go (100%) rename user_test.go => internal/user_test.go (100%) create mode 100644 ui/html/base.go.tmpl create mode 100644 ui/html/pages/home.go.tmpl create mode 100644 ui/html/partials/nav.go.tmpl diff --git a/.air.toml b/.air.toml new file mode 100644 index 0000000..d435769 --- /dev/null +++ b/.air.toml @@ -0,0 +1,52 @@ +root = "." +testdata_dir = "testdata" +tmp_dir = "tmp" + +[build] + args_bin = [] + bin = "./tmp/main" + cmd = "go build -o ./tmp/main cmd/ratchetd/main.go" + delay = 1000 + exclude_dir = ["assets", "tmp", "vendor", "testdata"] + exclude_file = [] + exclude_regex = ["_test.go"] + exclude_unchanged = false + follow_symlink = false + full_bin = "" + include_dir = [] + include_ext = ["go", "tpl", "tmpl", "html", "go.tmpl"] + include_file = [] + kill_delay = "0s" + log = "build-errors.log" + poll = false + poll_interval = 0 + post_cmd = [] + pre_cmd = [] + rerun = false + rerun_delay = 500 + send_interrupt = false + stop_on_error = false + +[color] + app = "" + build = "yellow" + main = "magenta" + runner = "green" + watcher = "cyan" + +[log] + main_only = false + silent = false + time = false + +[misc] + clean_on_exit = false + +[proxy] + app_port = 0 + enabled = false + proxy_port = 0 + +[screen] + clear_on_rebuild = false + keep_scroll = true diff --git a/.gitignore b/.gitignore index adf8f72..26ef082 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,4 @@ # Go workspace file go.work +tmp/ \ No newline at end of file diff --git a/README.md b/README.md index a554b37..180ec69 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,8 @@ An example web application in Golang. +https://lets-go.alexedwards.net/sample/02.09-serving-static-files.html + ## Project Structure Loosely inspired by the organization of [WTFDial](https://github.com/benbjohnson/wtf?tab=readme-ov-file#project-structure), diff --git a/cmd/ratchetd/main.go b/cmd/ratchetd/main.go index b2b8c6e..df49678 100644 --- a/cmd/ratchetd/main.go +++ b/cmd/ratchetd/main.go @@ -1,11 +1,12 @@ package main import ( - "fmt" - "os" + "log" + "net/http" "strings" "git.runcible.io/learning/ratchet" + ratchethttp "git.runcible.io/learning/ratchet/internal" ) var ( @@ -17,5 +18,8 @@ func main() { // Propagate build information to root package to share globally ratchet.Version = strings.TrimPrefix(version, "") ratchet.Commit = commit - fmt.Fprintf(os.Stdout, "Version: %s\nCommit: %s\n", ratchet.Version, ratchet.Commit) + + server := ratchethttp.NewRatchetServer() + log.Print("Listening on http://localhost:5001/") + log.Fatal(http.ListenAndServe(":5001", server)) } diff --git a/error.go b/internal/error.go similarity index 100% rename from error.go rename to internal/error.go diff --git a/error_test.go b/internal/error_test.go similarity index 100% rename from error_test.go rename to internal/error_test.go diff --git a/internal/server.go b/internal/server.go new file mode 100644 index 0000000..1b72444 --- /dev/null +++ b/internal/server.go @@ -0,0 +1,97 @@ +package ratchet + +import ( + "fmt" + "html/template" + "log" + "net/http" + "strconv" +) + +type RatchetServer struct { + http.Handler + + //Services used by HTTP routes + + UserService UserService +} + +func NewRatchetServer() *RatchetServer { + r := new(RatchetServer) + + router := http.NewServeMux() + // /{$} is used to prevent subtree path patterns from acting like a wildcard + // resulting in this route requiring an exact match on "/" only + // You can only include one HTTP method in a route pattern if you choose + // GET will match GET & HEAD http request methods + router.HandleFunc("GET /{$}", home) + router.HandleFunc("GET /snippet/view/{id}", snippetView) + router.HandleFunc("GET /snippet/create", snippetCreate) + router.HandleFunc("POST /snippet/create", snippetCreatePost) + r.Handler = router + return r +} + +func home(w http.ResponseWriter, r *http.Request) { + w.Header().Add("Server", "Go") + + // Initialize a slice containing the paths to the two files. It's important + // to note that the file containing our base template must be the *first* + // file in the slice. + files := []string{ + "./ui/html/base.go.tmpl", + "./ui/html/partials/nav.go.tmpl", + "./ui/html/pages/home.go.tmpl", + } + + // read template file into template set. + ts, err := template.ParseFiles(files...) + if err != nil { + log.Print(err.Error()) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + // Write template content to response body + err = ts.ExecuteTemplate(w, "base", nil) + if err != nil { + log.Print(err.Error()) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + } +} + +func snippetView(w http.ResponseWriter, r *http.Request) { + + id, err := strconv.Atoi(r.PathValue("id")) + if err != nil || id < 1 { + http.NotFound(w, r) + return + } + + // Set a new cache-control header. If an existing "Cache-Control" header exists + // it will be overwritten. + w.Header().Set("Cache-Control", "public, max-age=31536000") + + // msg := fmt.Sprintf("Snippet %d...", id) + + // w.Write([]byte(msg)) + + // we can rely on the Write() interface to use a differnent + // function to write out our response + + fmt.Fprintf(w, "Snippet %d...", id) +} + +// snippetCreate handles display of the form used to create snippets +func snippetCreate(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("Displaying snippetCreate form...")) +} + +// snippetCreate handles display of the form used to create snippets +func snippetCreatePost(w http.ResponseWriter, r *http.Request) { + // example of a custom header. Must be done before calling WriteHeader + // or they will fail to take effect. + w.Header().Add("Server", "Dirp") + w.WriteHeader(http.StatusCreated) + + w.Write([]byte("Created snippet...")) +} diff --git a/user.go b/internal/user.go similarity index 100% rename from user.go rename to internal/user.go diff --git a/user_test.go b/internal/user_test.go similarity index 100% rename from user_test.go rename to internal/user_test.go diff --git a/ui/html/base.go.tmpl b/ui/html/base.go.tmpl new file mode 100644 index 0000000..b017bf7 --- /dev/null +++ b/ui/html/base.go.tmpl @@ -0,0 +1,19 @@ +{{define "base" -}} + + + + + {{template "title" .}} + + +
+

Ratchet

+
+ {{template "nav" .}} +
+ {{template "main" .}} +
+ + + +{{end}} \ No newline at end of file diff --git a/ui/html/pages/home.go.tmpl b/ui/html/pages/home.go.tmpl new file mode 100644 index 0000000..4925fb7 --- /dev/null +++ b/ui/html/pages/home.go.tmpl @@ -0,0 +1,6 @@ +{{define "title"}}Home{{end}} + +{{define "main" -}} +

Latest Snippets

+

There's nothing to see yet!

+{{- end -}} diff --git a/ui/html/partials/nav.go.tmpl b/ui/html/partials/nav.go.tmpl new file mode 100644 index 0000000..256e49a --- /dev/null +++ b/ui/html/partials/nav.go.tmpl @@ -0,0 +1,5 @@ +{{define "nav" -}} + +{{- end}} \ No newline at end of file From ff81b307e38f99ddefc2998165b0cb1508051028 Mon Sep 17 00:00:00 2001 From: Drew Bednar Date: Sun, 12 Jan 2025 11:19:46 -0500 Subject: [PATCH 02/12] Added structured logging --- README.md | 43 ++++++ cmd/ratchetd/main.go | 25 ++- internal/server.go | 50 +++++- ui/html/base.go.tmpl | 5 + ui/static/css/main.css | 313 ++++++++++++++++++++++++++++++++++++++ ui/static/img/favicon.ico | Bin 0 -> 1150 bytes ui/static/img/logo.png | Bin 0 -> 1075 bytes ui/static/js/main.js | 8 + 8 files changed, 437 insertions(+), 7 deletions(-) create mode 100755 ui/static/css/main.css create mode 100755 ui/static/img/favicon.ico create mode 100755 ui/static/img/logo.png create mode 100755 ui/static/js/main.js diff --git a/README.md b/README.md index 180ec69..7a08fd2 100644 --- a/README.md +++ b/README.md @@ -41,3 +41,46 @@ You can build `ratchet` locally by cloning the respository, then run The `ratchetd` cmd binary uses Oauth so you will need to create a new Oauth App. The vlaue of the authorization callback must be the hostname and IP at which clients can access the `ratchetd` server. +## Additional Resources + +- [Content Range Requests](https://web.archive.org/web/20230918195519/https://benramsey.com/blog/2008/05/206-partial-content-and-range-requests/) +- [HTTP 204 and 205 Status Codes](https://web.archive.org/web/20230918193536/https://benramsey.com/blog/2008/05/http-status-204-no-content-and-205-reset-content/) +- [How to Disable FileServer Directory Listings](https://www.alexedwards.net/blog/disable-http-fileserver-directory-listings) +- [Understand Mutexs in Go](https://www.alexedwards.net/blog/understanding-mutexes) +- [Structured Logging in Go with log/slog](https://pkg.go.dev/log/slog) +- [Exit Codes with special meaning](https://tldp.org/LDP/abs/html/exitcodes.html) + +### Go http.FileServer + +Supports If-Modified-Since and Last-Modified headers +``` +curl -i -H "If-Modified-Since: +Thu, 04 May 2017 13:07:52 GMT" http://localhost:5001/static/img/logo.png +HTTP/1.1 304 Not Modified +Last-Modified: Thu, 04 May 2017 13:07:52 GMT +Date: Sun, 12 Jan 2025 14:26:06 GMT +``` + +Supports Range Requests and 206 Partial Content responses. +``` +curl -i -H "Range: bytes=100-199" --output - http://localhost:5001/static/img/logo.png +HTTP/1.1 206 Partial Content +Accept-Ranges: bytes +Content-Length: 100 +Content-Range: bytes 100-199/1075 +Content-Type: image/png +Last-Modified: Thu, 04 May 2017 13:07:52 GMT +Date: Sun, 12 Jan 2025 14:18:32 GMT +``` + +- The `Content-Type` is automatically set from the file extension using the `mime.TypeByExtension()` function. You can add your own custom extensions and content types using the `mime.AddExtensionType()` function if necessary. + +- Sometimes you might want to serve a single file from within a handler. For this there’s the `http.ServeFile()` function, which you can use like so: + +```go +func downloadHandler(w http.ResponseWriter, r *http.Request) { + http.ServeFile(w, r, "./ui/static/file.zip") +} +``` + +Warning: http.ServeFile() does not automatically sanitize the file path. If you’re constructing a file path from untrusted user input, to avoid directory traversal attacks you must sanitize the input with filepath.Clean() before using it. \ No newline at end of file diff --git a/cmd/ratchetd/main.go b/cmd/ratchetd/main.go index df49678..114b52f 100644 --- a/cmd/ratchetd/main.go +++ b/cmd/ratchetd/main.go @@ -1,8 +1,11 @@ package main import ( - "log" + "flag" + "fmt" + "log/slog" "net/http" + "os" "strings" "git.runcible.io/learning/ratchet" @@ -15,11 +18,27 @@ var ( ) func main() { + // Commandline options + addr := flag.String("addr", "0.0.0.0", "HTTP network address") + port := flag.String("port", "5001", "HTTP port") + logLevel := flag.String("logging", "INFO", "Logging Level. Valid values [INFO, DEBUG, WARN, ERROR].") + // must call parse or all values will be the defaults + flag.Parse() + + // Setup Logging + ratchethttp.InitLogging(*logLevel) + // Propagate build information to root package to share globally ratchet.Version = strings.TrimPrefix(version, "") ratchet.Commit = commit server := ratchethttp.NewRatchetServer() - log.Print("Listening on http://localhost:5001/") - log.Fatal(http.ListenAndServe(":5001", server)) + slog.Debug("Herp dirp!") + slog.Info(fmt.Sprintf("Listening on http://%s:%s", *addr, *port)) + //log.Fatal(http.ListenAndServe(fmt.Sprintf("%s:%s", *addr, *port), server)) + // there is no log.Fatal equivalent. This is an approximation of the behavior + err := http.ListenAndServe(fmt.Sprintf("%s:%s", *addr, *port), server) + slog.Error(err.Error()) + os.Exit(1) + } diff --git a/internal/server.go b/internal/server.go index 1b72444..d0775a2 100644 --- a/internal/server.go +++ b/internal/server.go @@ -3,23 +3,57 @@ package ratchet import ( "fmt" "html/template" - "log" + "log/slog" "net/http" + "os" "strconv" + "strings" ) type RatchetServer struct { http.Handler //Services used by HTTP routes - UserService UserService } +func parseLogLevel(levelStr string) slog.Level { + switch strings.ToUpper(levelStr) { + case "DEBUG": + return slog.LevelDebug + case "INFO": + return slog.LevelInfo + case "WARN": + return slog.LevelWarn + case "ERROR": + return slog.LevelError + default: + return slog.LevelInfo // Default level + } +} + +func InitLogging(level string) { + // Use os.Stderr + // + // Stderr is used for diagnostics and logging. Stdout is used for program + // output. Stderr also have greater likely hood of being seen if a programs + // output is being redirected. + parsedLogLevel := parseLogLevel(level) + loggerHandler := slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: parsedLogLevel, AddSource: true}) + logger := slog.New(loggerHandler) + slog.SetDefault(logger) +} + func NewRatchetServer() *RatchetServer { r := new(RatchetServer) + // TODO implement middleware that disables directory listings + fileServer := http.FileServer(http.Dir("./ui/static/")) router := http.NewServeMux() + + // Subtree pattern for static assets + router.Handle("GET /static/", http.StripPrefix("/static/", fileServer)) + // /{$} is used to prevent subtree path patterns from acting like a wildcard // resulting in this route requiring an exact match on "/" only // You can only include one HTTP method in a route pattern if you choose @@ -27,12 +61,20 @@ func NewRatchetServer() *RatchetServer { router.HandleFunc("GET /{$}", home) router.HandleFunc("GET /snippet/view/{id}", snippetView) router.HandleFunc("GET /snippet/create", snippetCreate) + + // FYI The http.HandlerFunc() adapter works by automatically adding a ServeHTTP() method to + // the passed function router.HandleFunc("POST /snippet/create", snippetCreatePost) + + // Mux Router implements the Handler interface. AKA it has a ServeHTTP receiver. r.Handler = router return r } func home(w http.ResponseWriter, r *http.Request) { + // TODO middleware should be able to print out these lines for all routes + slog.Info("request received", "method", "GET", "path", "/") + w.Header().Add("Server", "Go") // Initialize a slice containing the paths to the two files. It's important @@ -47,14 +89,14 @@ func home(w http.ResponseWriter, r *http.Request) { // read template file into template set. ts, err := template.ParseFiles(files...) if err != nil { - log.Print(err.Error()) + slog.Error(err.Error()) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } // Write template content to response body err = ts.ExecuteTemplate(w, "base", nil) if err != nil { - log.Print(err.Error()) + slog.Error(err.Error()) http.Error(w, "Internal Server Error", http.StatusInternalServerError) } } diff --git a/ui/html/base.go.tmpl b/ui/html/base.go.tmpl index b017bf7..09d86e4 100644 --- a/ui/html/base.go.tmpl +++ b/ui/html/base.go.tmpl @@ -4,6 +4,10 @@ {{template "title" .}} + + + +
@@ -14,6 +18,7 @@ {{template "main" .}}
Powered by Go
+ {{end}} \ No newline at end of file diff --git a/ui/static/css/main.css b/ui/static/css/main.css new file mode 100755 index 0000000..52e00f4 --- /dev/null +++ b/ui/static/css/main.css @@ -0,0 +1,313 @@ +* { + box-sizing: border-box; + margin: 0; + padding: 0; + font-size: 18px; + font-family: "Ubuntu Mono", monospace; +} + +html, body { + height: 100%; +} + +body { + line-height: 1.5; + background-color: #F1F3F6; + color: #34495E; + overflow-y: scroll; +} + +header, nav, main, footer { + padding: 2px calc((100% - 800px) / 2) 0; +} + +main { + margin-top: 54px; + margin-bottom: 54px; + min-height: calc(100vh - 345px); + overflow: auto; +} + +h1 a { + font-size: 36px; + font-weight: bold; + background-image: url("/static/img/logo.png"); + background-repeat: no-repeat; + background-position: 0px 0px; + height: 36px; + padding-left: 50px; + position: relative; +} + +h1 a:hover { + text-decoration: none; + color: #34495E; +} + +h2 { + font-size: 22px; + margin-bottom: 36px; + position: relative; + top: -9px; +} + +a { + color: #62CB31; + text-decoration: none; +} + +a:hover { + color: #4EB722; + text-decoration: underline; +} + +textarea, input:not([type="submit"]) { + font-size: 18px; + font-family: "Ubuntu Mono", monospace; +} + +header { + background-image: -webkit-linear-gradient(left, #34495e, #34495e 25%, #9b59b6 25%, #9b59b6 35%, #3498db 35%, #3498db 45%, #62cb31 45%, #62cb31 55%, #ffb606 55%, #ffb606 65%, #e67e22 65%, #e67e22 75%, #e74c3c 85%, #e74c3c 85%, #c0392b 85%, #c0392b 100%); + background-image: -moz-linear-gradient(left, #34495e, #34495e 25%, #9b59b6 25%, #9b59b6 35%, #3498db 35%, #3498db 45%, #62cb31 45%, #62cb31 55%, #ffb606 55%, #ffb606 65%, #e67e22 65%, #e67e22 75%, #e74c3c 85%, #e74c3c 85%, #c0392b 85%, #c0392b 100%); + background-image: -ms-linear-gradient(left, #34495e, #34495e 25%, #9b59b6 25%, #9b59b6 35%, #3498db 35%, #3498db 45%, #62cb31 45%, #62cb31 55%, #ffb606 55%, #ffb606 65%, #e67e22 65%, #e67e22 75%, #e74c3c 85%, #e74c3c 85%, #c0392b 85%, #c0392b 100%); + background-image: linear-gradient(to right, #34495e, #34495e 25%, #9b59b6 25%, #9b59b6 35%, #3498db 35%, #3498db 45%, #62cb31 45%, #62cb31 55%, #ffb606 55%, #ffb606 65%, #e67e22 65%, #e67e22 75%, #e74c3c 85%, #e74c3c 85%, #c0392b 85%, #c0392b 100%); + background-size: 100% 6px; + background-repeat: no-repeat; + border-bottom: 1px solid #E4E5E7; + overflow: auto; + padding-top: 33px; + padding-bottom: 27px; + text-align: center; +} + +header a { + color: #34495E; + text-decoration: none; +} + +nav { + border-bottom: 1px solid #E4E5E7; + padding-top: 17px; + padding-bottom: 15px; + background: #F7F9FA; + height: 60px; + color: #6A6C6F; +} + +nav a { + margin-right: 1.5em; + display: inline-block; +} + +nav form { + display: inline-block; + margin-left: 1.5em; +} + +nav div { + width: 50%; + float: left; +} + +nav div:last-child { + text-align: right; +} + +nav div:last-child a { + margin-left: 1.5em; + margin-right: 0; +} + +nav a.live { + color: #34495E; + cursor: default; +} + +nav a.live:hover { + text-decoration: none; +} + +nav a.live:after { + content: ''; + display: block; + position: relative; + left: calc(50% - 7px); + top: 9px; + width: 14px; + height: 14px; + background: #F7F9FA; + border-left: 1px solid #E4E5E7; + border-bottom: 1px solid #E4E5E7; + -moz-transform: rotate(45deg); + -webkit-transform: rotate(-45deg); +} + +a.button, input[type="submit"] { + background-color: #62CB31; + border-radius: 3px; + color: #FFFFFF; + padding: 18px 27px; + border: none; + display: inline-block; + margin-top: 18px; + font-weight: 700; +} + +a.button:hover, input[type="submit"]:hover { + background-color: #4EB722; + color: #FFFFFF; + cursor: pointer; + text-decoration: none; +} + +form div { + margin-bottom: 18px; +} + +form div:last-child { + border-top: 1px dashed #E4E5E7; +} + +form input[type="radio"] { + margin-left: 18px; +} + +form input[type="text"], form input[type="password"], form input[type="email"] { + padding: 0.75em 18px; + width: 100%; +} + +form input[type=text], form input[type="password"], form input[type="email"], textarea { + color: #6A6C6F; + background: #FFFFFF; + border: 1px solid #E4E5E7; + border-radius: 3px; +} + +form label { + display: inline-block; + margin-bottom: 9px; +} + +.error { + color: #C0392B; + font-weight: bold; + display: block; +} + +.error + textarea, .error + input { + border-color: #C0392B !important; + border-width: 2px !important; +} + +textarea { + padding: 18px; + width: 100%; + height: 266px; +} + +button { + background: none; + padding: 0; + border: none; + color: #62CB31; + text-decoration: none; +} + +button:hover { + color: #4EB722; + text-decoration: underline; + cursor: pointer; +} + +.snippet { + background-color: #FFFFFF; + border: 1px solid #E4E5E7; + border-radius: 3px; +} + +.snippet pre { + padding: 18px; + border-top: 1px solid #E4E5E7; + border-bottom: 1px solid #E4E5E7; +} + +.snippet .metadata { + background-color: #F7F9FA; + color: #6A6C6F; + padding: 0.75em 18px; + overflow: auto; +} + +.snippet .metadata span { + float: right; +} + +.snippet .metadata strong { + color: #34495E; +} + +.snippet .metadata time { + display: inline-block; +} + +.snippet .metadata time:first-child { + float: left; +} + +.snippet .metadata time:last-child { + float: right; +} + +div.flash { + color: #FFFFFF; + font-weight: bold; + background-color: #34495E; + padding: 18px; + margin-bottom: 36px; + text-align: center; +} + +div.error { + color: #FFFFFF; + background-color: #C0392B; + padding: 18px; + margin-bottom: 36px; + font-weight: bold; + text-align: center; +} + +table { + background: white; + border: 1px solid #E4E5E7; + border-collapse: collapse; + width: 100%; +} + +td, th { + text-align: left; + padding: 9px 18px; +} + +th:last-child, td:last-child { + text-align: right; + color: #6A6C6F; +} + +tr { + border-bottom: 1px solid #E4E5E7; +} + +tr:nth-child(2n) { + background-color: #F7F9FA; +} + +footer { + border-top: 1px solid #E4E5E7; + padding-top: 17px; + padding-bottom: 15px; + background: #F7F9FA; + height: 60px; + color: #6A6C6F; + text-align: center; +} diff --git a/ui/static/img/favicon.ico b/ui/static/img/favicon.ico new file mode 100755 index 0000000000000000000000000000000000000000..739e573d2877f4ef267f20783bf8b86b046f568a GIT binary patch literal 1150 zcmcIiJxc>Y5S^$f8b4wX8w*85Bw!%fJ2jOjin%>wc||bUixGm>{(z`hh*;QI+FAzu z0}|~8MFdN2(pYFE34YF8a&dF!#Kt?`&Fsv3vpcr~;KMf%0Q`=?dK_`9F($#qndOe&>umR*ABi<388G}vc7FZwoRj@)T z9v!O-YL01!+S8nEBIoIw+e~lQDO+6GHMLN;QwKFSvd-~;?(dw#wk1hlJ6}V-$@v@e z|E(8#G`zy6d!2sV_Lu!D!cg{e3D!j%i?a>XKK@aE>fDiO#yaW#sK#1678C|M!Wyqm z9g5RKYuw{DW2kyH0zOMuk{EwLln~i`9$F}e{e*;WiZ$3^6kea5MHn+5m+Ojs{r literal 0 HcmV?d00001 diff --git a/ui/static/img/logo.png b/ui/static/img/logo.png new file mode 100755 index 0000000000000000000000000000000000000000..4681437626a70fac0f4ccbe8a8625d568ca0c1a9 GIT binary patch literal 1075 zcmV-31kC%1P)P*ITtQ>;|2+IXQ*O+y7S_>k--pe`uby1NOXt@xlO*##3KULyD)S_Dbd zCYx@vb9_j*Y?5!UCVA@bX?Nz#`Tyse`Q|$#6i9VL%TCn3W8sz}<u9$&p zPgA4936MK-Ogd%Z*j!nxb0QFk7i^%)$jo)?=u=mS?8m`wf|dqF_%R2eH(Wji41lBz05#$L(5LZeu{GY3JWjg zJZkojhI`Hdm}INk#LXz1S6LjO#FAK_{Bvb}f1e!tdDETSKQhML1XQeo@I2^;T0H+? zG}u3$Y;@d&)%DGLRJ0S=z7om;XRMgENU&$zq0gC`nwsJn*R78$`X;ckK9!UUUlBRqx%M5UH0c zkJqyBBtEy+wnqp0CY=VjYg@L8NpvDopFvnr`ox$QMuI)(9VhgFcXK?U#{_vqU|Om? zUQOwDd{mvY8*0N1cE>#3y%lKdq?$88&8?*#&ZM22KX75)|E z@Mx&l4>;voFfG!x^o+@5AQ=+1HqjOx==;hsaYM_d+1Z&36XyaKocb&GHf<}i(y37A zm_56J1r$z~sCn+2aL=#VVqww7&G;?J*}!FH1y0V!VsE6IvDFe``r%9R=161shl?t{S*%z>wzrUJDe|2=Xql?1Qrg#1&4Mkx=hJO_;k; z4X>HBFeFwgBOzZ$F+gOn=Lf)(?-i^7ezHP)H0V2>Y*@4jM?(Hnv9h^sBD4Yjtf26; z!i&?C??@E~hZ^tlWwOg%_vj~LStm&;=2uF+7hFBfru{{V*OjVckBO>O`H002ovPDHLkV1gQd^sE2? literal 0 HcmV?d00001 diff --git a/ui/static/js/main.js b/ui/static/js/main.js new file mode 100755 index 0000000..886bacb --- /dev/null +++ b/ui/static/js/main.js @@ -0,0 +1,8 @@ +var navLinks = document.querySelectorAll("nav a"); +for (var i = 0; i < navLinks.length; i++) { + var link = navLinks[i] + if (link.getAttribute('href') == window.location.pathname) { + link.classList.add("live"); + break; + } +} \ No newline at end of file From 012ae2783e930ff90061b558f2e1065067357c21 Mon Sep 17 00:00:00 2001 From: Drew Bednar Date: Sun, 12 Jan 2025 14:23:51 -0500 Subject: [PATCH 03/12] Refactored internals more --- README.md | 1 + cmd/ratchetd/main.go | 28 ++++--- internal/{error.go => apperror/apperror.go} | 2 +- .../apperror_test.go} | 2 +- internal/{ => domain/user}/user.go | 6 +- internal/{ => domain/user}/user_test.go | 10 ++- internal/logging/logging.go | 36 +++++++++ internal/{server.go => server/base_server.go} | 76 +++++++------------ internal/server/helpers.go | 29 +++++++ 9 files changed, 121 insertions(+), 69 deletions(-) rename internal/{error.go => apperror/apperror.go} (99%) rename internal/{error_test.go => apperror/apperror_test.go} (98%) rename internal/{ => domain/user}/user.go (94%) rename internal/{ => domain/user}/user_test.go (70%) create mode 100644 internal/logging/logging.go rename internal/{server.go => server/base_server.go} (59%) create mode 100644 internal/server/helpers.go diff --git a/README.md b/README.md index 7a08fd2..f995bd6 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,7 @@ The `ratchetd` cmd binary uses Oauth so you will need to create a new Oauth App. - [Understand Mutexs in Go](https://www.alexedwards.net/blog/understanding-mutexes) - [Structured Logging in Go with log/slog](https://pkg.go.dev/log/slog) - [Exit Codes with special meaning](https://tldp.org/LDP/abs/html/exitcodes.html) +- [Organizing Database Access in Go](https://www.alexedwards.net/blog/organising-database-access) ### Go http.FileServer diff --git a/cmd/ratchetd/main.go b/cmd/ratchetd/main.go index 114b52f..898765a 100644 --- a/cmd/ratchetd/main.go +++ b/cmd/ratchetd/main.go @@ -6,33 +6,37 @@ import ( "log/slog" "net/http" "os" - "strings" - "git.runcible.io/learning/ratchet" - ratchethttp "git.runcible.io/learning/ratchet/internal" + "git.runcible.io/learning/ratchet/internal/logging" + "git.runcible.io/learning/ratchet/internal/server" + // "git.runcible.io/learning/ratchet" + // ratchethttp "git.runcible.io/learning/ratchet/internal" ) -var ( - version string - commit string -) +// var ( +// version string +// commit string +// ) func main() { - // Commandline options + // CONFIGURATION + // Parse command line options addr := flag.String("addr", "0.0.0.0", "HTTP network address") port := flag.String("port", "5001", "HTTP port") logLevel := flag.String("logging", "INFO", "Logging Level. Valid values [INFO, DEBUG, WARN, ERROR].") // must call parse or all values will be the defaults flag.Parse() + // DEPENDENCY INJECTION FOR HANDLERS // Setup Logging - ratchethttp.InitLogging(*logLevel) + logger := logging.InitLogging(*logLevel) // Propagate build information to root package to share globally - ratchet.Version = strings.TrimPrefix(version, "") - ratchet.Commit = commit + // ratchet.Version = strings.TrimPrefix(version, "") + // ratchet.Commit = commit + server := server.NewRatchetServer(logger) - server := ratchethttp.NewRatchetServer() + // START SERVING REQUESTS slog.Debug("Herp dirp!") slog.Info(fmt.Sprintf("Listening on http://%s:%s", *addr, *port)) //log.Fatal(http.ListenAndServe(fmt.Sprintf("%s:%s", *addr, *port), server)) diff --git a/internal/error.go b/internal/apperror/apperror.go similarity index 99% rename from internal/error.go rename to internal/apperror/apperror.go index 42daca0..6560556 100644 --- a/internal/error.go +++ b/internal/apperror/apperror.go @@ -1,4 +1,4 @@ -package ratchet +package apperror import ( "errors" diff --git a/internal/error_test.go b/internal/apperror/apperror_test.go similarity index 98% rename from internal/error_test.go rename to internal/apperror/apperror_test.go index 20917c0..ea4b633 100644 --- a/internal/error_test.go +++ b/internal/apperror/apperror_test.go @@ -1,4 +1,4 @@ -package ratchet +package apperror import ( "errors" diff --git a/internal/user.go b/internal/domain/user/user.go similarity index 94% rename from internal/user.go rename to internal/domain/user/user.go index aa726f1..90b991b 100644 --- a/internal/user.go +++ b/internal/domain/user/user.go @@ -1,8 +1,10 @@ -package ratchet +package user import ( "context" "time" + + "git.runcible.io/learning/ratchet/internal/apperror" ) type User struct { @@ -27,7 +29,7 @@ type User struct { func (u *User) Validate() error { if u.Name == "" { - return Errorf(EINVALID, "User name required.") + return apperror.Errorf(apperror.EINVALID, "User name required.") } return nil } diff --git a/internal/user_test.go b/internal/domain/user/user_test.go similarity index 70% rename from internal/user_test.go rename to internal/domain/user/user_test.go index f124f67..c3c70e8 100644 --- a/internal/user_test.go +++ b/internal/domain/user/user_test.go @@ -1,11 +1,15 @@ -package ratchet +package user -import "testing" +import ( + "testing" + + "git.runcible.io/learning/ratchet/internal/apperror" +) func TestUserValidation(t *testing.T) { t.Run("user should return invalid", func(t *testing.T) { u := &User{} - if ErrorCode(u.Validate()) != EINVALID { + if apperror.ErrorCode(u.Validate()) != apperror.EINVALID { t.Errorf("User validation should have failed but passed instead.") } }) diff --git a/internal/logging/logging.go b/internal/logging/logging.go new file mode 100644 index 0000000..18c38c6 --- /dev/null +++ b/internal/logging/logging.go @@ -0,0 +1,36 @@ +package logging + +import ( + "log/slog" + "os" + "strings" +) + +func parseLogLevel(levelStr string) slog.Level { + switch strings.ToUpper(levelStr) { + case "DEBUG": + return slog.LevelDebug + case "INFO": + return slog.LevelInfo + case "WARN": + return slog.LevelWarn + case "ERROR": + return slog.LevelError + default: + return slog.LevelInfo // Default level + } +} + +// InitLogggin initializes global structured logging for the entire application +func InitLogging(level string) *slog.Logger { + // Use os.Stderr + // + // Stderr is used for diagnostics and logging. Stdout is used for program + // output. Stderr also have greater likely hood of being seen if a programs + // output is being redirected. + parsedLogLevel := parseLogLevel(level) + loggerHandler := slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: parsedLogLevel, AddSource: true}) + logger := slog.New(loggerHandler) + slog.SetDefault(logger) + return logger +} diff --git a/internal/server.go b/internal/server/base_server.go similarity index 59% rename from internal/server.go rename to internal/server/base_server.go index d0775a2..c9219b8 100644 --- a/internal/server.go +++ b/internal/server/base_server.go @@ -1,52 +1,26 @@ -package ratchet +package server import ( "fmt" "html/template" "log/slog" "net/http" - "os" "strconv" - "strings" + + "git.runcible.io/learning/ratchet/internal/domain/user" ) type RatchetServer struct { http.Handler + logger *slog.Logger //Services used by HTTP routes - UserService UserService -} - -func parseLogLevel(levelStr string) slog.Level { - switch strings.ToUpper(levelStr) { - case "DEBUG": - return slog.LevelDebug - case "INFO": - return slog.LevelInfo - case "WARN": - return slog.LevelWarn - case "ERROR": - return slog.LevelError - default: - return slog.LevelInfo // Default level - } + UserService user.UserService } -func InitLogging(level string) { - // Use os.Stderr - // - // Stderr is used for diagnostics and logging. Stdout is used for program - // output. Stderr also have greater likely hood of being seen if a programs - // output is being redirected. - parsedLogLevel := parseLogLevel(level) - loggerHandler := slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: parsedLogLevel, AddSource: true}) - logger := slog.New(loggerHandler) - slog.SetDefault(logger) -} - -func NewRatchetServer() *RatchetServer { - r := new(RatchetServer) - +func NewRatchetServer(logger *slog.Logger) *RatchetServer { + rs := new(RatchetServer) + rs.logger = logger // TODO implement middleware that disables directory listings fileServer := http.FileServer(http.Dir("./ui/static/")) router := http.NewServeMux() @@ -58,22 +32,22 @@ func NewRatchetServer() *RatchetServer { // resulting in this route requiring an exact match on "/" only // You can only include one HTTP method in a route pattern if you choose // GET will match GET & HEAD http request methods - router.HandleFunc("GET /{$}", home) - router.HandleFunc("GET /snippet/view/{id}", snippetView) - router.HandleFunc("GET /snippet/create", snippetCreate) + router.HandleFunc("GET /{$}", rs.home) + router.HandleFunc("GET /snippet/view/{id}", rs.snippetView) + router.HandleFunc("GET /snippet/create", rs.snippetCreate) // FYI The http.HandlerFunc() adapter works by automatically adding a ServeHTTP() method to // the passed function - router.HandleFunc("POST /snippet/create", snippetCreatePost) + router.HandleFunc("POST /snippet/create", rs.snippetCreatePost) // Mux Router implements the Handler interface. AKA it has a ServeHTTP receiver. - r.Handler = router - return r + rs.Handler = router + return rs } -func home(w http.ResponseWriter, r *http.Request) { +func (rs *RatchetServer) home(w http.ResponseWriter, r *http.Request) { // TODO middleware should be able to print out these lines for all routes - slog.Info("request received", "method", "GET", "path", "/") + rs.logger.Info("request received", "method", "GET", "path", "/") w.Header().Add("Server", "Go") @@ -89,23 +63,25 @@ func home(w http.ResponseWriter, r *http.Request) { // read template file into template set. ts, err := template.ParseFiles(files...) if err != nil { - slog.Error(err.Error()) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) + rs.serverError(w, r, err) return } // Write template content to response body err = ts.ExecuteTemplate(w, "base", nil) if err != nil { - slog.Error(err.Error()) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) + // This is the older more verbose way of doing what RatchetServer.serverError does + // rs.logger.Error(err.Error()) + // http.Error(w, "Internal Server Error", http.StatusInternalServerError) + rs.serverError(w, r, err) } } -func snippetView(w http.ResponseWriter, r *http.Request) { +func (rs *RatchetServer) snippetView(w http.ResponseWriter, r *http.Request) { id, err := strconv.Atoi(r.PathValue("id")) if err != nil || id < 1 { - http.NotFound(w, r) + // http.NotFound(w, r) + rs.clientError(w, http.StatusNotFound) return } @@ -124,12 +100,12 @@ func snippetView(w http.ResponseWriter, r *http.Request) { } // snippetCreate handles display of the form used to create snippets -func snippetCreate(w http.ResponseWriter, r *http.Request) { +func (rs *RatchetServer) snippetCreate(w http.ResponseWriter, r *http.Request) { w.Write([]byte("Displaying snippetCreate form...")) } // snippetCreate handles display of the form used to create snippets -func snippetCreatePost(w http.ResponseWriter, r *http.Request) { +func (rs *RatchetServer) snippetCreatePost(w http.ResponseWriter, r *http.Request) { // example of a custom header. Must be done before calling WriteHeader // or they will fail to take effect. w.Header().Add("Server", "Dirp") diff --git a/internal/server/helpers.go b/internal/server/helpers.go new file mode 100644 index 0000000..2c235e4 --- /dev/null +++ b/internal/server/helpers.go @@ -0,0 +1,29 @@ +package server + +import ( + "net/http" + "runtime/debug" +) + +// serverError helper writes a log entry at Error level (including the request +// method and URI as attributes), then sends a generic 500 Internal Server Error +// response to the user. +func (rs *RatchetServer) serverError(w http.ResponseWriter, r *http.Request, err error) { + var ( + method = r.Method + uri = r.URL.RequestURI() + // Use debug.Stack() to get the stack trace. This returns a byte slice, which + // we need to convert to a string so that it's readable in the log entry. + trace = string(debug.Stack()) + ) + + rs.logger.Error(err.Error(), "method", method, "uri", uri, "trace", trace) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) +} + +// clientError helper sends a specific status code and corresponding description +// to the user. We'll use this later in the book to send responses like 400 "Bad +// Request" when there's a problem with the request that the user sent +func (rs *RatchetServer) clientError(w http.ResponseWriter, status int) { + http.Error(w, http.StatusText(status), status) +} From bda1ba8324015dcc14b5a0fb8c4f85ab979da1ab Mon Sep 17 00:00:00 2001 From: Drew Bednar Date: Sat, 18 Jan 2025 15:27:05 -0500 Subject: [PATCH 04/12] Adding sqlite3 migrate and make utility commands --- .gitignore | 7 ++++++- Makefile | 26 +++++++++++++++++++++++++- README.md | 6 +++++- go.mod | 2 ++ go.sum | 2 ++ migrations/10_snippets.down.sql | 1 + migrations/10_snippets.up.sql | 20 ++++++++++++++++++++ 7 files changed, 61 insertions(+), 3 deletions(-) create mode 100644 migrations/10_snippets.down.sql create mode 100644 migrations/10_snippets.up.sql diff --git a/.gitignore b/.gitignore index 26ef082..3a2321a 100644 --- a/.gitignore +++ b/.gitignore @@ -21,4 +21,9 @@ # Go workspace file go.work -tmp/ \ No newline at end of file +tmp/ + +# Sqlite +*.db +*.db-shm +*.db-wal diff --git a/Makefile b/Makefile index 7070b2d..ceb0eb3 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,31 @@ +SQL_DATABASE?=./ratchet.db + test: go test -v ./... PHONEY: test serve: go run ./cmd/ratchetd/main.go -PHONEY: serve \ No newline at end of file +PHONEY: serve + + +# SQLite Commands + +sql-cli: + sqlite3 $(SQL_DATABASE) -cmd ".headers on" -cmd ".mode column" -cmd ".tables" + +init-db: run-migrate + sqlite3 $(SQL_DATABASE) "PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL;" + +seed-db: + sqlite3 $(SQL_DATABASE) "INSERT INTO snippets (title, content, expires_at) VALUES ('placeholder', 'placeholder content', datetime('now', '+6 months'));" + +run-migrate: + migrate -database sqlite3://$(SQL_DATABASE) -path ./migrations up + +# Checks system dependencies needed to run the local dev environment +check-system-deps: + @echo "Checking system dependencies..." + @command -v sqlite3 > /dev/null || (echo "Missing sqlite3 command. brew install sqlite"; exit 1) + @command -v migrate > /dev/null || (echo "Missing migrate command. go install -tags 'sqlite3' github.com/golang-migrate/migrate/v4/cmd/migrate@latest"; exit 1) + @echo "System dependencies fulfilled 👍" \ No newline at end of file diff --git a/README.md b/README.md index f995bd6..a3e6226 100644 --- a/README.md +++ b/README.md @@ -84,4 +84,8 @@ func downloadHandler(w http.ResponseWriter, r *http.Request) { } ``` -Warning: http.ServeFile() does not automatically sanitize the file path. If you’re constructing a file path from untrusted user input, to avoid directory traversal attacks you must sanitize the input with filepath.Clean() before using it. \ No newline at end of file +Warning: http.ServeFile() does not automatically sanitize the file path. If you’re constructing a file path from untrusted user input, to avoid directory traversal attacks you must sanitize the input with filepath.Clean() before using it. + +## Databases + +The Let's-go book calls for MySQL. We use [go-sqlite3](ggithub.com/mattn/go-sqlite3) and [go-migrate]() tool to manage migrations instead. Use `make check-system-deps` to validate all tools for this repository are installed. diff --git a/go.mod b/go.mod index f5929ae..6c5902a 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,5 @@ module git.runcible.io/learning/ratchet go 1.23.3 + +require github.com/mattn/go-sqlite3 v1.14.24 // indirect diff --git a/go.sum b/go.sum index e69de29..9dcdc9b 100644 --- a/go.sum +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= +github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= diff --git a/migrations/10_snippets.down.sql b/migrations/10_snippets.down.sql new file mode 100644 index 0000000..6c0a277 --- /dev/null +++ b/migrations/10_snippets.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS snippets; \ No newline at end of file diff --git a/migrations/10_snippets.up.sql b/migrations/10_snippets.up.sql new file mode 100644 index 0000000..be37e4a --- /dev/null +++ b/migrations/10_snippets.up.sql @@ -0,0 +1,20 @@ +PRAGMA foreign_keys=1; +CREATE TABLE snippets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT, + content TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + expires_at DATETIME NOT NULL +); + +-- Add an index on the created column. +CREATE INDEX idx_snippets_created ON snippets(created_at); + +-- Add a trigger to keep timestamp updated. +CREATE TRIGGER snippet_update_timestamp +AFTER UPDATE ON snippets +FOR EACH ROW +BEGIN + UPDATE snippets SET updated_at = CURRENT_TIMESTAMP WHERE id = OLD.id; +END; From 550b7f553e9616c8af813761949c30336b12bd56 Mon Sep 17 00:00:00 2001 From: Drew Bednar Date: Sun, 19 Jan 2025 13:24:40 -0500 Subject: [PATCH 05/12] Adding sqlite3 and testing connection --- Makefile | 2 +- README.md | 24 +++++++++++++++++++++++- cmd/ratchetd/main.go | 15 +++++++++++++-- go.mod | 2 +- internal/database/database.go | 30 ++++++++++++++++++++++++++++++ 5 files changed, 68 insertions(+), 5 deletions(-) create mode 100644 internal/database/database.go diff --git a/Makefile b/Makefile index ceb0eb3..af1cca1 100644 --- a/Makefile +++ b/Makefile @@ -12,7 +12,7 @@ PHONEY: serve # SQLite Commands sql-cli: - sqlite3 $(SQL_DATABASE) -cmd ".headers on" -cmd ".mode column" -cmd ".tables" + sqlite3 $(SQL_DATABASE) -cmd ".headers on" -cmd ".mode box" -cmd ".tables" init-db: run-migrate sqlite3 $(SQL_DATABASE) "PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL;" diff --git a/README.md b/README.md index a3e6226..d693c85 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,12 @@ An example web application in Golang. - https://lets-go.alexedwards.net/sample/02.09-serving-static-files.html ## Project Structure +Following https://go.dev/doc/modules/layout#server-project the implementation + Loosely inspired by the organization of [WTFDial](https://github.com/benbjohnson/wtf?tab=readme-ov-file#project-structure), - Application domain types reside in the project root (User, UserService, etc) - Implementations of the application domain reside in the subpackages `sqlite`, `http`, etc. @@ -89,3 +90,24 @@ Warning: http.ServeFile() does not automatically sanitize the file path. If you ## Databases The Let's-go book calls for MySQL. We use [go-sqlite3](ggithub.com/mattn/go-sqlite3) and [go-migrate]() tool to manage migrations instead. Use `make check-system-deps` to validate all tools for this repository are installed. + + +## Managing Dependencies + +To upgrade to latest minor/patch version of a package in your go mod.go you can use the `-u` flag: + +``` +go get -u github.com/foo/bar +``` + +To update to a specific package + +``` +go get -u github.com/foo/bar@v2.0.0 +``` + +To remove the package you can use `go mod tidy` if all references have been removed, or: + +``` +go get github.com/foo/bar@none +``` \ No newline at end of file diff --git a/cmd/ratchetd/main.go b/cmd/ratchetd/main.go index 898765a..c8c918c 100644 --- a/cmd/ratchetd/main.go +++ b/cmd/ratchetd/main.go @@ -7,6 +7,7 @@ import ( "net/http" "os" + rdb "git.runcible.io/learning/ratchet/internal/database" "git.runcible.io/learning/ratchet/internal/logging" "git.runcible.io/learning/ratchet/internal/server" // "git.runcible.io/learning/ratchet" @@ -23,13 +24,23 @@ func main() { // Parse command line options addr := flag.String("addr", "0.0.0.0", "HTTP network address") port := flag.String("port", "5001", "HTTP port") - logLevel := flag.String("logging", "INFO", "Logging Level. Valid values [INFO, DEBUG, WARN, ERROR].") + logLevel := flag.String("logging", "DEBUG", "Logging Level. Valid values [INFO, DEBUG, WARN, ERROR].") + dbPath := flag.String("database", "./ratchet.db", "A path to a sqlite3 database") // must call parse or all values will be the defaults flag.Parse() // DEPENDENCY INJECTION FOR HANDLERS // Setup Logging logger := logging.InitLogging(*logLevel) + // Setup DB Connection Pool + db, err := rdb.OpenSqlite3DB(*dbPath) + + if err != nil { + slog.Error(err.Error()) + os.Exit(1) + } + // Close db connection before exiting main. + defer db.Close() // Propagate build information to root package to share globally // ratchet.Version = strings.TrimPrefix(version, "") @@ -41,7 +52,7 @@ func main() { slog.Info(fmt.Sprintf("Listening on http://%s:%s", *addr, *port)) //log.Fatal(http.ListenAndServe(fmt.Sprintf("%s:%s", *addr, *port), server)) // there is no log.Fatal equivalent. This is an approximation of the behavior - err := http.ListenAndServe(fmt.Sprintf("%s:%s", *addr, *port), server) + err = http.ListenAndServe(fmt.Sprintf("%s:%s", *addr, *port), server) slog.Error(err.Error()) os.Exit(1) diff --git a/go.mod b/go.mod index 6c5902a..c8971a2 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,4 @@ module git.runcible.io/learning/ratchet go 1.23.3 -require github.com/mattn/go-sqlite3 v1.14.24 // indirect +require github.com/mattn/go-sqlite3 v1.14.24 diff --git a/internal/database/database.go b/internal/database/database.go new file mode 100644 index 0000000..b0a765e --- /dev/null +++ b/internal/database/database.go @@ -0,0 +1,30 @@ +package database + +import ( + "database/sql" + "fmt" + "log/slog" + + _ "github.com/mattn/go-sqlite3" +) + +// OpenSqlite3DB is a wrapper +// +// TODO wtf dail uses context.Background(). Look into it more +func OpenSqlite3DB(dbPath string) (*sql.DB, error) { + full_database_path := "file:" + dbPath + "?cache=shared" + + slog.Debug(fmt.Sprintf("Using database path: %s", full_database_path)) + + db, err := sql.Open("sqlite3", full_database_path) + if err != nil { + return nil, fmt.Errorf("failed to open: %s", full_database_path) + } + + err = db.Ping() + if err != nil { + db.Close() + return nil, err + } + return db, nil +} From 8e42f69a7011cdb90e87065754b6eac6c1a2e5c2 Mon Sep 17 00:00:00 2001 From: Drew Bednar Date: Sun, 19 Jan 2025 13:32:06 -0500 Subject: [PATCH 06/12] Use DEBUG log level in dev --- .air.toml | 4 ++-- Makefile | 1 + cmd/ratchetd/main.go | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.air.toml b/.air.toml index d435769..d6e480a 100644 --- a/.air.toml +++ b/.air.toml @@ -3,7 +3,7 @@ testdata_dir = "testdata" tmp_dir = "tmp" [build] - args_bin = [] + args_bin = ["-logging=DEBUG"] bin = "./tmp/main" cmd = "go build -o ./tmp/main cmd/ratchetd/main.go" delay = 1000 @@ -22,7 +22,7 @@ tmp_dir = "tmp" poll_interval = 0 post_cmd = [] pre_cmd = [] - rerun = false + rerun = false rerun_delay = 500 send_interrupt = false stop_on_error = false diff --git a/Makefile b/Makefile index af1cca1..f50ca24 100644 --- a/Makefile +++ b/Makefile @@ -26,6 +26,7 @@ run-migrate: # Checks system dependencies needed to run the local dev environment check-system-deps: @echo "Checking system dependencies..." + @command -v air > /dev/null || (echo "Missing air command. go install github.com/air-verse/air@latest"; exit 1) @command -v sqlite3 > /dev/null || (echo "Missing sqlite3 command. brew install sqlite"; exit 1) @command -v migrate > /dev/null || (echo "Missing migrate command. go install -tags 'sqlite3' github.com/golang-migrate/migrate/v4/cmd/migrate@latest"; exit 1) @echo "System dependencies fulfilled 👍" \ No newline at end of file diff --git a/cmd/ratchetd/main.go b/cmd/ratchetd/main.go index c8c918c..94a50e0 100644 --- a/cmd/ratchetd/main.go +++ b/cmd/ratchetd/main.go @@ -24,7 +24,7 @@ func main() { // Parse command line options addr := flag.String("addr", "0.0.0.0", "HTTP network address") port := flag.String("port", "5001", "HTTP port") - logLevel := flag.String("logging", "DEBUG", "Logging Level. Valid values [INFO, DEBUG, WARN, ERROR].") + logLevel := flag.String("logging", "INFO", "Logging Level. Valid values [INFO, DEBUG, WARN, ERROR].") dbPath := flag.String("database", "./ratchet.db", "A path to a sqlite3 database") // must call parse or all values will be the defaults flag.Parse() From 0a978da3ac497b9faa3dc842af0d49ef9f861842 Mon Sep 17 00:00:00 2001 From: Drew Bednar Date: Sun, 19 Jan 2025 14:32:25 -0500 Subject: [PATCH 07/12] Initial insert in handler --- cmd/ratchetd/main.go | 2 +- internal/model/snippets.go | 66 ++++++++++++++++++++++++++++++++++ internal/server/base_server.go | 26 +++++++++++--- 3 files changed, 88 insertions(+), 6 deletions(-) create mode 100644 internal/model/snippets.go diff --git a/cmd/ratchetd/main.go b/cmd/ratchetd/main.go index 94a50e0..d9ccf1b 100644 --- a/cmd/ratchetd/main.go +++ b/cmd/ratchetd/main.go @@ -45,7 +45,7 @@ func main() { // Propagate build information to root package to share globally // ratchet.Version = strings.TrimPrefix(version, "") // ratchet.Commit = commit - server := server.NewRatchetServer(logger) + server := server.NewRatchetServer(logger, db) // START SERVING REQUESTS slog.Debug("Herp dirp!") diff --git a/internal/model/snippets.go b/internal/model/snippets.go new file mode 100644 index 0000000..7c146d9 --- /dev/null +++ b/internal/model/snippets.go @@ -0,0 +1,66 @@ +package model + +import ( + "database/sql" + "fmt" + "log/slog" + "time" +) + +type Snippet struct { + ID int + Title string + Content string + CreatedAt time.Time + UpdatedAt time.Time + ExpiresAt time.Time +} + +type SnippetService struct { + DB *sql.DB +} + +// Insert inserts a new SnippetModel into the database +func (s *SnippetService) Insert(title, content string, expiresAt int) (int, error) { + slog.Debug(fmt.Sprintf("Inserting new snippet. Title: %s", title)) + stmt, err := s.DB.Prepare("INSERT INTO snippets (title, content, expires_at) VALUES ($1, $2, DATETIME(CURRENT_TIMESTAMP, '+' || $3 || ' DAY'))") + if err != nil { + slog.Debug("The prepared statement has an error") + return 0, err + } + defer stmt.Close() + + // stmt.Exec returns a sql.Result. That also has access to the statement metadata + // use _ if you don't care about the result and only want to check the err. + + // Exec will NOT reserve a connection. unlike db.Query which returns a sql.Rows that + // will hold on to a connection until .Close() is called. + res, err := stmt.Exec(title, content, expiresAt) + if err != nil { + slog.Debug("SQL DDL returned an error.") + return 0, err + } + + // Use the LastInsertId() method on the result to get the ID of our + // newly inserted record in the snippets table. + lastId, err := res.LastInsertId() + if err != nil { + slog.Debug("An error occured when retrieving insert result id.") + return 0, err + } + + // The ID returned has the type int64, so we convert it to an int type + // before returning. + slog.Debug(fmt.Sprintf("Inserted new snippet. Snippet pk: %d", int(lastId))) + return int(lastId), nil +} + +// Get retrieves a specific Snippet by ID +func (s *SnippetService) Get(id int) (Snippet, error) { + return Snippet{}, nil +} + +// Latest retrieves up to latest 10 Snippets from the database. +func (s *SnippetService) Lastest() (Snippet, error) { + return Snippet{}, nil +} diff --git a/internal/server/base_server.go b/internal/server/base_server.go index c9219b8..db19959 100644 --- a/internal/server/base_server.go +++ b/internal/server/base_server.go @@ -1,6 +1,7 @@ package server import ( + "database/sql" "fmt" "html/template" "log/slog" @@ -8,6 +9,7 @@ import ( "strconv" "git.runcible.io/learning/ratchet/internal/domain/user" + "git.runcible.io/learning/ratchet/internal/model" ) type RatchetServer struct { @@ -15,12 +17,14 @@ type RatchetServer struct { logger *slog.Logger //Services used by HTTP routes - UserService user.UserService + snippetService *model.SnippetService + UserService user.UserService } -func NewRatchetServer(logger *slog.Logger) *RatchetServer { +func NewRatchetServer(logger *slog.Logger, db *sql.DB) *RatchetServer { rs := new(RatchetServer) rs.logger = logger + rs.snippetService = &model.SnippetService{DB: db} // TODO implement middleware that disables directory listings fileServer := http.FileServer(http.Dir("./ui/static/")) router := http.NewServeMux() @@ -101,15 +105,27 @@ func (rs *RatchetServer) snippetView(w http.ResponseWriter, r *http.Request) { // snippetCreate handles display of the form used to create snippets func (rs *RatchetServer) snippetCreate(w http.ResponseWriter, r *http.Request) { - w.Write([]byte("Displaying snippetCreate form...")) + w.Write([]byte("Create snippet form...")) } // snippetCreate handles display of the form used to create snippets +// +// curl -iL -d "" http://localhost:5000/snippet/create func (rs *RatchetServer) snippetCreatePost(w http.ResponseWriter, r *http.Request) { // example of a custom header. Must be done before calling WriteHeader // or they will fail to take effect. w.Header().Add("Server", "Dirp") - w.WriteHeader(http.StatusCreated) + // Create some variables holding dummy data. We'll remove these later on + // during the build. + title := "O snail" + content := "O snail\nClimb Mount Fuji,\nBut slowly, slowly!\n\n– Kobayashi Issa" + expires := 7 - w.Write([]byte("Created snippet...")) + id, err := rs.snippetService.Insert(title, content, expires) + + if err != nil { + rs.serverError(w, r, err) + } + + http.Redirect(w, r, fmt.Sprintf("/snippet/view/%d", id), http.StatusSeeOther) } From 1b823bf1d58691bf57da3af4b7f9d02dccb43e1d Mon Sep 17 00:00:00 2001 From: Drew Bednar Date: Sun, 19 Jan 2025 20:24:31 -0500 Subject: [PATCH 08/12] Got some sloppy DB service support for snippets now. --- README.md | 5 ++ internal/model/errors.go | 6 +++ internal/model/example.go | 65 +++++++++++++++++++++++ internal/model/snippets.go | 97 +++++++++++++++++++++++++++++++--- internal/server/base_server.go | 74 ++++++++++++++++---------- 5 files changed, 211 insertions(+), 36 deletions(-) create mode 100644 internal/model/errors.go create mode 100644 internal/model/example.go diff --git a/README.md b/README.md index d693c85..1a4ca45 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,11 @@ Warning: http.ServeFile() does not automatically sanitize the file path. If you The Let's-go book calls for MySQL. We use [go-sqlite3](ggithub.com/mattn/go-sqlite3) and [go-migrate]() tool to manage migrations instead. Use `make check-system-deps` to validate all tools for this repository are installed. +Packages to consider adopting include sqlx or https://github.com/blockloop/scan. Both have the potential to reduce the verbosity that comes will using the standard database/sql package. + +### Nulls Gotcha + +Beware of Nulls when using `DB.Scan()` To avoid issues consider using the sql.Null* types or putting a `NOT NULL` constraint in your DDL. Also a sensible Default ## Managing Dependencies diff --git a/internal/model/errors.go b/internal/model/errors.go new file mode 100644 index 0000000..6d3a5d2 --- /dev/null +++ b/internal/model/errors.go @@ -0,0 +1,6 @@ +package model + +import "errors" + +// TODO migrate this to an apperror +var ErrNoRecord = errors.New("models: no record found") diff --git a/internal/model/example.go b/internal/model/example.go new file mode 100644 index 0000000..c814eed --- /dev/null +++ b/internal/model/example.go @@ -0,0 +1,65 @@ +// This file simply illustrates the use of transactions when performing operations on your sql db. +package model + +import "database/sql" + +type ExampleModel struct { + DB *sql.DB + // Prepared statements + // + // Prepared statements exist on database connections. Statement objects will therefore attempt to reuse the connection + // object from the connection pool that the statement was created on. If the connection was Closed or in use, it will + // be re-prepared on a new connection. This can increase load, create more connections than expected. Etc. Really its + // and optimization that you may not need to start looking at. When you do you have to look at load test data to get an + // idea for how it actually behaves. + // + // Another pattern is avoid recreated prepared statements on each invocation and instead attach them + // to the service instead. This doesn't really work well with transactions which have thier own tx.Prepare + // method + // InsertStmt *sql.Stmt +} + +// func NewExampleModel(db *sql.DB) (*ExampleModel, error) { +// insertStmt, err := db.Prepare("INSERT INTO example (message, thought) VALUES (?, ?)") +// if err != nil { +// return nil, err +// } +// return &ExampleModel{DB: db, InsertStmt: insertStmt}, nil +// } + +func (m *ExampleModel) ExampleTransaction() error { + // Calling the Begin() method on the connection pool creates a new sql.Tx + // object, which represents the in-progress database transaction. + tx, err := m.DB.Begin() + if err != nil { + return err + } + + // Defer a call to tx.Rollback() to ensure it is always called before the + // function returns. If the transaction succeeds it will be already be + // committed by the time tx.Rollback() is called, making tx.Rollback() a + // no-op. Otherwise, in the event of an error, tx.Rollback() will rollback + // the changes before the function returns. + defer tx.Rollback() + + // Call Exec() on the transaction, passing in your statement and any + // parameters. It's important to notice that tx.Exec() is called on the + // transaction object just created, NOT the connection pool. Although we're + // using tx.Exec() here you can also use tx.Query() and tx.QueryRow() in + // exactly the same way. + _, err = tx.Exec("INSERT INTO ...") + if err != nil { + return err + } + + // Carry out another transaction in exactly the same way. + _, err = tx.Exec("UPDATE ...") + if err != nil { + return err + } + + // If there are no errors, the statements in the transaction can be committed + // to the database with the tx.Commit() method. + err = tx.Commit() + return err +} diff --git a/internal/model/snippets.go b/internal/model/snippets.go index 7c146d9..ea4ac1e 100644 --- a/internal/model/snippets.go +++ b/internal/model/snippets.go @@ -2,15 +2,18 @@ package model import ( "database/sql" + "errors" "fmt" "log/slog" "time" ) type Snippet struct { - ID int - Title string - Content string + ID int + // Title string + // Content string + Title sql.NullString + Content sql.NullString CreatedAt time.Time UpdatedAt time.Time ExpiresAt time.Time @@ -23,6 +26,8 @@ type SnippetService struct { // Insert inserts a new SnippetModel into the database func (s *SnippetService) Insert(title, content string, expiresAt int) (int, error) { slog.Debug(fmt.Sprintf("Inserting new snippet. Title: %s", title)) + // Really don't prepare statements. There are a lot of gotcha's where they exist on the connection objects they were created. They can potentially + // recreate connections. It's an optimization you probably don't need at the moment. stmt, err := s.DB.Prepare("INSERT INTO snippets (title, content, expires_at) VALUES ($1, $2, DATETIME(CURRENT_TIMESTAMP, '+' || $3 || ' DAY'))") if err != nil { slog.Debug("The prepared statement has an error") @@ -37,7 +42,7 @@ func (s *SnippetService) Insert(title, content string, expiresAt int) (int, erro // will hold on to a connection until .Close() is called. res, err := stmt.Exec(title, content, expiresAt) if err != nil { - slog.Debug("SQL DDL returned an error.") + slog.Debug("SQL DML statement returned an error.") return 0, err } @@ -55,12 +60,88 @@ func (s *SnippetService) Insert(title, content string, expiresAt int) (int, erro return int(lastId), nil } -// Get retrieves a specific Snippet by ID +// Get retrieves a specific Snippet by ID ignoring the record if expired. func (s *SnippetService) Get(id int) (Snippet, error) { - return Snippet{}, nil + + stmt := `SELECT id, title, content, created_at, updated_at, expires_at FROM snippets + WHERE expires_at > CURRENT_TIMESTAMP AND id = $1` + + // errors from DB.QueryRow() are deferred until Scan() is called. + // meaning you could also have used DB.QueryRow(...).Scan(...) + row := s.DB.QueryRow(stmt, id) + + var snip Snippet + + err := row.Scan(&snip.ID, + &snip.Title, + &snip.Content, + &snip.CreatedAt, + &snip.UpdatedAt, + &snip.ExpiresAt, + ) + + if err != nil { + slog.Debug("SQL DML statement returned an error.") + // Loop up the difference between errors.Is and errors.As + if errors.Is(err, sql.ErrNoRows) { + return Snippet{}, ErrNoRecord + } else { + return Snippet{}, err + } + } + + return snip, nil } // Latest retrieves up to latest 10 Snippets from the database. -func (s *SnippetService) Lastest() (Snippet, error) { - return Snippet{}, nil +func (s *SnippetService) Lastest() ([]Snippet, error) { + + stmt := `SELECT id, title, content, created_at, updated_at, expires_at FROM snippets + WHERE expires_at > CURRENT_TIMESTAMP ORDER BY id DESC LIMIT 10` + + rows, err := s.DB.Query(stmt) + if err != nil { + return nil, err + } + + // We defer rows.Close() to ensure the sql.Rows resultset is + // always properly closed before the Latest() method returns. This defer + // statement should come *after* you check for an error from the Query() + // method. Otherwise, if Query() returns an error, you'll get a panic + // trying to close a nil resultset. + defer rows.Close() + + var snippets []Snippet + + // Use rows.Next to iterate through the rows in the resultset. This + // prepares the first (and then each subsequent) row to be acted on by the + // rows.Scan() method. If iteration over all the rows completes then the + // resultset automatically closes itself and frees-up the underlying + // database connection. + for rows.Next() { + var snip Snippet + + err := rows.Scan(&snip.ID, + &snip.Title, + &snip.Content, + &snip.CreatedAt, + &snip.UpdatedAt, + &snip.ExpiresAt, + ) + if err != nil { + return nil, err + } + + snippets = append(snippets, snip) + } + + // When the rows.Next() loop has finished we call rows.Err() to retrieve any + // error that was encountered during the iteration. It's important to + // call this - don't assume that a successful iteration was completed + // over the whole resultset. + if err = rows.Err(); err != nil { + return nil, err + } + + return snippets, nil } diff --git a/internal/server/base_server.go b/internal/server/base_server.go index db19959..fcc836e 100644 --- a/internal/server/base_server.go +++ b/internal/server/base_server.go @@ -2,8 +2,8 @@ package server import ( "database/sql" + "errors" "fmt" - "html/template" "log/slog" "net/http" "strconv" @@ -55,29 +55,42 @@ func (rs *RatchetServer) home(w http.ResponseWriter, r *http.Request) { w.Header().Add("Server", "Go") - // Initialize a slice containing the paths to the two files. It's important - // to note that the file containing our base template must be the *first* - // file in the slice. - files := []string{ - "./ui/html/base.go.tmpl", - "./ui/html/partials/nav.go.tmpl", - "./ui/html/pages/home.go.tmpl", - } - - // read template file into template set. - ts, err := template.ParseFiles(files...) - if err != nil { + // Retrieve Snippets from DB + snippets, err := rs.snippetService.Lastest() + if err != err { rs.serverError(w, r, err) return } - // Write template content to response body - err = ts.ExecuteTemplate(w, "base", nil) - if err != nil { - // This is the older more verbose way of doing what RatchetServer.serverError does - // rs.logger.Error(err.Error()) - // http.Error(w, "Internal Server Error", http.StatusInternalServerError) - rs.serverError(w, r, err) + + rs.logger.Debug(fmt.Sprintf("%d snippets retrieved", len(snippets))) + + for _, snippet := range snippets { + fmt.Fprintf(w, "%+v\n", snippet) } + + // // Initialize a slice containing the paths to the two files. It's important + // // to note that the file containing our base template must be the *first* + // // file in the slice. + // files := []string{ + // "./ui/html/base.go.tmpl", + // "./ui/html/partials/nav.go.tmpl", + // "./ui/html/pages/home.go.tmpl", + // } + + // // read template file into template set. + // ts, err := template.ParseFiles(files...) + // if err != nil { + // rs.serverError(w, r, err) + // return + // } + // // Write template content to response body + // err = ts.ExecuteTemplate(w, "base", nil) + // if err != nil { + // // This is the older more verbose way of doing what RatchetServer.serverError does + // // rs.logger.Error(err.Error()) + // // http.Error(w, "Internal Server Error", http.StatusInternalServerError) + // rs.serverError(w, r, err) + // } } func (rs *RatchetServer) snippetView(w http.ResponseWriter, r *http.Request) { @@ -91,16 +104,21 @@ func (rs *RatchetServer) snippetView(w http.ResponseWriter, r *http.Request) { // Set a new cache-control header. If an existing "Cache-Control" header exists // it will be overwritten. - w.Header().Set("Cache-Control", "public, max-age=31536000") - - // msg := fmt.Sprintf("Snippet %d...", id) - - // w.Write([]byte(msg)) + // w.Header().Set("Cache-Control", "public, max-age=31536000") - // we can rely on the Write() interface to use a differnent - // function to write out our response + snippet, err := rs.snippetService.Get(id) + if err != nil { + rs.logger.Debug(fmt.Sprintf("Failed to retrieve an active record with id: %d", id)) + if errors.Is(err, model.ErrNoRecord) { + rs.clientError(w, http.StatusNotFound) + } else { + rs.serverError(w, r, err) + } + return + } - fmt.Fprintf(w, "Snippet %d...", id) + // Write the snippet data as a plain-text HTTP response body. + fmt.Fprintf(w, "%+v", snippet) } // snippetCreate handles display of the form used to create snippets From 43b7e2d986db0dac38e782ae6e81d747a5e36a66 Mon Sep 17 00:00:00 2001 From: Drew Bednar Date: Fri, 24 Jan 2025 15:34:27 -0500 Subject: [PATCH 09/12] Making some decisions and refactoring --- internal/{domain/user => model}/user.go | 2 +- internal/{domain/user => model}/user_test.go | 2 +- internal/server/base_server.go | 149 ------------------- internal/server/handlers.go | 140 +++++++++++++++++ internal/server/helpers.go | 8 +- internal/server/routes.go | 28 ++++ internal/server/server.go | 47 ++++++ 7 files changed, 222 insertions(+), 154 deletions(-) rename internal/{domain/user => model}/user.go (99%) rename internal/{domain/user => model}/user_test.go (97%) delete mode 100644 internal/server/base_server.go create mode 100644 internal/server/handlers.go create mode 100644 internal/server/routes.go create mode 100644 internal/server/server.go diff --git a/internal/domain/user/user.go b/internal/model/user.go similarity index 99% rename from internal/domain/user/user.go rename to internal/model/user.go index 90b991b..ae3b28f 100644 --- a/internal/domain/user/user.go +++ b/internal/model/user.go @@ -1,4 +1,4 @@ -package user +package model import ( "context" diff --git a/internal/domain/user/user_test.go b/internal/model/user_test.go similarity index 97% rename from internal/domain/user/user_test.go rename to internal/model/user_test.go index c3c70e8..05559a4 100644 --- a/internal/domain/user/user_test.go +++ b/internal/model/user_test.go @@ -1,4 +1,4 @@ -package user +package model import ( "testing" diff --git a/internal/server/base_server.go b/internal/server/base_server.go deleted file mode 100644 index fcc836e..0000000 --- a/internal/server/base_server.go +++ /dev/null @@ -1,149 +0,0 @@ -package server - -import ( - "database/sql" - "errors" - "fmt" - "log/slog" - "net/http" - "strconv" - - "git.runcible.io/learning/ratchet/internal/domain/user" - "git.runcible.io/learning/ratchet/internal/model" -) - -type RatchetServer struct { - http.Handler - - logger *slog.Logger - //Services used by HTTP routes - snippetService *model.SnippetService - UserService user.UserService -} - -func NewRatchetServer(logger *slog.Logger, db *sql.DB) *RatchetServer { - rs := new(RatchetServer) - rs.logger = logger - rs.snippetService = &model.SnippetService{DB: db} - // TODO implement middleware that disables directory listings - fileServer := http.FileServer(http.Dir("./ui/static/")) - router := http.NewServeMux() - - // Subtree pattern for static assets - router.Handle("GET /static/", http.StripPrefix("/static/", fileServer)) - - // /{$} is used to prevent subtree path patterns from acting like a wildcard - // resulting in this route requiring an exact match on "/" only - // You can only include one HTTP method in a route pattern if you choose - // GET will match GET & HEAD http request methods - router.HandleFunc("GET /{$}", rs.home) - router.HandleFunc("GET /snippet/view/{id}", rs.snippetView) - router.HandleFunc("GET /snippet/create", rs.snippetCreate) - - // FYI The http.HandlerFunc() adapter works by automatically adding a ServeHTTP() method to - // the passed function - router.HandleFunc("POST /snippet/create", rs.snippetCreatePost) - - // Mux Router implements the Handler interface. AKA it has a ServeHTTP receiver. - rs.Handler = router - return rs -} - -func (rs *RatchetServer) home(w http.ResponseWriter, r *http.Request) { - // TODO middleware should be able to print out these lines for all routes - rs.logger.Info("request received", "method", "GET", "path", "/") - - w.Header().Add("Server", "Go") - - // Retrieve Snippets from DB - snippets, err := rs.snippetService.Lastest() - if err != err { - rs.serverError(w, r, err) - return - } - - rs.logger.Debug(fmt.Sprintf("%d snippets retrieved", len(snippets))) - - for _, snippet := range snippets { - fmt.Fprintf(w, "%+v\n", snippet) - } - - // // Initialize a slice containing the paths to the two files. It's important - // // to note that the file containing our base template must be the *first* - // // file in the slice. - // files := []string{ - // "./ui/html/base.go.tmpl", - // "./ui/html/partials/nav.go.tmpl", - // "./ui/html/pages/home.go.tmpl", - // } - - // // read template file into template set. - // ts, err := template.ParseFiles(files...) - // if err != nil { - // rs.serverError(w, r, err) - // return - // } - // // Write template content to response body - // err = ts.ExecuteTemplate(w, "base", nil) - // if err != nil { - // // This is the older more verbose way of doing what RatchetServer.serverError does - // // rs.logger.Error(err.Error()) - // // http.Error(w, "Internal Server Error", http.StatusInternalServerError) - // rs.serverError(w, r, err) - // } -} - -func (rs *RatchetServer) snippetView(w http.ResponseWriter, r *http.Request) { - - id, err := strconv.Atoi(r.PathValue("id")) - if err != nil || id < 1 { - // http.NotFound(w, r) - rs.clientError(w, http.StatusNotFound) - return - } - - // Set a new cache-control header. If an existing "Cache-Control" header exists - // it will be overwritten. - // w.Header().Set("Cache-Control", "public, max-age=31536000") - - snippet, err := rs.snippetService.Get(id) - if err != nil { - rs.logger.Debug(fmt.Sprintf("Failed to retrieve an active record with id: %d", id)) - if errors.Is(err, model.ErrNoRecord) { - rs.clientError(w, http.StatusNotFound) - } else { - rs.serverError(w, r, err) - } - return - } - - // Write the snippet data as a plain-text HTTP response body. - fmt.Fprintf(w, "%+v", snippet) -} - -// snippetCreate handles display of the form used to create snippets -func (rs *RatchetServer) snippetCreate(w http.ResponseWriter, r *http.Request) { - w.Write([]byte("Create snippet form...")) -} - -// snippetCreate handles display of the form used to create snippets -// -// curl -iL -d "" http://localhost:5000/snippet/create -func (rs *RatchetServer) snippetCreatePost(w http.ResponseWriter, r *http.Request) { - // example of a custom header. Must be done before calling WriteHeader - // or they will fail to take effect. - w.Header().Add("Server", "Dirp") - // Create some variables holding dummy data. We'll remove these later on - // during the build. - title := "O snail" - content := "O snail\nClimb Mount Fuji,\nBut slowly, slowly!\n\n– Kobayashi Issa" - expires := 7 - - id, err := rs.snippetService.Insert(title, content, expires) - - if err != nil { - rs.serverError(w, r, err) - } - - http.Redirect(w, r, fmt.Sprintf("/snippet/view/%d", id), http.StatusSeeOther) -} diff --git a/internal/server/handlers.go b/internal/server/handlers.go new file mode 100644 index 0000000..3bca7a3 --- /dev/null +++ b/internal/server/handlers.go @@ -0,0 +1,140 @@ +package server + +import ( + "errors" + "fmt" + "log/slog" + "net/http" + "strconv" + + "git.runcible.io/learning/ratchet/internal/model" +) + +// TODO function should accept and a pointer to an interface allowing for mocking in tests. +func handleHome(logger *slog.Logger, snippetService *model.SnippetService) http.Handler { + return http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + logger.Info("request received", "method", "GET", "path", "/") + // Just and example of adding a header + w.Header().Add("Server", "Go") + // Retrieve Snippets from DB + snippets, err := snippetService.Lastest() + if err != err { + serverError(w, r, err) + return + } + + logger.Debug(fmt.Sprintf("%d snippets retrieved", len(snippets))) + + for _, snippet := range snippets { + fmt.Fprintf(w, "%+v\n", snippet) + } + // // Initialize a slice containing the paths to the two files. It's important + // // to note that the file containing our base template must be the *first* + // // file in the slice. + // files := []string{ + // "./ui/html/base.go.tmpl", + // "./ui/html/partials/nav.go.tmpl", + // "./ui/html/pages/home.go.tmpl", + // } + + // // read template file into template set. + // ts, err := template.ParseFiles(files...) + // if err != nil { + // rs.serverError(w, r, err) + // return + // } + // // Write template content to response body + // err = ts.ExecuteTemplate(w, "base", nil) + // if err != nil { + // // This is the older more verbose way of doing what RatchetServer.serverError does + // // rs.logger.Error(err.Error()) + // // http.Error(w, "Internal Server Error", http.StatusInternalServerError) + // rs.serverError(w, r, err) + // } + }) +} + +func handleSnippetView(logger *slog.Logger, snippetService *model.SnippetService) http.Handler { + return http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(r.PathValue("id")) + if err != nil || id < 1 { + clientError(w, http.StatusNotFound) + return + } + // Set a new cache-control header. If an existing "Cache-Control" header exists + // it will be overwritten. + // w.Header().Set("Cache-Control", "public, max-age=31536000") + + snippet, err := snippetService.Get(id) + if err != nil { + logger.Debug(fmt.Sprintf("Failed to retrieve an active record with id: %d", id)) + if errors.Is(err, model.ErrNoRecord) { + clientError(w, http.StatusNotFound) + } else { + serverError(w, r, err) + } + return + } + + // Write the snippet data as a plain-text HTTP response body. + fmt.Fprintf(w, "%+v", snippet) + + }) +} + +// // snippetCreate handles display of the form used to create snippets +// func (rs *RatchetServer) snippetCreate(w http.ResponseWriter, r *http.Request) { +// w.Write([]byte("Create snippet form...")) +// } + +func handleSnippetCreateGet() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("Create snippet form..")) + }) +} + +// snippetCreate handles display of the form used to create snippets +// +// curl -iL -d "" http://localhost:5001/snippet/create +func handleSnippetCreatePost(logger *slog.Logger, snippetService *model.SnippetService) http.Handler { + return http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + // example of a custom header. Must be done before calling WriteHeader + // or they will fail to take effect. + w.Header().Add("Server", "Dirp") + // Create some variables holding dummy data. We'll remove these later on + // during the build. + title := "O snail" + content := "O snail\nClimb Mount Fuji,\nBut slowly, slowly!\n\n– Kobayashi Issa" + expires := 7 + + id, err := snippetService.Insert(title, content, expires) + if err != nil { + serverError(w, r, err) + } + logger.Info(fmt.Sprintf("Inserted record. id: %d", id)) + + http.Redirect(w, r, fmt.Sprintf("/snippet/view/%d", id), http.StatusSeeOther) + }) +} + +// func (w http.ResponseWriter, r *http.Request) { +// // example of a custom header. Must be done before calling WriteHeader +// // or they will fail to take effect. +// w.Header().Add("Server", "Dirp") +// // Create some variables holding dummy data. We'll remove these later on +// // during the build. +// title := "O snail" +// content := "O snail\nClimb Mount Fuji,\nBut slowly, slowly!\n\n– Kobayashi Issa" +// expires := 7 + +// id, err := rs.snippetService.Insert(title, content, expires) + +// if err != nil { +// rs.serverError(w, r, err) +// } + +// http.Redirect(w, r, fmt.Sprintf("/snippet/view/%d", id), http.StatusSeeOther) +// } diff --git a/internal/server/helpers.go b/internal/server/helpers.go index 2c235e4..979e3e5 100644 --- a/internal/server/helpers.go +++ b/internal/server/helpers.go @@ -1,6 +1,7 @@ package server import ( + "log/slog" "net/http" "runtime/debug" ) @@ -8,7 +9,8 @@ import ( // serverError helper writes a log entry at Error level (including the request // method and URI as attributes), then sends a generic 500 Internal Server Error // response to the user. -func (rs *RatchetServer) serverError(w http.ResponseWriter, r *http.Request, err error) { +func serverError(w http.ResponseWriter, r *http.Request, err error) { + logger := slog.Default() var ( method = r.Method uri = r.URL.RequestURI() @@ -17,13 +19,13 @@ func (rs *RatchetServer) serverError(w http.ResponseWriter, r *http.Request, err trace = string(debug.Stack()) ) - rs.logger.Error(err.Error(), "method", method, "uri", uri, "trace", trace) + logger.Error(err.Error(), "method", method, "uri", uri, "trace", trace) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } // clientError helper sends a specific status code and corresponding description // to the user. We'll use this later in the book to send responses like 400 "Bad // Request" when there's a problem with the request that the user sent -func (rs *RatchetServer) clientError(w http.ResponseWriter, status int) { +func clientError(w http.ResponseWriter, status int) { http.Error(w, http.StatusText(status), status) } diff --git a/internal/server/routes.go b/internal/server/routes.go new file mode 100644 index 0000000..b472bf1 --- /dev/null +++ b/internal/server/routes.go @@ -0,0 +1,28 @@ +package server + +import ( + "database/sql" + "log/slog" + "net/http" + + "git.runcible.io/learning/ratchet/internal/model" +) + +func addRoutes(mux *http.ServeMux, + logger *slog.Logger, + db *sql.DB, + snippetService *model.SnippetService) http.Handler { + + // /{$} is used to prevent subtree path patterns from acting like a wildcard + // resulting in this route requiring an exact match on "/" only + // You can only include one HTTP method in a route pattern if you choose + // GET will match GET & HEAD http request methods + mux.Handle("GET /{$}", handleHome(logger, snippetService)) + mux.Handle("GET /snippet/view/{id}", handleSnippetView(logger, snippetService)) + mux.Handle("GET /snippet/create", handleSnippetCreateGet()) + mux.Handle("POST /snippet/create", handleSnippetCreatePost(logger, snippetService)) + // mux.Handle("/something", handleSomething(logger, config)) + // mux.Handle("/healthz", handleHealthzPlease(logger)) + // mux.Handle("/", http.NotFoundHandler()) + return mux +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..2f5f312 --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,47 @@ +package server + +import ( + "database/sql" + "log/slog" + "net/http" + + "git.runcible.io/learning/ratchet/internal/model" +) + +type RatchetServer struct { + http.Handler + + logger *slog.Logger + //Services used by HTTP routes + snippetService *model.SnippetService + UserService model.UserService +} + +func NewRatchetServer(logger *slog.Logger, db *sql.DB) *RatchetServer { + rs := new(RatchetServer) + rs.logger = logger + rs.snippetService = &model.SnippetService{DB: db} + // TODO implement middleware that disables directory listings + fileServer := http.FileServer(http.Dir("./ui/static/")) + router := http.NewServeMux() + + // Subtree pattern for static assets + router.Handle("GET /static/", http.StripPrefix("/static/", fileServer)) + + // /{$} is used to prevent subtree path patterns from acting like a wildcard + // resulting in this route requiring an exact match on "/" only + // You can only include one HTTP method in a route pattern if you choose + // GET will match GET & HEAD http request methods + // router.HandleFunc("GET /{$}", rs.home) + // router.HandleFunc("GET /snippet/view/{id}", rs.snippetView) + // router.HandleFunc("GET /snippet/create", rs.snippetCreate) + + // FYI The http.HandlerFunc() adapter works by automatically adding a ServeHTTP() method to + // the passed function + // router.HandleFunc("POST /snippet/create", rs.snippetCreatePost) + + // Mux Router implements the Handler interface. AKA it has a ServeHTTP receiver. + // SEE we can really clean things up by moving this into routes.go and handlers.go + rs.Handler = addRoutes(router, rs.logger, db, rs.snippetService) + return rs +} From f0da255c6a740c0017c18494b073b06d7370cd09 Mon Sep 17 00:00:00 2001 From: Drew Bednar Date: Sat, 25 Jan 2025 14:55:24 -0500 Subject: [PATCH 10/12] Added templates and some notes --- README.md | 50 +++++++++++++++++++++++++++++++-- internal/server/handlers.go | 54 +++++++++++++++++++----------------- internal/server/templates.go | 50 +++++++++++++++++++++++++++++++++ ui/html/pages/view.go.tmpl | 17 ++++++++++++ 4 files changed, 143 insertions(+), 28 deletions(-) create mode 100644 internal/server/templates.go create mode 100644 ui/html/pages/view.go.tmpl diff --git a/README.md b/README.md index 1a4ca45..e058b5b 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ Warning: http.ServeFile() does not automatically sanitize the file path. If you ## Databases -The Let's-go book calls for MySQL. We use [go-sqlite3](ggithub.com/mattn/go-sqlite3) and [go-migrate]() tool to manage migrations instead. Use `make check-system-deps` to validate all tools for this repository are installed. +The Let's-go book calls for MySQL. We use [go-sqlite3](https://github.com/mattn/go-sqlite3) and [go-migrate]() tool to manage migrations instead. Use `make check-system-deps` to validate all tools for this repository are installed. Packages to consider adopting include sqlx or https://github.com/blockloop/scan. Both have the potential to reduce the verbosity that comes will using the standard database/sql package. @@ -115,4 +115,50 @@ To remove the package you can use `go mod tidy` if all references have been remo ``` go get github.com/foo/bar@none -``` \ No newline at end of file +``` + +## Templates + +The [official template docs](https://pkg.go.dev/text/template#hdr-Functions) can be confusing to sort out. If you are new to templates check this out first: https://www.evandemond.com/kb/go-templates + +- Always use `html/templates` because it escapes characters, preventing XSS attacks. +- With Nested templates you must explicitly pass or pipeline your template data. This occurs in `{{template}}` or `{{block}}` and appears like `{{template "main" .}}` or `{{block "sidebar" .}}{{end}}` +- If the type that you’re yielding between `{{ }}` tags has methods defined against it, you can call these methods (so long as they are exported and they return only a single value — or a single value and an error). Example `.Snippet.Created` struct field has the underlying type `time.Time` so `{{.Snippet.Created.Weekday}}`. You can also pass parameters `{{.Snippet.Created.AddDate 0 6 0}}` +- The `html/template` package always strips out any HTML comments you include in your templates which also help avoid XSS attacks when rendering dynamic content. +- Variable names must be prefixed by a dollar sign and can contain alphanumeric characters only. Ex: `{{$bar := len .Foo}}` + + +### Template Actions + +| Action | Description | +|-----------------------------------------------|-----------------------------------------------------------------------------------------------| +| `{{ define }}` | Defines a reusable named template. The content inside `{{ define "name" }} ... {{ end }}` can be invoked using `{{ template "name" }}`. Nested `define` calls are not allowed. | +| `{{ template }}` | Executes a template defined by `{{ define "name" }}`. Can optionally pass data into the template as `dot` (e.g., `{{ template "name" .Data }}`). | +| `{{ block }}` | Defines a named template block, similar to `define`, but allows the content to be overridden when embedding templates. Often used in base templates to create extendable sections. | +| `{{/* a comment */}}` | A comment that is ignored by the template engine. Use it to add notes or disable parts of the template without affecting the rendered output. | + +### Template Functions + +- For all actions below the `{{else}}` clause is optional. +- The _empty_ values are false, 0, any nil pointer or interface value, and any array, slice, map, or string of length zero. +- the `with` and `range` actions change the value of dot. Once you start using them, what dot represents can be different depending on where you are in the template and what you’re doing. +- `-}}` and `{{-` trims white space. `"{{23 -}} < {{- 45}}"` becomes `"23<45"` + +| Function | Description | +|-----------------------------------------------|-------------------------------------------------------------------------------------| +| `{{ if .Foo }} C1 {{ else }} C2 {{ end }}` | If `.Foo` is not empty then render the content C1, otherwise render the content C2. | +| `{{ with .Foo }} C1 {{ else }} C2 {{ end }}` | If `.Foo` is not empty, then set `dot` to the value of `.Foo` and render the content C1, otherwise render the content C2.| +| `{{ range .Foo }} C1 {{ else }} C2 {{ end }}` | If the length of `.Foo` is greater than zero then loop over each element, setting dot to the value of each element and rendering the content C1. If the length of `.Foo` is zero then render the content C2. The underlying type of `.Foo` must be an `array`, `slice`, `map`, or `channel`.| +| `{{ define }}` | | +| `{{ define }}` | | + +| Action | Description | +|---------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------| +| `{{ eq .Foo .Bar }}` | Yields `true` if `.Foo` is equal to `.Bar`. | +| `{{ ne .Foo .Bar }}` | Yields `true` if `.Foo` is not equal to `.Bar`. | +| `{{ not .Foo }}` | Yields the boolean negation of `.Foo`. | +| `{{ or .Foo .Bar }}` | Yields `.Foo` if `.Foo` is not empty; otherwise yields `.Bar`. | +| `{{ index .Foo i }}` | Yields the value of `.Foo` at index `i`. The underlying type of `.Foo` must be a map, slice, or array, and `i` must be an integer value. | +| `{{ printf "%s-%s" .Foo .Bar }}`| Yields a formatted string containing the `.Foo` and `.Bar` values. Works in the same way as `fmt.Sprintf()`. | +| `{{ len .Foo }}` | Yields the length of `.Foo` as an integer. | +| `{{$bar := len .Foo}}` | Assigns the length of `.Foo` to the template variable `$bar`. | diff --git a/internal/server/handlers.go b/internal/server/handlers.go index 3bca7a3..bb295b0 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -78,17 +78,38 @@ func handleSnippetView(logger *slog.Logger, snippetService *model.SnippetService return } - // Write the snippet data as a plain-text HTTP response body. - fmt.Fprintf(w, "%+v", snippet) + files := []string{ + "./ui/html/base.go.tmpl", + "./ui/html/partials/nav.go.tmpl", + "./ui/html/pages/view.go.tmpl", + } + + //ts, err := template.ParseFiles(files...) + ts, err := parseTemplateFiles(files...) + if err != nil { + serverError(w, r, err) + return + } + + data := templateData{ + Snippet: snippet, + } + + logger.Debug(fmt.Sprintf("created template: %s", ts.Name())) + // Any data that you pass as the final parameter to ts.ExecuteTemplate() + // is represented within your HTML templates by the . character (referred to as dot). + // In this specific case, the underlying type of dot will be a models.Snippet struct. + // When the underlying type of dot is a struct, you can render (or yield) the value + // of any exported field in your templates by postfixing dot with the field name + // field, we could yield the snippet title by writing {{.Title}} in our templates. + err = ts.ExecuteTemplate(w, "base", data) + if err != nil { + serverError(w, r, err) + } }) } -// // snippetCreate handles display of the form used to create snippets -// func (rs *RatchetServer) snippetCreate(w http.ResponseWriter, r *http.Request) { -// w.Write([]byte("Create snippet form...")) -// } - func handleSnippetCreateGet() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("Create snippet form..")) @@ -119,22 +140,3 @@ func handleSnippetCreatePost(logger *slog.Logger, snippetService *model.SnippetS http.Redirect(w, r, fmt.Sprintf("/snippet/view/%d", id), http.StatusSeeOther) }) } - -// func (w http.ResponseWriter, r *http.Request) { -// // example of a custom header. Must be done before calling WriteHeader -// // or they will fail to take effect. -// w.Header().Add("Server", "Dirp") -// // Create some variables holding dummy data. We'll remove these later on -// // during the build. -// title := "O snail" -// content := "O snail\nClimb Mount Fuji,\nBut slowly, slowly!\n\n– Kobayashi Issa" -// expires := 7 - -// id, err := rs.snippetService.Insert(title, content, expires) - -// if err != nil { -// rs.serverError(w, r, err) -// } - -// http.Redirect(w, r, fmt.Sprintf("/snippet/view/%d", id), http.StatusSeeOther) -// } diff --git a/internal/server/templates.go b/internal/server/templates.go new file mode 100644 index 0000000..193cd42 --- /dev/null +++ b/internal/server/templates.go @@ -0,0 +1,50 @@ +package server + +import ( + "database/sql" + "html/template" + + "git.runcible.io/learning/ratchet/internal/model" +) + +// Define a templateData type to act as the holding structure for +// any dynamic data that we want to pass to our HTML templates. +// At the moment it only contains one field, but we'll add more +// to it as the build progresses. +type templateData struct { + Snippet model.Snippet +} + +// TEMPLATE FILTERS + +var templateFuncMap = template.FuncMap{ + // This is a trivial example because you can simply use {{ .Content.String }} to call the sql.NullString.String() function. + "nullStringToStr": func(ns sql.NullString) string { + if ns.Valid { + return ns.String + } + return "" + }, +} + +// parseTemplateFiles parses the provided template files and extends the resulting +// template with additional custom template functions (filters) defined in the +// templateFuncMap. +// +// This function serves as a wrapper around template.ParseFiles, allowing the +// inclusion of reusable template functions to enhance the template's capabilities. +// +// Parameters: +// - files: A variadic list of file paths to the template files to be parsed. +// +// Returns: +// - (*template.Template): The parsed template with custom functions injected. +// - (error): An error if the template files cannot be parsed. +func parseTemplateFiles(files ...string) (*template.Template, error) { + tmpl := template.New("").Funcs(templateFuncMap) + tmpl, err := tmpl.ParseFiles(files...) + if err != nil { + return nil, err + } + return tmpl, nil +} diff --git a/ui/html/pages/view.go.tmpl b/ui/html/pages/view.go.tmpl new file mode 100644 index 0000000..b888e5c --- /dev/null +++ b/ui/html/pages/view.go.tmpl @@ -0,0 +1,17 @@ +{{define "title"}}Snippet #{{.Snippet.ID}}{{end}} + +{{define "main"}} + +
+ +
{{.Snippet.Content.String }}
+ +
+{{end}} + \ No newline at end of file From 13445115dae6e62c81141466a63f4c9c7d5a93b6 Mon Sep 17 00:00:00 2001 From: Drew Bednar Date: Sun, 26 Jan 2025 09:44:54 -0500 Subject: [PATCH 11/12] Templates complete --- README.md | 3 +- cmd/ratchetd/main.go | 4 +- internal/server/handlers.go | 71 ++++++++++++++++------------ internal/server/routes.go | 7 +-- internal/server/server.go | 9 ++-- internal/server/templates.go | 90 +++++++++++++++++++++++++++++++++++- ui/html/base.go.tmpl | 2 +- ui/html/pages/home.go.tmpl | 21 ++++++++- ui/html/pages/view.go.tmpl | 12 +++-- 9 files changed, 173 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index e058b5b..8fd3129 100644 --- a/README.md +++ b/README.md @@ -126,7 +126,8 @@ The [official template docs](https://pkg.go.dev/text/template#hdr-Functions) can - If the type that you’re yielding between `{{ }}` tags has methods defined against it, you can call these methods (so long as they are exported and they return only a single value — or a single value and an error). Example `.Snippet.Created` struct field has the underlying type `time.Time` so `{{.Snippet.Created.Weekday}}`. You can also pass parameters `{{.Snippet.Created.AddDate 0 6 0}}` - The `html/template` package always strips out any HTML comments you include in your templates which also help avoid XSS attacks when rendering dynamic content. - Variable names must be prefixed by a dollar sign and can contain alphanumeric characters only. Ex: `{{$bar := len .Foo}}` - +- You can combine multiple template functions with `()`. Ex: `{{if (gt (len .Foo) 99)}} C1 {{end}}` +- Within a `{{range}}` action you can use the `{{break}}` command to end the loop early, and `{{continue}}` to immediately start the next loop iteration. ### Template Actions diff --git a/cmd/ratchetd/main.go b/cmd/ratchetd/main.go index d9ccf1b..524e2d9 100644 --- a/cmd/ratchetd/main.go +++ b/cmd/ratchetd/main.go @@ -42,10 +42,12 @@ func main() { // Close db connection before exiting main. defer db.Close() + tc, err := server.InitTemplateCache() + // Propagate build information to root package to share globally // ratchet.Version = strings.TrimPrefix(version, "") // ratchet.Commit = commit - server := server.NewRatchetServer(logger, db) + server := server.NewRatchetServer(logger, tc, db) // START SERVING REQUESTS slog.Debug("Herp dirp!") diff --git a/internal/server/handlers.go b/internal/server/handlers.go index bb295b0..cceeda2 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -11,7 +11,7 @@ import ( ) // TODO function should accept and a pointer to an interface allowing for mocking in tests. -func handleHome(logger *slog.Logger, snippetService *model.SnippetService) http.Handler { +func handleHome(logger *slog.Logger, tc *TemplateCache, snippetService *model.SnippetService) http.Handler { return http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { logger.Info("request received", "method", "GET", "path", "/") @@ -26,9 +26,15 @@ func handleHome(logger *slog.Logger, snippetService *model.SnippetService) http. logger.Debug(fmt.Sprintf("%d snippets retrieved", len(snippets))) - for _, snippet := range snippets { - fmt.Fprintf(w, "%+v\n", snippet) - } + // Old way. We want default data so + // data := templateData{ + // Snippets: snippets, + // } + data := newTemplateData() + data.Snippets = snippets + + renderTemplate(w, r, tc, http.StatusOK, "home.go.tmpl", data) + // // Initialize a slice containing the paths to the two files. It's important // // to note that the file containing our base template must be the *first* // // file in the slice. @@ -38,24 +44,25 @@ func handleHome(logger *slog.Logger, snippetService *model.SnippetService) http. // "./ui/html/pages/home.go.tmpl", // } - // // read template file into template set. + // read template file into template set. // ts, err := template.ParseFiles(files...) + // ts, err := parseTemplateFiles(files...) // if err != nil { - // rs.serverError(w, r, err) + // serverError(w, r, err) // return // } // // Write template content to response body - // err = ts.ExecuteTemplate(w, "base", nil) + // err = ts.ExecuteTemplate(w, "base", data) // if err != nil { // // This is the older more verbose way of doing what RatchetServer.serverError does // // rs.logger.Error(err.Error()) // // http.Error(w, "Internal Server Error", http.StatusInternalServerError) - // rs.serverError(w, r, err) + // serverError(w, r, err) // } }) } -func handleSnippetView(logger *slog.Logger, snippetService *model.SnippetService) http.Handler { +func handleSnippetView(logger *slog.Logger, tc *TemplateCache, snippetService *model.SnippetService) http.Handler { return http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { id, err := strconv.Atoi(r.PathValue("id")) @@ -78,24 +85,24 @@ func handleSnippetView(logger *slog.Logger, snippetService *model.SnippetService return } - files := []string{ - "./ui/html/base.go.tmpl", - "./ui/html/partials/nav.go.tmpl", - "./ui/html/pages/view.go.tmpl", - } + // files := []string{ + // "./ui/html/base.go.tmpl", + // "./ui/html/partials/nav.go.tmpl", + // "./ui/html/pages/view.go.tmpl", + // } - //ts, err := template.ParseFiles(files...) - ts, err := parseTemplateFiles(files...) - if err != nil { - serverError(w, r, err) - return - } + // //ts, err := template.ParseFiles(files...) + // ts, err := parseTemplateFiles(files...) + // if err != nil { + // serverError(w, r, err) + // return + // } - data := templateData{ - Snippet: snippet, - } + // data := templateData{ + // Snippet: snippet, + // } - logger.Debug(fmt.Sprintf("created template: %s", ts.Name())) + // logger.Debug(fmt.Sprintf("created template: %s", ts.Name())) // Any data that you pass as the final parameter to ts.ExecuteTemplate() // is represented within your HTML templates by the . character (referred to as dot). @@ -103,10 +110,16 @@ func handleSnippetView(logger *slog.Logger, snippetService *model.SnippetService // When the underlying type of dot is a struct, you can render (or yield) the value // of any exported field in your templates by postfixing dot with the field name // field, we could yield the snippet title by writing {{.Title}} in our templates. - err = ts.ExecuteTemplate(w, "base", data) - if err != nil { - serverError(w, r, err) - } + // err = ts.ExecuteTemplate(w, "base", data) + // if err != nil { + // serverError(w, r, err) + // } + // data := templateData{ + // Snippet: snippet, + // } + data := newTemplateData() + data.Snippet = snippet + renderTemplate(w, r, tc, http.StatusOK, "view.go.tmpl", data) }) } @@ -119,7 +132,7 @@ func handleSnippetCreateGet() http.Handler { // snippetCreate handles display of the form used to create snippets // // curl -iL -d "" http://localhost:5001/snippet/create -func handleSnippetCreatePost(logger *slog.Logger, snippetService *model.SnippetService) http.Handler { +func handleSnippetCreatePost(logger *slog.Logger, tc *TemplateCache, snippetService *model.SnippetService) http.Handler { return http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { // example of a custom header. Must be done before calling WriteHeader diff --git a/internal/server/routes.go b/internal/server/routes.go index b472bf1..5563ebe 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -10,6 +10,7 @@ import ( func addRoutes(mux *http.ServeMux, logger *slog.Logger, + tc *TemplateCache, db *sql.DB, snippetService *model.SnippetService) http.Handler { @@ -17,10 +18,10 @@ func addRoutes(mux *http.ServeMux, // resulting in this route requiring an exact match on "/" only // You can only include one HTTP method in a route pattern if you choose // GET will match GET & HEAD http request methods - mux.Handle("GET /{$}", handleHome(logger, snippetService)) - mux.Handle("GET /snippet/view/{id}", handleSnippetView(logger, snippetService)) + mux.Handle("GET /{$}", handleHome(logger, tc, snippetService)) + mux.Handle("GET /snippet/view/{id}", handleSnippetView(logger, tc, snippetService)) mux.Handle("GET /snippet/create", handleSnippetCreateGet()) - mux.Handle("POST /snippet/create", handleSnippetCreatePost(logger, snippetService)) + mux.Handle("POST /snippet/create", handleSnippetCreatePost(logger, tc, snippetService)) // mux.Handle("/something", handleSomething(logger, config)) // mux.Handle("/healthz", handleHealthzPlease(logger)) // mux.Handle("/", http.NotFoundHandler()) diff --git a/internal/server/server.go b/internal/server/server.go index 2f5f312..9f1ac50 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -11,16 +11,19 @@ import ( type RatchetServer struct { http.Handler - logger *slog.Logger + logger *slog.Logger + templateCache *TemplateCache //Services used by HTTP routes snippetService *model.SnippetService UserService model.UserService } -func NewRatchetServer(logger *slog.Logger, db *sql.DB) *RatchetServer { +func NewRatchetServer(logger *slog.Logger, tc *TemplateCache, db *sql.DB) *RatchetServer { rs := new(RatchetServer) rs.logger = logger rs.snippetService = &model.SnippetService{DB: db} + + rs.templateCache = tc // TODO implement middleware that disables directory listings fileServer := http.FileServer(http.Dir("./ui/static/")) router := http.NewServeMux() @@ -42,6 +45,6 @@ func NewRatchetServer(logger *slog.Logger, db *sql.DB) *RatchetServer { // Mux Router implements the Handler interface. AKA it has a ServeHTTP receiver. // SEE we can really clean things up by moving this into routes.go and handlers.go - rs.Handler = addRoutes(router, rs.logger, db, rs.snippetService) + rs.Handler = addRoutes(router, rs.logger, rs.templateCache, db, rs.snippetService) return rs } diff --git a/internal/server/templates.go b/internal/server/templates.go index 193cd42..2909eaa 100644 --- a/internal/server/templates.go +++ b/internal/server/templates.go @@ -1,8 +1,13 @@ package server import ( + "bytes" "database/sql" + "fmt" "html/template" + "net/http" + "path/filepath" + "time" "git.runcible.io/learning/ratchet/internal/model" ) @@ -12,7 +17,25 @@ import ( // At the moment it only contains one field, but we'll add more // to it as the build progresses. type templateData struct { - Snippet model.Snippet + CurrentYear int + Snippet model.Snippet + Snippets []model.Snippet +} + +// newTemplateData is useful to inject default values. Example CSRF tokens for forms. +func newTemplateData() templateData { + return templateData{CurrentYear: time.Now().Year()} +} + +// TEMPLATE FUNCTIONS + +// Custom template functions can accept as many parameters as they need to, but they must return one value only. +// The only exception to this is if you want to return an error as the second value, in which case that’s OK too. + +// Create a humanDate function which returns a nicely formatted string +// representation of a time.Time object. +func humanDate(t time.Time) string { + return t.Format("02 Jan 2006 at 15:04") } // TEMPLATE FILTERS @@ -25,6 +48,7 @@ var templateFuncMap = template.FuncMap{ } return "" }, + "humanDate": humanDate, } // parseTemplateFiles parses the provided template files and extends the resulting @@ -48,3 +72,67 @@ func parseTemplateFiles(files ...string) (*template.Template, error) { } return tmpl, nil } + +// TODO use an go:embed FS instead of file paths + +type TemplateCache map[string]*template.Template + +func InitTemplateCache() (*TemplateCache, error) { + cache := TemplateCache{} + + pages, err := filepath.Glob("./ui/html/pages/*.tmpl") + if err != nil { + return nil, err + } + + for _, page := range pages { + name := filepath.Base(page) + + tmpl := template.New(name).Funcs(templateFuncMap) + // Parse the base template file into a template set. + tmpl, err = tmpl.ParseFiles("./ui/html/base.go.tmpl") + if err != nil { + return nil, err + } + // Call ParseGlob() *on this template set* to add any partials. + tmpl, err = tmpl.ParseGlob("./ui/html/partials/*.tmpl") + if err != nil { + return nil, err + } + + // Call ParseFiles() *on this template set* to add the page template. + tmpl, err = tmpl.ParseFiles(page) + if err != nil { + return nil, err + } + + cache[name] = tmpl + + } + return &cache, nil +} + +func renderTemplate(w http.ResponseWriter, r *http.Request, tc *TemplateCache, status int, page string, data templateData) { + cache := *tc + ts, ok := cache[page] + if !ok { + err := fmt.Errorf("the template %s does not exist", page) + serverError(w, r, err) + return + } + + // Write the template results to a buffer to capture templating errors before writing + // to ResponseWriter + buf := new(bytes.Buffer) + + err := ts.ExecuteTemplate(buf, "base", data) + if err != nil { + serverError(w, r, err) + return + } + + w.WriteHeader(status) + + buf.WriteTo(w) + +} diff --git a/ui/html/base.go.tmpl b/ui/html/base.go.tmpl index 09d86e4..d9f03b1 100644 --- a/ui/html/base.go.tmpl +++ b/ui/html/base.go.tmpl @@ -17,7 +17,7 @@
{{template "main" .}}
-
Powered by Go
+
Powered by Go in {{ .CurrentYear }}
diff --git a/ui/html/pages/home.go.tmpl b/ui/html/pages/home.go.tmpl index 4925fb7..ddda5d0 100644 --- a/ui/html/pages/home.go.tmpl +++ b/ui/html/pages/home.go.tmpl @@ -1,6 +1,23 @@ {{define "title"}}Home{{end}} {{define "main" -}} -

Latest Snippets

-

There's nothing to see yet!

+ {{ if .Snippets}} + + + + + + + {{range .Snippets}} + + + + + + + {{end}} +
TitleCreatedID
{{.Title.String}}{{.CreatedAt | humanDate }}#{{.ID}}
+ {{else}} +

There's nothing to see yet!

+ {{end}} {{- end -}} diff --git a/ui/html/pages/view.go.tmpl b/ui/html/pages/view.go.tmpl index b888e5c..c1001ae 100644 --- a/ui/html/pages/view.go.tmpl +++ b/ui/html/pages/view.go.tmpl @@ -1,17 +1,19 @@ {{define "title"}}Snippet #{{.Snippet.ID}}{{end}} {{define "main"}} + {{ with .Snippet }}
-
{{.Snippet.Content.String }}
+
{{.Content.String }}
+ {{ end }} {{end}} \ No newline at end of file From ff9da4ebc404201becf9d0e33cdd3471d2458e5c Mon Sep 17 00:00:00 2001 From: Drew Bednar Date: Sun, 26 Jan 2025 13:44:37 -0500 Subject: [PATCH 12/12] Middleware --- README.md | 15 +++++ cmd/ratchetd/main.go | 2 +- internal/logging/logging.go | 4 +- internal/server/handlers.go | 3 - internal/server/middleware.go | 103 ++++++++++++++++++++++++++++++++++ internal/server/server.go | 17 ++---- 6 files changed, 125 insertions(+), 19 deletions(-) create mode 100644 internal/server/middleware.go diff --git a/README.md b/README.md index 8fd3129..f3dcb3f 100644 --- a/README.md +++ b/README.md @@ -163,3 +163,18 @@ The [official template docs](https://pkg.go.dev/text/template#hdr-Functions) can | `{{ printf "%s-%s" .Foo .Bar }}`| Yields a formatted string containing the `.Foo` and `.Bar` values. Works in the same way as `fmt.Sprintf()`. | | `{{ len .Foo }}` | Yields the length of `.Foo` as an integer. | | `{{$bar := len .Foo}}` | Assigns the length of `.Foo` to the template variable `$bar`. | + + +## Middleware + +- Function that forms a closure over the `next.ServerHTTP` function in a call chain +- `myMiddleware → servemux → application handler` applies to all requests +- `servemux → myMiddleware → application handler` wraps specific handlers. Example Auth middleware. +- The control flow actually looks like `commonHeaders → servemux → application handler → servemux → commonHeaders` + - This means defered blocks or code after `next.ServeHTTP()` will execute on the way back through. +- Early returns before `next.ServeHTTP()` will hand controlflow back upsteam. + - Auth middle ware is a good example. `w.WriteHeader(http.StatusForbidden); return` + +### Headers + +- [Primer on Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP) \ No newline at end of file diff --git a/cmd/ratchetd/main.go b/cmd/ratchetd/main.go index 524e2d9..9ba5e52 100644 --- a/cmd/ratchetd/main.go +++ b/cmd/ratchetd/main.go @@ -31,7 +31,7 @@ func main() { // DEPENDENCY INJECTION FOR HANDLERS // Setup Logging - logger := logging.InitLogging(*logLevel) + logger := logging.InitLogging(*logLevel, false) // Setup DB Connection Pool db, err := rdb.OpenSqlite3DB(*dbPath) diff --git a/internal/logging/logging.go b/internal/logging/logging.go index 18c38c6..fbacd13 100644 --- a/internal/logging/logging.go +++ b/internal/logging/logging.go @@ -22,14 +22,14 @@ func parseLogLevel(levelStr string) slog.Level { } // InitLogggin initializes global structured logging for the entire application -func InitLogging(level string) *slog.Logger { +func InitLogging(level string, addSource bool) *slog.Logger { // Use os.Stderr // // Stderr is used for diagnostics and logging. Stdout is used for program // output. Stderr also have greater likely hood of being seen if a programs // output is being redirected. parsedLogLevel := parseLogLevel(level) - loggerHandler := slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: parsedLogLevel, AddSource: true}) + loggerHandler := slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: parsedLogLevel, AddSource: addSource}) logger := slog.New(loggerHandler) slog.SetDefault(logger) return logger diff --git a/internal/server/handlers.go b/internal/server/handlers.go index cceeda2..2d4f90f 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -14,9 +14,6 @@ import ( func handleHome(logger *slog.Logger, tc *TemplateCache, snippetService *model.SnippetService) http.Handler { return http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { - logger.Info("request received", "method", "GET", "path", "/") - // Just and example of adding a header - w.Header().Add("Server", "Go") // Retrieve Snippets from DB snippets, err := snippetService.Lastest() if err != err { diff --git a/internal/server/middleware.go b/internal/server/middleware.go new file mode 100644 index 0000000..814cbed --- /dev/null +++ b/internal/server/middleware.go @@ -0,0 +1,103 @@ +package server + +import ( + "fmt" + "log/slog" + "net/http" +) + +// https://owasp.org/www-project-secure-headers/ guidance +// - Headers to Add: https://owasp.org/www-project-secure-headers/ci/headers_add.json +// - Headers to Remove: https://owasp.org/www-project-secure-headers/ci/headers_remove.json +// - See also testing tools like https://github.com/ovh/venom for security testing + +// Content-Security-Policy (often abbreviated to CSP) headers are used to restrict where the +// resources for your web page (e.g. JavaScript, images, fonts etc) can be loaded from. Setting +// a strict CSP policy helps prevent a variety of cross-site scripting, clickjacking, and +// other code-injection attacks. + +// CSP headers and how they work is a big topic, and I recommend reading this primer if you +// haven’t come across them before. But, in our case, the header tells the browser that +//it’s OK to load fonts from fonts.gstatic.com, stylesheets from fonts.googleapis.com and +// self (our own origin), and then everything else only from self. Inline JavaScript is blocked +// by default. + +// Referrer-Policy is used to control what information is included in a Referer header when +// a user navigates away from your web page. In our case, we’ll set the value to +// origin-when-cross-origin, which means that the full URL will be included for same-origin +// requests, but for all other requests information like the URL path and any query string +// values will be stripped out. + +// X-Content-Type-Options: nosniff instructs browsers to not MIME-type sniff the content-type +// of the response, which in turn helps to prevent content-sniffing attacks. + +// X-Frame-Options: deny is used to help prevent clickjacking attacks in older browsers that +// don’t support CSP headers. + +// X-XSS-Protection: 0 is used to disable the blocking of cross-site scripting attacks. +// Previously it was good practice to set this header to X-XSS-Protection: 1; mode=block, +// but when you’re using CSP headers like we are the recommendation is to disable this feature +// altogether. + +// CommonHeaderMiddleware adds common headers and secure headers following OWASP guidance. +func CommonHeaderMiddleware(next http.Handler) http.Handler { + // Technically the guidance is to remove "Server" header + headers := map[string]string{"Server": "Go"} + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + for k, v := range headers { + // know the diff between Add and Set + w.Header().Set(k, v) + } + + w.Header().Set("Content-Security-Policy", + "default-src 'self'; style-src 'self' fonts.googleapis.com; font-src fonts.gstatic.com") + + w.Header().Set("Referrer-Policy", "origin-when-cross-origin") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("X-Frame-Options", "deny") + w.Header().Set("X-XSS-Protection", "0") + next.ServeHTTP(w, r) + }) +} + +func RequestLoggingMiddleware(next http.Handler, logger *slog.Logger) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var ( + ip = r.RemoteAddr + proto = r.Proto + method = r.Method + uri = r.URL.RequestURI() + ) + logger.Info("received request", "ip", ip, "proto", proto, "method", method, "uri", uri) + + next.ServeHTTP(w, r) + }) +} + +// RecoveryMiddleware recovers from panics that occur within http handler functions +// +// Go's HTTP server assumes that any panic is isolated to the goroutine serving the +// active http request. Following a panic the server will log a stack trace to the +// server error log, unwind the stack of the affected goroutine, calling defered functions +// along the way, and closing the underlying http connection. This doesn't terminate the application. +// +// Important this only will recover panics raised within the same goroutine that was +// throwing the panic. This means that if the handler is spinning off it's own goroutine +// you need to add a defer recover function like this into that goroutine or your server +// will crash since the http Server will not be handling the panic in the request go routine +func RecoveryMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + if err := recover(); err != nil { + // Go's HTTP server will close the connection for us after the response with + // this header has been sent. http/2 it will strip it and send a GOAWAY frame + // instead + w.Header().Set("Connection", "close") + serverError(w, r, fmt.Errorf("%s", err)) + } + }() + + next.ServeHTTP(w, r) + }) +} diff --git a/internal/server/server.go b/internal/server/server.go index 9f1ac50..f8b3291 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -31,20 +31,11 @@ func NewRatchetServer(logger *slog.Logger, tc *TemplateCache, db *sql.DB) *Ratch // Subtree pattern for static assets router.Handle("GET /static/", http.StripPrefix("/static/", fileServer)) - // /{$} is used to prevent subtree path patterns from acting like a wildcard - // resulting in this route requiring an exact match on "/" only - // You can only include one HTTP method in a route pattern if you choose - // GET will match GET & HEAD http request methods - // router.HandleFunc("GET /{$}", rs.home) - // router.HandleFunc("GET /snippet/view/{id}", rs.snippetView) - // router.HandleFunc("GET /snippet/create", rs.snippetCreate) - - // FYI The http.HandlerFunc() adapter works by automatically adding a ServeHTTP() method to - // the passed function - // router.HandleFunc("POST /snippet/create", rs.snippetCreatePost) - // Mux Router implements the Handler interface. AKA it has a ServeHTTP receiver. // SEE we can really clean things up by moving this into routes.go and handlers.go - rs.Handler = addRoutes(router, rs.logger, rs.templateCache, db, rs.snippetService) + wrappedMux := addRoutes(router, rs.logger, rs.templateCache, db, rs.snippetService) + rs.Handler = CommonHeaderMiddleware(wrappedMux) + rs.Handler = RequestLoggingMiddleware(rs.Handler, logger) + rs.Handler = RecoveryMiddleware(rs.Handler) return rs }