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" .}} + {{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 0000000..739e573 Binary files /dev/null and b/ui/static/img/favicon.ico differ diff --git a/ui/static/img/logo.png b/ui/static/img/logo.png new file mode 100755 index 0000000..4681437 Binary files /dev/null and b/ui/static/img/logo.png differ 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