diff --git a/.air.toml b/.air.toml new file mode 100644 index 0000000..d6e480a --- /dev/null +++ b/.air.toml @@ -0,0 +1,52 @@ +root = "." +testdata_dir = "testdata" +tmp_dir = "tmp" + +[build] + args_bin = ["-logging=DEBUG"] + 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..3a2321a 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,9 @@ # Go workspace file go.work +tmp/ + +# Sqlite +*.db +*.db-shm +*.db-wal diff --git a/Makefile b/Makefile index 7070b2d..f50ca24 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,32 @@ +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 box" -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 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/README.md b/README.md index a554b37..f3dcb3f 100644 --- a/README.md +++ b/README.md @@ -2,9 +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. @@ -39,3 +42,139 @@ 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) +- [Organizing Database Access in Go](https://www.alexedwards.net/blog/organising-database-access) + +### 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. + +## Databases + +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. + +### 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 + +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 +``` + +## 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}}` +- 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 + +| 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`. | + + +## 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 b2b8c6e..9ba5e52 100644 --- a/cmd/ratchetd/main.go +++ b/cmd/ratchetd/main.go @@ -1,21 +1,61 @@ package main import ( + "flag" "fmt" + "log/slog" + "net/http" "os" - "strings" - "git.runcible.io/learning/ratchet" + 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" + // ratchethttp "git.runcible.io/learning/ratchet/internal" ) -var ( - version string - commit string -) +// var ( +// version string +// commit string +// ) func main() { + // 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].") + 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, false) + // 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() + + tc, err := server.InitTemplateCache() + // 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) + // ratchet.Version = strings.TrimPrefix(version, "") + // ratchet.Commit = commit + server := server.NewRatchetServer(logger, tc, db) + + // 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)) + // 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/go.mod b/go.mod index f5929ae..c8971a2 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 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/error.go b/internal/apperror/apperror.go similarity index 99% rename from error.go rename to internal/apperror/apperror.go index 42daca0..6560556 100644 --- a/error.go +++ b/internal/apperror/apperror.go @@ -1,4 +1,4 @@ -package ratchet +package apperror import ( "errors" diff --git a/error_test.go b/internal/apperror/apperror_test.go similarity index 98% rename from error_test.go rename to internal/apperror/apperror_test.go index 20917c0..ea4b633 100644 --- a/error_test.go +++ b/internal/apperror/apperror_test.go @@ -1,4 +1,4 @@ -package ratchet +package apperror import ( "errors" 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 +} diff --git a/internal/logging/logging.go b/internal/logging/logging.go new file mode 100644 index 0000000..fbacd13 --- /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, 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: addSource}) + logger := slog.New(loggerHandler) + slog.SetDefault(logger) + return logger +} 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 new file mode 100644 index 0000000..ea4ac1e --- /dev/null +++ b/internal/model/snippets.go @@ -0,0 +1,147 @@ +package model + +import ( + "database/sql" + "errors" + "fmt" + "log/slog" + "time" +) + +type Snippet struct { + ID int + // Title string + // Content string + Title sql.NullString + Content sql.NullString + 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)) + // 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") + 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 DML statement 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 ignoring the record if expired. +func (s *SnippetService) Get(id int) (Snippet, error) { + + 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) { + + 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/user.go b/internal/model/user.go similarity index 93% rename from user.go rename to internal/model/user.go index aa726f1..ae3b28f 100644 --- a/user.go +++ b/internal/model/user.go @@ -1,8 +1,10 @@ -package ratchet +package model 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/user_test.go b/internal/model/user_test.go similarity index 70% rename from user_test.go rename to internal/model/user_test.go index f124f67..05559a4 100644 --- a/user_test.go +++ b/internal/model/user_test.go @@ -1,11 +1,15 @@ -package ratchet +package model -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/server/handlers.go b/internal/server/handlers.go new file mode 100644 index 0000000..2d4f90f --- /dev/null +++ b/internal/server/handlers.go @@ -0,0 +1,152 @@ +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, tc *TemplateCache, snippetService *model.SnippetService) http.Handler { + return http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + // 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))) + + // 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. + // 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...) + // ts, err := parseTemplateFiles(files...) + // if err != nil { + // serverError(w, r, err) + // return + // } + // // Write template content to response body + // 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) + // serverError(w, r, err) + // } + }) +} + +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")) + 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 + } + + // 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) + // } + // data := templateData{ + // Snippet: snippet, + // } + data := newTemplateData() + data.Snippet = snippet + renderTemplate(w, r, tc, http.StatusOK, "view.go.tmpl", data) + }) +} + +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, 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 + // 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) + }) +} diff --git a/internal/server/helpers.go b/internal/server/helpers.go new file mode 100644 index 0000000..979e3e5 --- /dev/null +++ b/internal/server/helpers.go @@ -0,0 +1,31 @@ +package server + +import ( + "log/slog" + "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 serverError(w http.ResponseWriter, r *http.Request, err error) { + logger := slog.Default() + 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()) + ) + + 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 clientError(w http.ResponseWriter, status int) { + http.Error(w, http.StatusText(status), status) +} 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/routes.go b/internal/server/routes.go new file mode 100644 index 0000000..5563ebe --- /dev/null +++ b/internal/server/routes.go @@ -0,0 +1,29 @@ +package server + +import ( + "database/sql" + "log/slog" + "net/http" + + "git.runcible.io/learning/ratchet/internal/model" +) + +func addRoutes(mux *http.ServeMux, + logger *slog.Logger, + tc *TemplateCache, + 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, 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, tc, 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..f8b3291 --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,41 @@ +package server + +import ( + "database/sql" + "log/slog" + "net/http" + + "git.runcible.io/learning/ratchet/internal/model" +) + +type RatchetServer struct { + http.Handler + + logger *slog.Logger + templateCache *TemplateCache + //Services used by HTTP routes + snippetService *model.SnippetService + UserService model.UserService +} + +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() + + // Subtree pattern for static assets + router.Handle("GET /static/", http.StripPrefix("/static/", fileServer)) + + // 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 + 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 +} diff --git a/internal/server/templates.go b/internal/server/templates.go new file mode 100644 index 0000000..2909eaa --- /dev/null +++ b/internal/server/templates.go @@ -0,0 +1,138 @@ +package server + +import ( + "bytes" + "database/sql" + "fmt" + "html/template" + "net/http" + "path/filepath" + "time" + + "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 { + 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 + +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 "" + }, + "humanDate": humanDate, +} + +// 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 +} + +// 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/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; diff --git a/ui/html/base.go.tmpl b/ui/html/base.go.tmpl new file mode 100644 index 0000000..d9f03b1 --- /dev/null +++ b/ui/html/base.go.tmpl @@ -0,0 +1,24 @@ +{{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..ddda5d0 --- /dev/null +++ b/ui/html/pages/home.go.tmpl @@ -0,0 +1,23 @@ +{{define "title"}}Home{{end}} + +{{define "main" -}} + {{ 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 new file mode 100644 index 0000000..c1001ae --- /dev/null +++ b/ui/html/pages/view.go.tmpl @@ -0,0 +1,19 @@ +{{define "title"}}Snippet #{{.Snippet.ID}}{{end}} + +{{define "main"}} + {{ with .Snippet }} + +
+
+ {{.Title.String }} + #{{.ID}} +
+
{{.Content.String }}
+
+ + +
+
+ {{ end }} +{{end}} + \ No newline at end of file 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 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