Compare commits

..

No commits in common. 'main' and 'drew/lets-go' have entirely different histories.

@ -16,7 +16,7 @@ tmp_dir = "tmp"
include_dir = []
include_ext = ["go", "tpl", "tmpl", "html", "go.tmpl"]
include_file = []
kill_delay = "1s"
kill_delay = "0s"
log = "build-errors.log"
poll = false
poll_interval = 0
@ -24,7 +24,7 @@ tmp_dir = "tmp"
pre_cmd = []
rerun = false
rerun_delay = 500
send_interrupt = true
send_interrupt = false
stop_on_error = false
[color]

@ -1,15 +0,0 @@
kind: pipeline
type: docker
name: CI Test Pipeline
steps:
- name: Unit Tests
image: golang:1.23
privileged: true
commands:
- go test -v ./...
trigger:
event:
- pull_request
- push

3
.gitignore vendored

@ -27,6 +27,3 @@ tmp/
*.db
*.db-shm
*.db-wal
# certs
*.pem

@ -1,63 +1,27 @@
SQL_DATABASE?=./ratchet.db
COVER_PROFILE?=./tmp/profile.out
# TODO maybe only use -race in an ARG for CI
FLAGS := -race -v
# `make <target> ARGS=no-cache` will avoid using cached results in go test.
ifeq ($(ARGS), no-cache)
FLAGS += -count=1
endif
# make test ARGS=no-cache
test:
go test $(FLAGS) ./...
.PHONY: test
test-short:
go test -short $(FLAGS) ./...
.PHONY: test-short
# make test-int ARGS=no-cache
test-int:
go test $(FLAGS) ./cmd/...
.PHONY: test-int
## Coverage See also -covermode=count and -covermode=atomic
cover-html: test-cover
go tool cover -html=$(COVER_PROFILE)
.PHONY:cover-html
coverage: test-cover
go tool cover -func=$(COVER_PROFILE)
.PHONY: coverage
test-cover:
go test -coverprofile=$(COVER_PROFILE) $(FLAGS) ./...
.PHONY: test-cover
go test -v ./...
PHONEY: test
serve:
go run ./cmd/ratchetd/main.go
.PHONY: serve
PHONEY: serve
# SQLite Commands
sql-cli:
sqlite3 $(SQL_DATABASE) -cmd ".headers on" -cmd ".mode box" -cmd ".tables"
.PHONY: sql-cli
init-db: run-migrate
sqlite3 $(SQL_DATABASE) "PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL;"
.PHONY: init-db
seed-db:
sqlite3 $(SQL_DATABASE) "INSERT INTO snippets (title, content, expires_at) VALUES ('placeholder', 'placeholder content', datetime('now', '+6 months'));"
.PHONY: seed-db
run-migrate:
migrate -database sqlite3://$(SQL_DATABASE) -path ./migrations up
.PHONY: run-migrate
# Checks system dependencies needed to run the local dev environment
check-system-deps:
@ -65,10 +29,4 @@ check-system-deps:
@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 👍"
.PHONY: check-system-deps
# Certs
local-certs:
cd ./tls && go run /usr/local/go/src/crypto/tls/generate_cert.go --rsa-bits=2048 --host=localhost
.PHONY: local-certs
@echo "System dependencies fulfilled 👍"

@ -1,7 +1,5 @@
# Ratchet
![build-status](https://drone.runcible.io/api/badges/learning/ratchet/status.svg)
An example web application in Golang.
https://lets-go.alexedwards.net/sample/02.09-serving-static-files.html
@ -20,12 +18,6 @@ Loosely inspired by the organization of [WTFDial](https://github.com/benbjohnson
This is project is an Indie reader inspired by [Feedi](https://github.com/facundoolano/feedi).
### Packages/Tools to investigate
- ent
- chi router
- https://github.com/FiloSottile/mkcert
### Implementation Subpackages
@ -52,10 +44,6 @@ The `ratchetd` cmd binary uses Oauth so you will need to create a new Oauth App.
## Additional Resources
- [SQLite DB backups with LiteStream](https://litestream.io/getting-started/)
- [Unit / Integration Testing](https://dev.to/sha254/testing-rest-apis-in-go-a-guide-to-unit-and-integration-testing-with-gos-standard-testing-library-2o9l)
- [Graceful Shutdown](https://dev.to/mokiat/proper-http-shutdown-in-go-3fji)
- [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)
@ -187,102 +175,6 @@ The [official template docs](https://pkg.go.dev/text/template#hdr-Functions) can
- Early returns before `next.ServeHTTP()` will hand controlflow back upsteam.
- Auth middle ware is a good example. `w.WriteHeader(http.StatusForbidden); return`
Needs:
- [x] Access Logs
- [ ] Rate Limiting
- [ ] CORS
- [X] Common Headers
- [X] Recovery from Panic
### Headers
- [Primer on Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP)
## Form Validation
- https://www.alexedwards.net/blog/validation-snippets-for-go: Covers snippets for common form validation logic.
## Session Managment
- https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html
- https://cheatsheetseries.owasp.org/cheatsheets/HTTP_Strict_Transport_Security_Cheat_Sheet.html
- https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html
Session management used here: `go get github.com/alexedwards/scs/v2@v2`
Session ids should be meaningless. The server side stored information can typically include:
- the client IP address
- User-Agent
- e-mail
- username
- user ID
- role
- privilege level
- access rights
- language preferences
- account ID
- current state
- last login
- session timeouts
- and other internal session details.
The preferred session ID exchange mechanism should allow defining advanced token properties, such as the token expiration date and time, or granular usage constraints.
- Cookies foot the bill which is why they are used.
- the Secure cookie attribute must be used to ensure the session ID is only exchanged through an encrypted channel.
- Do not switch a given session from HTTP to HTTPS, or vice-versa, as this will disclose the session ID in the clear through the network.
- When redirecting to HTTPS, ensure that the cookie is set or regenerated after the redirect has occurred.
- Do not mix encrypted and unencrypted contents (HTML pages, images, CSS, JavaScript files, etc) in the same page, or from the same domain.
- HTTP Strict Transport Security (also named HSTS) can be used to enforce HTTPS connections. Can also https://www.leviathansecurity.com/blog/the-double-edged-sword-of-hsts-persistence-and-privacy result in user tracking even without cookies.
- It is important to emphasize that TLS does not protect against session ID prediction, brute force, client-side tampering or fixation; however, it does provide effective protection against an attacker intercepting or stealing session IDs through a man in the middle attack.
### Cookies
- `Secure`attribute instructs web browsers to only send the cookie through an encrypted HTTPS (SSL/TLS) connection.
- protects against man in the middle
- The `HttpOnly` cookie attribute instructs web browsers not to allow scripts (e.g. JavaScript or VBscript) an ability to access the cookies via the DOM document.cookie object.
- This session ID protection is mandatory to prevent session ID stealing through XSS attacks.
- XSS + CSRF though could still leak the session id.
- `SameSite` defines a cookie attribute preventing browsers from sending a SameSite flagged cookie with cross-site requests
- The `Domain` cookie attribute instructs web browsers to only send the cookie to the specified domain and all subdomains.
- THE DOMAIN ATTRIBUTE SHOULD NOT BE SET, which will restrict the cookie to the origin server.
- You don't want to mess with cross subdomain cookies. That can just be a mess.
- The `Path` cookie attribute instructs web browsers to only send the cookie to the specified directory or subdirectories (or paths or resources) within the web application
- `Path` attribute should be set as restrictive as possible to the web application path that makes use of the session ID.
- Session management mechanisms based on cookies can make use of two types of cookies, non-persistent (or session) cookies, and persistent cookies. If a cookie presents the `Max-Age` (that has preference over `Expires`) or `Expires` attributes, it will be considered a persistent cookie and will be stored on disk by the web browser based until the expiration time.
- Typically, session management capabilities to track users after authentication make use of non-persistent cookies. This forces the session to disappear from the client if the current web browser instance is closed.
- Therefore, it is highly recommended to use non-persistent cookies for session management purposes, so that the session ID does not remain on the web client cache for long periods of time, from where an attacker can obtain it.
- Depends on what the application does. An RSS reader doesn't need a short lived session.
If we look at reddit cookies we can find one called `token_v2`.
It has:
- Domain: .reddit.com
- Path: /
- Expires/ Max-Age for roughly 3 days
- httpOnly: true
- Secure: true
- SameSite: None
## Local TLS Certs
https://github.com/FiloSottile/mkcert Can be used to create local certs with a trusted CA installed on the machine.
## CSRF
- http://www.gnucitizen.org/blog/csrf-demystified/
- https://stackoverflow.com/questions/6412813/do-login-forms-need-tokens-against-csrf-attacks
### Mitigations
- **SameSite cookies**. In chapter 11 we had `Lax`. This means that the session cookie "won't be sent by the browser for POST, PUT, or DELETE requests. As long as you stick to only using POST, PUT, or DELETE for state changing requests (login, signup, create snippet) `Lax` will prevent CRSF attacks.
- **Token-based mitigation** The [OWASP guidance](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#token-based-mitigation)is to use some form of token check if using TLS 1.2 or lower. Like session and password management you may be best served by using a known and tested third party package. This project uses `justinas/nosurf (`gorilla/csrf` is a valid alternative). Both use the [double submit cookie](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#double-submit-cookie) pattern. In this pattern a random CSRF token is generated and sent to the user in a CSRF cookie. This CSRF token is then added to a hidden field in each HTML form that is potentially vulnerable to CSRF. When the form is submitted, both packages use some middleware to check that the hidden field value and cookie value match.
Due to the fact that no browser exists which supports TLS 1.3 and does not support SameSite cookies. If you only allow HTTPS requests to your application and enforce TLS 1.3 as the minimum TLS version, you dont need to make any additional mitigation against CSRF attacks (like using the justinas/nosurf package). Just make sure that you always:
Set SameSite=Lax or SameSite=Strict on the session cookie; and
Use the POST, PUT or DELETE HTTP methods for any state-changing requests.
- [Primer on Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP)

@ -1,26 +1,17 @@
package main
import (
"context"
"crypto/tls"
"errors"
"flag"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
rdb "git.runcible.io/learning/ratchet/internal/database"
"git.runcible.io/learning/ratchet/internal/logging"
"git.runcible.io/learning/ratchet/internal/model"
"git.runcible.io/learning/ratchet/internal/server"
"github.com/alexedwards/scs/sqlite3store"
"github.com/alexedwards/scs/v2"
_ "github.com/mattn/go-sqlite3"
// "git.runcible.io/learning/ratchet"
// ratchethttp "git.runcible.io/learning/ratchet/internal"
)
// var (
@ -28,148 +19,43 @@ import (
// commit string
// )
func run(ctx context.Context, w io.Writer, args []string) error {
func main() {
// CONFIGURATION
// Parse command line options
flags := flag.NewFlagSet(args[0], flag.ExitOnError)
addr := flags.String("addr", "0.0.0.0", "HTTP network address")
port := flags.String("port", "5001", "HTTP port")
logLevel := flags.String("logging", "INFO", "Logging Level. Valid values [INFO, DEBUG, WARN, ERROR].")
dbPath := flags.String("database", "./ratchet.db", "A path to a sqlite3 database")
certPath := flags.String("cert", "./tls/cert.pem", "A public cert in .pem format")
keyPath := flags.String("key", "./tls/key.pem", "A private key in .pem format")
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
if err := flags.Parse(args[1:]); err != nil {
return err
}
flag.Parse()
// DEPENDENCY INJECTION FOR HANDLERS
// Setup Logging
logger := logging.InitLogging(*logLevel, w, false)
logger := logging.InitLogging(*logLevel, false)
// Setup DB Connection Pool
db, err := rdb.OpenSqlite3DB(*dbPath)
if err != nil {
return err
slog.Error(err.Error())
os.Exit(1)
}
// Close db connection before exiting main.
defer func() {
slog.Info("Cleaning up database")
_, err := db.Exec("PRAGMA wal_checkpoint(TRUNCATE)")
if err != nil {
slog.Error(fmt.Sprintf("Error checkpointing database: %v", err))
}
db.Close()
}()
//tc, err := server.InitTemplateCache()
tc, err := server.InitFSTemplateCache()
if err != nil {
return err
}
defer db.Close()
// SessionManager
sm := scs.New()
sm.Store = sqlite3store.New(db)
// If you want to change the same sight cookie setting from Lax to Strict
// will block the session cookie being sent by the users browser for all cross-site usage
// including GET and HEAD. That means the cookie won't be sent when clicking on a link in another site
// for a GET request, so the user won't be treated as being "logged in" to the app even if they
// did in another tab
// sm.Cookie.SameSite = http.SameSiteStrictMode
app := server.NewRatchetApp(logger, tc, &model.SnippetService{DB: db}, &model.UserService{DB: db}, sm)
tc, err := server.InitTemplateCache()
// these two elliptic curves have assembly implementations
tlsConfig := tls.Config{
CurvePreferences: []tls.CurveID{tls.X25519, tls.CurveP256},
// example of cipher suites usage. These are ignored if TLS 1.3 is used
// CipherSuites: []uint16{
// tls.TLS_AES_128_GCM_SHA256,
// tls.TLS_AES_256_GCM_SHA384,
// tls.TLS_CHACHA20_POLY1305_SHA256,
// tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
// tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
// tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
// tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
// tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256,
// tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256,
// },
// example of explicitly setting TLS versions supported.
// you do this for older browsers or for devices like micros that
// have hardware limitations.
// MinVersion: tls.VersionTLS10,
// MaxVersion: tls.VersionTLS12,
}
// Propagate build information to root package to share globally
// 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)
srv := &http.Server{
Addr: fmt.Sprintf("%s:%s", *addr, *port),
Handler: app.Routes(),
ErrorLog: slog.NewLogLogger(logger.Handler(), slog.LevelError),
TLSConfig: &tlsConfig,
// Server wide timeouts.
// IdleTimeout by default go uses keep-alives on accepted connetions.
// this lowers the default timeout. If you set read timeout but not idle
// then the idle becomes the readtimeout. Be careful.
IdleTimeout: time.Minute,
// If the request headers or body are still being read 5 seconds after the
// request is first accepted, then Go will close the underlying connection
// Care close. User won't recieve any http response
// See also http.Server also provides a ReadHeaderTimeout setting. Would allow
// per route timeouts.
ReadTimeout: 5 * time.Second,
// The WriteTimeout setting will close the underlying connection if our server
// attempts to write to the connection after a given period. When using TLS
// it's sensible to set WriteTimeout above ReadTimeout
// Its important to bear in mind that writes made by a handler are buffered and
// written to the connection as one when the handler returns. Therefore, the
// idea of WriteTimeout is generally not to prevent long-running handlers, but
// to prevent the data that the handler returns from taking too long to write.
WriteTimeout: 10 * time.Second,
// the maximum number of bytes the server will read when parsing request headers.
// default is one mb. Exceeding this returns a 431 Request Header Fields Too Large response
// btw go adds 4096 bytes to the number you provide.
MaxHeaderBytes: 524288, // 0.5 mb
}
slog.Info(fmt.Sprintf("Listening on https://%s", srv.Addr))
go func() {
if err = srv.ListenAndServeTLS(*certPath, *keyPath); !errors.Is(err, http.ErrServerClosed) {
slog.Error(err.Error())
os.Exit(1)
}
slog.Info("Stopped serving connections")
}()
// Handle graceful shutdown
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
// Block until signal is recieved
<-sigChan
shutdownCtx, shutdownRelease := context.WithTimeout(ctx, 10*time.Second)
defer shutdownRelease()
if err := srv.Shutdown(shutdownCtx); err != nil {
slog.Error("Failed to close within context timeout. Forcing server close.")
srv.Close()
return err
}
return nil
}
func main() {
ctx := context.Background()
if err := run(ctx, os.Stdout, os.Args); err != nil {
slog.Error(err.Error())
os.Exit(1)
}
}

@ -1,113 +1 @@
package main
import (
"net/http"
"testing"
"git.runcible.io/learning/ratchet/internal/assert"
)
// func testingLogger() *slog.Logger {
// return slog.New(slog.NewTextHandler(io.Discard, nil))
// }
// WITHOUT testutils_test.go helpers
// func TestPingIntegration(t *testing.T) {
// rs := server.NewRatchetApp(testingLogger(), nil, nil, nil)
// // We then use the httptest.NewTLSServer() function to create a new test
// // server, passing in the value returned by our app.routes() method as the
// // handler for the server. This starts up a HTTPS server which listens on a
// // randomly-chosen port of your local machine for the duration of the test.
// // Notice that we defer a call to ts.Close() so that the server is shutdown
// // when the test finishes.
// ts := httptest.NewTLSServer(rs)
// defer ts.Close()
// resp, err := ts.Client().Get(ts.URL + "/ping")
// if err != nil {
// t.Fatal(err)
// }
// assert.Equal(t, resp.StatusCode, http.StatusOK)
// defer resp.Body.Close()
// body, err := io.ReadAll(resp.Body)
// if err != nil {
// t.Fatal(err)
// }
// body = bytes.TrimSpace(body)
// assert.Equal(t, string(body), "OK")
// }
func TestPingIntegration(t *testing.T) {
// Tests marked using t.Parallel() will be run in parallel with — and only with — other parallel tests.
// By default, the maximum number of tests that will be run simultaneously is the current value of
// GOMAXPROCS. You can override this by setting a specific value via the -parallel flag.
t.Parallel()
app := newTestApplication(t)
ts := newTestServer(t, app.Routes())
defer ts.Close()
code, _, body := ts.get(t, "/ping")
assert.Equal(t, code, http.StatusOK)
assert.Equal(t, body, "OK")
}
func TestSnippetView(t *testing.T) {
t.Parallel()
app := newTestApplication(t)
ts := newTestServer(t, app.Routes())
defer ts.Close()
tests := []struct {
name string
urlPath string
wantCode int
wantBody string
}{
{
name: "Valid ID",
urlPath: "/snippet/view/1",
wantCode: 200,
wantBody: "Hello golang mocking",
},
{
name: "Nonexistent ID",
urlPath: "/snippet/view/2",
wantCode: 404,
},
{
name: "Negative ID",
urlPath: "/snippet/view/-1",
wantCode: 404,
},
{
name: "Decimal ID",
urlPath: "/snippet/view/1.23",
wantCode: 404,
},
{
name: "string ID",
urlPath: "/snippet/view/foo",
wantCode: 404,
},
{
name: "emptry ID",
urlPath: "/snippet/view/",
wantCode: 404,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
code, _, body := ts.get(t, test.urlPath)
assert.Equal(t, code, test.wantCode)
assert.StringContains(t, body, test.wantBody)
})
}
}

@ -1,74 +0,0 @@
package main
import (
"io"
"log/slog"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"testing"
"time"
"git.runcible.io/learning/ratchet/internal/model/mock"
"git.runcible.io/learning/ratchet/internal/server"
"github.com/alexedwards/scs/v2"
)
// Create a newTestApplication helper which returns an instance of our
// application struct containing mocked dependencies.
func newTestApplication(t *testing.T) *server.RatchetApp {
//tc, err := server.InitTemplateCache()
tc, err := server.InitFSTemplateCache()
if err != nil {
t.Fatal(err)
}
sessionManager := scs.New()
sessionManager.Lifetime = 12 * time.Hour
sessionManager.Cookie.Secure = true
rs := server.NewRatchetApp(slog.New(slog.NewTextHandler(io.Discard, nil)), tc, &mock.SnippetService{}, &mock.UserService{}, sessionManager)
return rs
}
// create out own test server with additional receiver functions for ease of testing
type testServer struct {
*httptest.Server
}
func newTestServer(t *testing.T, h http.Handler) *testServer {
ts := httptest.NewTLSServer(h)
jar, err := cookiejar.New(nil)
if err != nil {
t.Fatal(err)
}
// Any response cookies will be stored and sent on subsequent requests
ts.Client().Jar = jar
// Disable redirect-following for test server client by setting custom
// CheckRedirect function. Called whenever 3xx response. By returning a
// http.ErrUseLastResponse error it forces the client to immediately return
// the received response
ts.Client().CheckRedirect = func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
}
return &testServer{ts}
}
func (ts *testServer) get(t *testing.T, urlPath string) (int, http.Header, string) {
resp, err := ts.Client().Get(ts.URL + urlPath)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatal(err)
}
return resp.StatusCode, resp.Header, string(body)
}

@ -2,18 +2,4 @@ module git.runcible.io/learning/ratchet
go 1.23.3
require (
github.com/alexedwards/scs/sqlite3store v0.0.0-20240316134038-7e11d57e8885
github.com/alexedwards/scs/v2 v2.8.0
github.com/go-playground/form/v4 v4.2.1
github.com/golang-migrate/migrate/v4 v4.18.1
github.com/justinas/nosurf v1.1.1
github.com/mattn/go-sqlite3 v1.14.24
golang.org/x/crypto v0.32.0
)
require (
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
go.uber.org/atomic v1.7.0 // indirect
)
require github.com/mattn/go-sqlite3 v1.14.24

@ -1,37 +1,2 @@
github.com/alexedwards/scs/sqlite3store v0.0.0-20240316134038-7e11d57e8885 h1:+DCxWg/ojncqS+TGAuRUoV7OfG/S4doh0pcpAwEcow0=
github.com/alexedwards/scs/sqlite3store v0.0.0-20240316134038-7e11d57e8885/go.mod h1:Iyk7S76cxGaiEX/mSYmTZzYehp4KfyylcLaV3OnToss=
github.com/alexedwards/scs/v2 v2.8.0 h1:h31yUYoycPuL0zt14c0gd+oqxfRwIj6SOjHdKRZxhEw=
github.com/alexedwards/scs/v2 v2.8.0/go.mod h1:ToaROZxyKukJKT/xLcVQAChi5k6+Pn1Gvmdl7h3RRj8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A=
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/form/v4 v4.2.1 h1:HjdRDKO0fftVMU5epjPW2SOREcZ6/wLUzEobqUGJuPw=
github.com/go-playground/form/v4 v4.2.1/go.mod h1:q1a2BY+AQUUzhl6xA/6hBetay6dEIhMHjgvJiGo6K7U=
github.com/golang-migrate/migrate/v4 v4.18.1 h1:JML/k+t4tpHCpQTCAD62Nu43NUFzHY4CV3uAuvHGC+Y=
github.com/golang-migrate/migrate/v4 v4.18.1/go.mod h1:HAX6m3sQgcdO81tdjn5exv20+3Kb13cmGli1hrD6hks=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/justinas/nosurf v1.1.1 h1:92Aw44hjSK4MxJeMSyDa7jwuI9GR2J/JCQiaKvXXSlk=
github.com/justinas/nosurf v1.1.1/go.mod h1:ALpWdSbuNGy2lZWtyXdjkYv4edL23oSEgfBT1gPJ5BQ=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
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=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

@ -1,38 +0,0 @@
package assert
import (
"strings"
"testing"
)
// Equal a generic function to test equivalence between two values
// of the same type
func Equal[T comparable](t *testing.T, actual, expected T) {
//The t.Helper() function that were using in the code above indicates
// to the Go test runner that our Equal() function is a test helper.
// This means that when t.Errorf() is called from our Equal() function,
// the Go test runner will report the filename and line number of the
// code which called our Equal() function in the output.
t.Helper()
if actual != expected {
t.Errorf("got: %v; want %v", actual, expected)
}
}
func StringContains(t *testing.T, actual, expectedSubstring string) {
t.Helper()
if !strings.Contains(actual, expectedSubstring) {
t.Errorf("got: %q; expected to contain %q", actual, expectedSubstring)
}
}
func NilError(t *testing.T, actual error) {
t.Helper()
if actual != nil {
t.Errorf("got: %v; expected: nil", actual)
}
}

@ -1,7 +1,6 @@
package logging
import (
"io"
"log/slog"
"os"
"strings"
@ -23,7 +22,7 @@ func parseLogLevel(levelStr string) slog.Level {
}
// InitLogggin initializes global structured logging for the entire application
func InitLogging(level string, w io.Writer, addSource bool) *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

@ -3,8 +3,4 @@ package model
import "errors"
// TODO migrate this to an apperror
var (
ErrNoRecord = errors.New("models: no record found")
ErrInvalidCredentials = errors.New("models: invalid credentials")
ErrDuplicateEmail = errors.New("models: duplicate email")
)
var ErrNoRecord = errors.New("models: no record found")

@ -1,9 +0,0 @@
INSERT INTO users VALUES(
1337,
'tester',
'tester@example.com',
/* thisisinsecure */
'$2a$12$M51w5lWkveAOhwoanoCxO.hJe3s1m8qJuCzbzdETt0SThjpq4BPRq',
'2025-02-25 18:58:44',
'2025-02-25 18:58:44'
);

@ -1,52 +0,0 @@
package integration
import (
"database/sql"
"os"
"testing"
"git.runcible.io/learning/ratchet/migrations"
)
const testerPasswd = "thisisinsecure"
func newTestDB(t *testing.T) *sql.DB {
dbFile, err := os.CreateTemp("", "ratchet-*.db")
if err != nil {
t.Fatal(err)
}
db, err := sql.Open("sqlite3", dbFile.Name())
if err != nil {
db.Close()
t.Fatal(err)
}
err = migrations.Migrate(db)
if err != nil {
db.Close()
t.Fatal(err)
}
script, err := os.ReadFile("./testdata/seed.sql")
if err != nil {
db.Close()
t.Fatal(err)
}
_, err = db.Exec(string(script))
if err != nil {
db.Close()
t.Fatal(err)
}
t.Cleanup(func() {
db.Close()
err := os.Remove(dbFile.Name())
if err != nil {
t.Fatal(err)
}
})
return db
}

@ -1,51 +0,0 @@
package integration
import (
"testing"
"git.runcible.io/learning/ratchet/internal/assert"
"git.runcible.io/learning/ratchet/internal/model"
)
func TestUserModelExists(t *testing.T) {
// Skip the test if the "-short" flag is provided when running the test.
if testing.Short() {
t.Skip("models: skipping model integration test")
}
tests := []struct {
name string
userID int
want bool
}{{
name: "Valid ID",
userID: 1337,
want: true,
},
{
name: "Zero ID",
userID: 0,
want: false,
},
{
name: "Zero ID",
userID: 2,
want: false,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
db := newTestDB(t)
userService := model.UserService{db}
exists, err := userService.Exists(test.userID)
assert.Equal(t, exists, test.want)
assert.NilError(t, err)
})
}
}

@ -1,35 +0,0 @@
package mock
import (
"database/sql"
"time"
"git.runcible.io/learning/ratchet/internal/model"
)
var mockSnippet = model.Snippet{
ID: 1,
Title: sql.NullString{String: "Hello golang mocking", Valid: true},
Content: sql.NullString{String: "Hello golang mocking", Valid: true},
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
ExpiresAt: time.Now(),
}
type SnippetService struct{}
func (s *SnippetService) Insert(title, content string, expiresAt int) (int, error) {
return 2, nil
}
func (s *SnippetService) Get(id int) (model.Snippet, error) {
if id == mockSnippet.ID {
return mockSnippet, nil
} else {
return model.Snippet{}, model.ErrNoRecord
}
}
func (s *SnippetService) Lastest() ([]model.Snippet, error) {
return []model.Snippet{mockSnippet}, nil
}

@ -1,34 +0,0 @@
package mock
import "git.runcible.io/learning/ratchet/internal/model"
var MockEmail = "drew@fake.com"
var MockPassword = "thisisinsecure"
type UserService struct{}
func (u *UserService) Insert(name, email, password string) (int, error) {
switch email {
case "dupe@example.com":
return 0, model.ErrDuplicateEmail
default:
return 1, nil
}
}
func (u *UserService) Authenticate(email, password string) (int, error) {
if email == MockEmail && password == MockPassword {
return 1, nil
}
return 0, model.ErrInvalidCredentials
}
func (u *UserService) Exists(id int) (bool, error) {
switch id {
case 1:
return true, nil
default:
return false, nil
}
}

@ -19,16 +19,6 @@ type Snippet struct {
ExpiresAt time.Time
}
func (s *Snippet) GetTitle() {
return
}
type SnippetServiceInterface interface {
Insert(title, content string, expiresAt int) (int, error)
Get(id int) (Snippet, error)
Lastest() ([]Snippet, error)
}
type SnippetService struct {
DB *sql.DB
}

@ -1,99 +1,75 @@
package model
import (
"database/sql"
"errors"
"fmt"
"log/slog"
"context"
"time"
"github.com/mattn/go-sqlite3"
"golang.org/x/crypto/bcrypt"
"git.runcible.io/learning/ratchet/internal/apperror"
)
type User struct {
ID int
Name string
Email string
HashedPassword []byte
CreatedAt time.Time
UpdatedAt time.Time
}
ID int `json:"id"`
type UserServiceInterface interface {
Insert(name, email, password string) (int, error)
Authenticate(email, password string) (int, error)
Exists(id int) (bool, error)
}
// User prefered name and email
Name string `json:"name"`
Email string `json:"email"`
// TODD add logger to service
type UserService struct {
DB *sql.DB
}
// Randomly generated API key for use with the API
// "-" omits the key from serialization
APIKey string `json:"-"`
func (u *UserService) Insert(name, email, password string) (int, error) {
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), 12)
if err != nil {
return 0, err
}
// Timestamps for user creatation and last update
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
stmt := `INSERT INTO users (name, email, hashed_password)
VALUES (?,?,?)`
result, err := u.DB.Exec(stmt, name, email, string(hashedPassword))
if err != nil {
slog.Debug(fmt.Sprintf("Error encounters on insert: %s", err.Error()))
// This is a assertion that err is of type sqlite3.Error. If it is ok is true.
if serr, ok := err.(sqlite3.Error); ok {
slog.Debug("Error is sqlite3.Error type.")
if serr.ExtendedCode == sqlite3.ErrConstraintUnique {
slog.Debug("Error is a unique contraint violation.")
return 0, ErrDuplicateEmail
}
}
return 0, err
}
// List of associated Oauth authentication Objects
// Not yet implemented
// Auths []*Auth `json:"auths"`
}
lastId, err := result.LastInsertId()
if err != nil {
slog.Debug("An error occured when retrieving insert result id.")
return 0, err
func (u *User) Validate() error {
if u.Name == "" {
return apperror.Errorf(apperror.EINVALID, "User name required.")
}
slog.Debug(fmt.Sprintf("Inserted new user. User pk: %d", int(lastId)))
return int(lastId), nil
return nil
}
func (u *UserService) Authenticate(email, password string) (int, error) {
var id int
var hashedPassword []byte
stmt := `SELECT id, hashed_password FROM users WHERE email == ?`
err := u.DB.QueryRow(stmt, email).Scan(&id, &hashedPassword)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return 0, ErrInvalidCredentials
} else {
return 0, err
}
}
// UserService represents a service for managing users.
type UserService interface {
// Retrieves a user by ID along with their associated auth objects
// Returns ENOTFOUND if user does not exist.
FindUserByID(ctx context.Context, id int) (*User, error)
err = bcrypt.CompareHashAndPassword(hashedPassword, []byte(password))
if err != nil {
if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) {
return 0, ErrInvalidCredentials
} else {
return 0, err
}
}
// Retrieves a list of users by filter. Also returns total count of matching users
// which may differ from retruned results if filter.Limit is specified.
FindUsers(ctc context.Context, filter UserFilter) ([]*User, int, error)
// Creates a new use. This is only used for testing since users are typically
// cretaed during the OAuth creation process in the AuthService.CreateAuth().
CreateUser(ctx context.Context, user *User) error
return id, nil
// Updates a user object. Returns EUNAUTHORIZED if current user is not
// the user that is being updated. Returns ENOTFOUND if the user does not exist.
UpdateUser(ctx context.Context, id int, upd UserUpdate) (*User, error)
// Permanently deletes a user and all owned application resources. Returns EUNAUTHORIZED
// if current user is not the user being deleted. Returns ENOTFOUND if the user
// does not exist.
DeleteUser(ctx context.Context, id int) error
}
func (u *UserService) Exists(id int) (bool, error) {
var exists bool
// UserFilter respresents a filter passed to FindUsers().
type UserFilter struct {
ID *int `json:"id"`
Email *string `json:"email"`
APIKey *string `json:"apiKey"`
stmt := "SELECT EXISTS(SELECT true FROM users WHERE id = ?)"
// Restrict to subset of results
Offset int `json:"offset"`
Limit int `json:"limit"`
}
err := u.DB.QueryRow(stmt, id).Scan(&exists)
return exists, err
type UserUpdate struct {
Name *string `json:"name"`
Email *string `json:"email"`
}

@ -8,14 +8,14 @@ import (
func TestUserValidation(t *testing.T) {
t.Run("user should return invalid", func(t *testing.T) {
u := &Userwtf{}
u := &User{}
if apperror.ErrorCode(u.Validate()) != apperror.EINVALID {
t.Errorf("User validation should have failed but passed instead.")
}
})
t.Run("user validation should pass", func(t *testing.T) {
u := &Userwtf{Name: "Drew"}
u := &User{Name: "Drew"}
if u.Validate() != nil {
t.Errorf("User validation failed")
}

@ -1,75 +0,0 @@
package model
import (
"context"
"time"
"git.runcible.io/learning/ratchet/internal/apperror"
)
type Userwtf struct {
ID int `json:"id"`
// User prefered name and email
Name string `json:"name"`
Email string `json:"email"`
// Randomly generated API key for use with the API
// "-" omits the key from serialization
APIKey string `json:"-"`
// Timestamps for user creatation and last update
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
// List of associated Oauth authentication Objects
// Not yet implemented
// Auths []*Auth `json:"auths"`
}
func (u *Userwtf) Validate() error {
if u.Name == "" {
return apperror.Errorf(apperror.EINVALID, "User name required.")
}
return nil
}
// UserService represents a service for managing users.
type UserServicewtf interface {
// Retrieves a user by ID along with their associated auth objects
// Returns ENOTFOUND if user does not exist.
FindUserByID(ctx context.Context, id int) (*Userwtf, error)
// Retrieves a list of users by filter. Also returns total count of matching users
// which may differ from retruned results if filter.Limit is specified.
FindUsers(ctc context.Context, filter UserFilter) ([]*Userwtf, int, error)
// Creates a new use. This is only used for testing since users are typically
// cretaed during the OAuth creation process in the AuthService.CreateAuth().
CreateUser(ctx context.Context, user *Userwtf) error
// Updates a user object. Returns EUNAUTHORIZED if current user is not
// the user that is being updated. Returns ENOTFOUND if the user does not exist.
UpdateUser(ctx context.Context, id int, upd UserUpdate) (*Userwtf, error)
// Permanently deletes a user and all owned application resources. Returns EUNAUTHORIZED
// if current user is not the user being deleted. Returns ENOTFOUND if the user
// does not exist.
DeleteUser(ctx context.Context, id int) error
}
// UserFilter respresents a filter passed to FindUsers().
type UserFilter struct {
ID *int `json:"id"`
Email *string `json:"email"`
APIKey *string `json:"apiKey"`
// Restrict to subset of results
Offset int `json:"offset"`
Limit int `json:"limit"`
}
type UserUpdate struct {
Name *string `json:"name"`
Email *string `json:"email"`
}

@ -1,5 +0,0 @@
package server
type contextKey string
const isAuthenticatedKey = contextKey("isAuthenticated")

@ -1,66 +0,0 @@
// test
package server
import (
"errors"
"net/http"
"git.runcible.io/learning/ratchet/internal/validator"
"github.com/go-playground/form/v4"
)
// Define a snippetCreateForm struct to represent the form data and validation
// errors for the form fields. Note that all the struct fields are deliberately
// exported (i.e. start with a capital letter). This is because struct fields
// must be exported in order to be read by the html/template package when
// rendering the template.
//
// Remove the explicit FieldErrors struct field and instead embed the Validator
// struct. Embedding this means that our snippetCreateForm "inherits" all the
// fields and methods of our Validator struct (including the FieldErrors field).
//
// MOVING TO go-playground/form
// Update our snippetCreateForm struct to include struct tags which tell the
// decoder how to map HTML form values into the different struct fields. So, for
// example, here we're telling the decoder to store the value from the HTML form
// input with the name "title" in the Title field. The struct tag `form:"-"`
// tells the decoder to completely ignore a field during decoding.
type snippetCreateForm struct {
Title string `form:"title"`
Content string `form:"content"`
Expires int `form:"expires"`
validator.Validator `form:"-"`
}
type userSignupForm struct {
Name string `form:"name"`
Email string `form:"email"`
Password string `form:"password"`
validator.Validator `form:"-"`
}
type userLoginForm struct {
Email string `form:"email"`
Password string `form:"password"`
validator.Validator `form:"-"`
}
func decodePostForm(r *http.Request, fd *form.Decoder, dst any) error {
err := r.ParseForm()
if err != nil {
return err
}
err = fd.Decode(dst, r.PostForm)
if err != nil {
var invalidDecoderError *form.InvalidDecoderError
if errors.As(err, &invalidDecoderError) {
// if called in the handler, recovery middleware
// will log and send 500 response back
panic(err)
}
return err
}
return nil
}

@ -1,48 +0,0 @@
package server
import (
"bytes"
"io"
"net/http"
"net/http/httptest"
"testing"
"git.runcible.io/learning/ratchet/internal/assert"
)
func TestPing(t *testing.T) {
// This is essentially an implementation of http.ResponseWriter
// which records the response status code, headers and body instead
// of actually writing them to a HTTP connection.
rr := httptest.NewRecorder()
// Initialize a dummy request
r, err := http.NewRequest(http.MethodGet, "/", nil)
if err != nil {
// When called, t.Fatal() will mark the test as failed, log the error,
// and then completely stop execution of the current test
// (or sub-test).
// Typically you should call t.Fatal() in situations where it doesnt
// make sense to continue the current test — such as an error during
// a setup step, or where an unexpected error from a Go standard
// library function means you cant proceed with the test.
t.Fatal(err)
}
ping := PingHandler()
ping.ServeHTTP(rr, r)
resp := rr.Result()
assert.Equal(t, resp.StatusCode, http.StatusOK)
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatal(err)
}
body = bytes.TrimSpace(body)
assert.Equal(t, string(body), "OK")
}

@ -1,4 +1,3 @@
// test
package server
import (
@ -9,13 +8,10 @@ import (
"strconv"
"git.runcible.io/learning/ratchet/internal/model"
"git.runcible.io/learning/ratchet/internal/validator"
"github.com/alexedwards/scs/v2"
"github.com/go-playground/form/v4"
)
// TODO function should accept and a pointer to an interface allowing for mocking in tests.
func handleHome(logger *slog.Logger, tc *TemplateCache, sm *scs.SessionManager, snippetService model.SnippetServiceInterface) http.Handler {
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
@ -31,7 +27,7 @@ func handleHome(logger *slog.Logger, tc *TemplateCache, sm *scs.SessionManager,
// data := templateData{
// Snippets: snippets,
// }
data := newTemplateData(r, sm)
data := newTemplateData()
data.Snippets = snippets
renderTemplate(w, r, tc, http.StatusOK, "home.go.tmpl", data)
@ -63,7 +59,7 @@ func handleHome(logger *slog.Logger, tc *TemplateCache, sm *scs.SessionManager,
})
}
func handleSnippetView(logger *slog.Logger, tc *TemplateCache, sm *scs.SessionManager, snippetService model.SnippetServiceInterface) 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"))
@ -86,15 +82,6 @@ func handleSnippetView(logger *slog.Logger, tc *TemplateCache, sm *scs.SessionMa
return
}
// Use the PopString() method to retrieve the value for the "flash" key.
// PopString() also deletes the key and value from the session data, so it
// acts like a one-time fetch. If there is no matching key in the session
// data this will return the empty string.
// See also GetInt, GetBool, GetBytes, GetTime etc.
// NOW DONE IN TEMPLATE DATA FUNC
// flash := sm.PopString(r.Context(), "flash")
// files := []string{
// "./ui/html/base.go.tmpl",
// "./ui/html/partials/nav.go.tmpl",
@ -127,326 +114,39 @@ func handleSnippetView(logger *slog.Logger, tc *TemplateCache, sm *scs.SessionMa
// data := templateData{
// Snippet: snippet,
// }
data := newTemplateData(r, sm)
data := newTemplateData()
data.Snippet = snippet
// data.Flash = flash
renderTemplate(w, r, tc, http.StatusOK, "view.go.tmpl", data)
})
}
func handleSnippetCreateGet(tc *TemplateCache, sm *scs.SessionManager) http.Handler {
func handleSnippetCreateGet() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
data := newTemplateData(r, sm)
// Initialize a new snippetCreateForm instance and pass it to the template.
// Notice how this is also a great opportunity to set any default or
// 'initial' values for the form --- here we set the initial value for the
// snippet expiry to 365 days.
data.Form = snippetCreateForm{
Expires: 365,
}
renderTemplate(w, r, tc, http.StatusOK, "create.go.tmpl", data)
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, formDecoder *form.Decoder, sm *scs.SessionManager, snippetService model.SnippetServiceInterface) 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
// 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
// Limit the request body size to 4096 bytes
// by default it's 10mb unless enctype="multipart/form-data in which case
// thier is no limit. You would do this however to limit the number of bytes
// that r.ParseForm will process. In this case it is only the first 4096 bytes
// reading beyond that will cause the MaxBytesReader to throw and error surfaced
// in your call to r.ParseForm()
// Also if the limit is reached the reader flags the server which will terminate
// the under lying TCP connection.
r.Body = http.MaxBytesReader(w, r.Body, 4096)
// ParseForm is idempotent. Can be called many times.
// First we call r.ParseForm() which adds any data in POST request bodies
// to the r.PostForm map. This also works in the same way for PUT and PATCH
// requests. If there are any errors, we use our app.ClientError() helper to
// send a 400 Bad Request response to the user.
// If you have a form that submits data using the HTTP method GET, rather than POST,
// the form data will be included as URL query string parameters /foo/bar?title=value&content=value.
// can be retrieved using r.URL.Query().Get() which will return the value or an empty string.
// r.Form could be used, but it is clearer and more explicit to stick with r.URL.Query and r.PostForm
// to access submitted data.
err := r.ParseForm()
if err != nil {
clientError(w, http.StatusBadRequest)
return
}
// initializes the struct with zero values. So &form is non-nil
var form snippetCreateForm
// AUTOMATIC FORM PROCESSING
// When we call app.formDecoder.Decode() it requires a non-nil pointer as the target
// decode destination. If we try to pass in something that isnt a non-nil pointer, then
// Decode() will return a form.InvalidDecoderError error. That error should be handled
// differently form a 400 error since it's a server side problem (ie 5xx error)
err = decodePostForm(r, formDecoder, &form)
if err != nil {
clientError(w, http.StatusBadRequest)
return
}
// OLD WAY
// The r.PostForm.Get() method always returns the form data as a *string*.
// However, we're expecting our expires value to be a number, and want to
// represent it in our Go code as an integer. So we need to manually convert
// the form data to an integer using strconv.Atoi(), and we send a 400 Bad
// Request response if the conversion fails.
// expires, err := strconv.Atoi(r.PostForm.Get("expires"))
// if err != nil {
// clientError(w, http.StatusBadRequest)
// return
// }
// Create an instance of the snippetCreateForm struct containing the values
// from the form and an empty map for any validation errors.
// form := snippetCreateForm{
// Title: r.PostForm.Get("title"),
// Content: r.PostForm.Get("content"),
// Expires: expires,
// }
// VALIDATION
// THE OLD WAY
// if strings.TrimSpace(form.Title) == "" {
// form.FieldErrors["title"] = "field cannot be blank"
// // we want to count the number of unicode code points not bytes in the string
// } else if utf8.RuneCountInString(form.Title) > 100 {
// form.FieldErrors["title"] = "This field cannot exceed 100 characters"
// }
// if strings.TrimSpace(form.Content) == "" {
// form.FieldErrors["content"] = "field cannot be blank"
// }
// if expires != 1 && expires != 7 && expires != 365 {
// form.FieldErrors["expires"] = "This field must equal 1,7, or 365"
// }
// If there are any validation errors, then re-display the create.tmpl template,
// passing in the snippetCreateForm instance as dynamic data in the Form
// field. Note that we use the HTTP status code 422 Unprocessable Entity
// when sending the response to indicate that there was a validation error.
// if len(form.FieldErrors) > 0 {
// data := newTemplateData()
// data.Form = form
// renderTemplate(w, r, tc, http.StatusUnprocessableEntity, "create.go.tmpl", data)
// return
// }
// New VALIDATION
form.CheckField(validator.NotBlank(form.Title), "title", "this field cannot be blank")
form.CheckField(validator.MaxChars(form.Title, 100), "title", "this field cannot exceed 100 characters")
form.CheckField(validator.NotBlank(form.Content), "content", "this field cannot be blank")
form.CheckField(validator.PermittedValue(form.Expires, 1, 7, 365), "expires", "this field cannot be blank")
if !form.Valid() {
data := newTemplateData(r, sm)
data.Form = form
renderTemplate(w, r, tc, http.StatusUnprocessableEntity, "create.go.tmpl", data)
return
}
// NOTE
//
// In the case of a form field with multiple values youll need to work with the
// r.PostForm map directly. The underlying type of the r.PostForm map is url.Values,
// which in turn has the underlying type map[string][]string. So, for fields with
// multiple values you can loop over the underlying map to access them like so:
// for i, item := range r.PostForm["items"] {
// fmt.Fprintf(w, "%d: Item %s\n", i, item)
// }
id, err := snippetService.Insert(form.Title, form.Content, form.Expires)
id, err := snippetService.Insert(title, content, expires)
if err != nil {
serverError(w, r, err)
}
logger.Info(fmt.Sprintf("Inserted record. id: %d", id))
// Use the Put() method to add a string value ("Snippet successfully
// created!") and the corresponding key ("flash") to the session data.
sm.Put(r.Context(), "flash", "Snippet successfully created!")
http.Redirect(w, r, fmt.Sprintf("/snippet/view/%d", id), http.StatusSeeOther)
})
}
func handleUserSignupGet(tc *TemplateCache, sm *scs.SessionManager) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
data := newTemplateData(r, sm)
data.Form = userSignupForm{}
renderTemplate(w, r, tc, http.StatusOK, "signup.go.tmpl", data)
})
}
func handleUserSignupPost(logger *slog.Logger, tc *TemplateCache, fd *form.Decoder, sm *scs.SessionManager, userService model.UserServiceInterface) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Check that the provided name, email address and password are not blank.
// Sanity check the format of the email address.
// Ensure that the password is at least 8 characters long.
// Make sure that the email address isnt already in use.
err := r.ParseForm()
if err != nil {
logger.Error("Failed to parse signup form")
clientError(w, http.StatusBadRequest)
return
}
form := userSignupForm{}
err = decodePostForm(r, fd, &form)
if err != nil {
logger.Error("Failed to decode signup form")
clientError(w, http.StatusBadRequest)
return
}
form.CheckField(validator.NotBlank(form.Name), "name", "this field cannot be blank")
form.CheckField(validator.NotBlank(form.Email), "email", "this field cannot be blank")
form.CheckField(validator.Matches(form.Email, validator.EmailRX), "email", "this field must be a valid email address")
form.CheckField(validator.NotBlank(form.Password), "password", "this field cannot be blank")
form.CheckField(validator.MinChars(form.Password, 8), "password", "this field must be at least 8 characters long")
// Todo Email allready in use validation
if !form.Valid() {
data := newTemplateData(r, sm)
data.Form = form
renderTemplate(w, r, tc, http.StatusUnprocessableEntity, "signup.go.tmpl", data)
}
_, err = userService.Insert(form.Name, form.Email, form.Password)
if err != nil {
if errors.Is(err, model.ErrDuplicateEmail) {
form.AddFieldError("email", "Email is already in use")
data := newTemplateData(r, sm)
data.Form = form
renderTemplate(w, r, tc, http.StatusUnprocessableEntity, "signup.go.tmpl", data)
} else {
serverError(w, r, err)
}
return
}
sm.Put(r.Context(), "flash", "Your signup was successful. Please log in.")
http.Redirect(w, r, "/user/login", http.StatusSeeOther)
})
}
func handleUserLoginGet(tc *TemplateCache, sm *scs.SessionManager) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
data := newTemplateData(r, sm)
data.Form = userLoginForm{}
renderTemplate(w, r, tc, http.StatusOK, "login.go.tmpl", data)
})
}
func handleUserLoginPost(logger *slog.Logger, tc *TemplateCache, sm *scs.SessionManager, fd *form.Decoder, userService model.UserServiceInterface) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// parse form
err := r.ParseForm()
if err != nil {
logger.Error("Failed to parse login form")
clientError(w, http.StatusBadRequest)
return
}
form := userLoginForm{}
err = decodePostForm(r, fd, &form)
if err != nil {
logger.Error("Failed to decode login form")
clientError(w, http.StatusBadRequest)
return
}
form.CheckField(validator.NotBlank(form.Email), "email", "This field cannot be blank")
form.CheckField(validator.Matches(form.Email, validator.EmailRX), "email", "This field must be a valid email")
form.CheckField(validator.NotBlank(form.Password), "password", "This field cannot be blank")
if !form.Valid() {
logger.Info("An invalid form was submitted")
data := newTemplateData(r, sm)
data.Form = form
renderTemplate(w, r, tc, http.StatusUnprocessableEntity, "login.go.tmpl", data)
return
}
id, err := userService.Authenticate(form.Email, form.Password)
if err != nil {
if errors.Is(err, model.ErrInvalidCredentials) {
logAuthFailure(logger, r, form.Email)
form.AddNonFieldError("Email or password is incorrect")
data := newTemplateData(r, sm)
data.Form = form
renderTemplate(w, r, tc, http.StatusUnprocessableEntity, "login.go.tmpl", data)
} else {
serverError(w, r, err)
}
return
}
// Use the RenewToken() method on the current session to change the session ID.
// It's good practice to generate a new session ID when the authentication state
// or privilege levels change for the user (e.g. login and logout operations)
// This changes the ID of the current users session but retain any data
// associated with the session
// see https://github.com/OWASP/CheatSheetSeries/blob/master/cheatsheets/Session_Management_Cheat_Sheet.md#renew-the-session-id-after-any-privilege-level-change
err = sm.RenewToken(r.Context())
if err != nil {
serverError(w, r, err)
return
}
// Add the ID of the current user to the session, so that they are now "logged in"
sm.Put(r.Context(), "authenticatedUserID", id)
logAuthSuccess(logger, r, form.Email, id)
http.Redirect(w, r, "/snippet/create", http.StatusSeeOther)
})
}
func handleUserLogoutPost(logger *slog.Logger, sm *scs.SessionManager) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Use RenewToken on the current session to change the session ID
err := sm.RenewToken(r.Context())
if err != nil {
serverError(w, r, err)
}
userId := sm.GetString(r.Context(), "authenticatedUserID")
if userId == "" {
logger.Info("No athenticated user in session")
} else {
logger.Info(fmt.Sprintf("Logging out user: %s", userId))
}
// Remove the authenticatedUserID from the session data
sm.Remove(r.Context(), "authenticatedUserID")
// Add a flash message
sm.Put(r.Context(), "flash", "You've been logged out successfully!")
http.Redirect(w, r, "/", http.StatusSeeOther)
})
}
func PingHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("OK"))
})
}

@ -4,8 +4,6 @@ import (
"log/slog"
"net/http"
"runtime/debug"
"strconv"
"strings"
)
// serverError helper writes a log entry at Error level (including the request
@ -31,44 +29,3 @@ func serverError(w http.ResponseWriter, r *http.Request, err error) {
func clientError(w http.ResponseWriter, status int) {
http.Error(w, http.StatusText(status), status)
}
func getClientIP(r *http.Request) string {
// Check X-Forwarded-For header first (for proxied requests)
forwardedFor := r.Header.Get("X-Forwarded-For")
if forwardedFor != "" {
// Take the first IP in case of multiple proxies
return strings.Split(forwardedFor, ",")[0]
}
// Fall back to RemoteAddr
return strings.Split(r.RemoteAddr, ":")[0]
}
// TODO we probably want to distinguish between invalid email and in valid password
func logAuthFailure(logger *slog.Logger, r *http.Request, email string) {
logger.Info("authentication attempt failed",
slog.String("event_type", "authentication_failure"),
slog.String("username", email),
slog.String("ip_address", getClientIP(r)),
slog.String("user_agent", r.Header.Get("User-Agent")))
}
func logAuthSuccess(logger *slog.Logger, r *http.Request, email string, userId int) {
logger.Info("successful login",
slog.String("event_type", "authentication_success"),
slog.String("username", email),
slog.String("user_id", strconv.Itoa(userId)),
slog.String("ip_address", getClientIP(r)),
slog.String("user_agent", r.Header.Get("User-Agent")))
}
// is Authenticated returns true if an authenticated user ID has been set in the session
func isAuthenticated(r *http.Request) bool {
// return sm.Exists(r.Context(), "authenticatedUserID")
isAuthenticated, ok := r.Context().Value(isAuthenticatedKey).(bool)
if !ok {
return false
}
return isAuthenticated
}

@ -1,14 +1,9 @@
package server
import (
"context"
"fmt"
"log/slog"
"net/http"
"git.runcible.io/learning/ratchet/internal/model"
"github.com/alexedwards/scs/v2"
"github.com/justinas/nosurf"
)
// https://owasp.org/www-project-secure-headers/ guidance
@ -106,74 +101,3 @@ func RecoveryMiddleware(next http.Handler) http.Handler {
next.ServeHTTP(w, r)
})
}
func RequireAuthenticationMiddleware(next http.Handler, sm *scs.SessionManager) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// If the user is not authenticated, redirect them to the login page and
// return from the middleware chain so that no subsequent handlers in
// the chain are executed.
if !isAuthenticated(r) {
http.Redirect(w, r, "/user/login", http.StatusSeeOther)
return
}
// Otherwise set the "Cache-Control: no-store" header so that pages
// require authentication are not stored in the users browser cache (or
// other intermediary cache).
w.Header().Add("Cache-Control", "no-store")
next.ServeHTTP(w, r)
})
}
// NoSurfMiddleware uses the noSurf package to create a customized CSRF cookie
// with the Secure, Path and HttpOnly attributes set
func NoSurfMiddleware(next http.Handler) http.Handler {
csrfHandler := nosurf.New(next)
csrfHandler.SetBaseCookie(http.Cookie{
HttpOnly: true,
Path: "/",
Secure: true,
})
return csrfHandler
}
func AuthenticateMiddleware(next http.Handler, sm *scs.SessionManager, userService model.UserServiceInterface) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := sm.GetInt(r.Context(), "authenticatedUserID")
if id == 0 {
// no authenticated user
next.ServeHTTP(w, r)
return
}
exists, err := userService.Exists(id)
if err != nil {
serverError(w, r, err)
return
}
// If a matching user is found, we know that the request is
// coming from an authenticated user who exists in our database. We
// create a new copy of the request (with an isAuthenticatedContextKey
// value of true in the request context) and assign it to r.
if exists {
ctx := context.WithValue(r.Context(), isAuthenticatedKey, true)
r = r.WithContext(ctx)
}
next.ServeHTTP(w, r)
})
}
// CacheHeaders is a middleware that provides cache-control headers to be used
// for static assests.
func CacheHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// TODO parameterize cache control via config
w.Header().Set("Cache-Control", "private, max-age=21600")
next.ServeHTTP(w, r)
})
}

@ -1,77 +0,0 @@
package server
import (
"bytes"
"io"
"net/http"
"net/http/httptest"
"testing"
"git.runcible.io/learning/ratchet/internal/assert"
)
func TestCommonHeadersMiddleware(t *testing.T) {
rr := httptest.NewRecorder()
r, err := http.NewRequest(http.MethodGet, "/", nil)
if err != nil {
t.Fatal(err)
}
// mock http.Handler
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("OK"))
})
// Pass the mock HTTP handler to our commonHeaders middleware. Because
// commonHeaders *returns* a http.Handler we can call its ServeHTTP()
// method, passing in the http.ResponseRecorder and dummy http.Request to
// execute it.
CommonHeaderMiddleware(next).ServeHTTP(rr, r)
resp := rr.Result()
// Check that the middleware has correctly set the Content-Security-Policy
// header on the response.
expectedValue := "default-src 'self'; style-src 'self' fonts.googleapis.com; font-src fonts.gstatic.com"
assert.Equal(t, resp.Header.Get("Content-Security-Policy"), expectedValue)
// Check that the middleware has correctly set the Referrer-Policy
// header on the response.
expectedValue = "origin-when-cross-origin"
assert.Equal(t, resp.Header.Get("Referrer-Policy"), expectedValue)
// Check that the middleware has correctly set the X-Content-Type-Options
// header on the response.
expectedValue = "nosniff"
assert.Equal(t, resp.Header.Get("X-Content-Type-Options"), expectedValue)
// Check that the middleware has correctly set the X-Frame-Options header
// on the response.
expectedValue = "deny"
assert.Equal(t, resp.Header.Get("X-Frame-Options"), expectedValue)
// Check that the middleware has correctly set the X-XSS-Protection header
// on the response
expectedValue = "0"
assert.Equal(t, resp.Header.Get("X-XSS-Protection"), expectedValue)
// Check that the middleware has correctly set the Server header on the
// response.
expectedValue = "Go"
assert.Equal(t, resp.Header.Get("Server"), expectedValue)
// Check that the middleware has correctly called the next handler in line
// and the response status code and body are as expected.
assert.Equal(t, resp.StatusCode, http.StatusOK)
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatal(err)
}
body = bytes.TrimSpace(body)
assert.Equal(t, string(body), "OK")
}

@ -1,55 +1,29 @@
package server
import (
"database/sql"
"log/slog"
"net/http"
"git.runcible.io/learning/ratchet/ui"
"git.runcible.io/learning/ratchet/internal/model"
)
func addBaseMiddleware(app *RatchetApp, next http.Handler, requireAuth bool) http.Handler {
var h http.Handler
h = next
if requireAuth {
h = RequireAuthenticationMiddleware(h, app.sessionManager)
}
h = AuthenticateMiddleware(h, app.sessionManager, app.userService)
h = NoSurfMiddleware(h)
h = app.sessionManager.LoadAndSave(h)
return h
}
func (a *RatchetApp) Routes() http.Handler {
// TODO implement middleware that disables directory listings
// This line was superceded by using the embedded filesystem
// fileServer := http.FileServer(http.Dir("./ui/static/"))
router := http.NewServeMux()
// Subtree pattern for static assets
// This line was superceded by using the embedded filesystem
// router.Handle("GET /static/", http.StripPrefix("/static/", fileServer))
router.Handle("GET /static/", CacheHeaders(http.FileServerFS(ui.Files)))
router.Handle("GET /ping", PingHandler())
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
router.Handle("GET /{$}", addBaseMiddleware(a, handleHome(a.logger, a.templateCache, a.sessionManager, a.snippetService), false)) // might be time to swith to github.com/justinas/alice dynamic chain
router.Handle("GET /snippet/view/{id}", addBaseMiddleware(a, handleSnippetView(a.logger, a.templateCache, a.sessionManager, a.snippetService), false))
router.Handle("GET /snippet/create", addBaseMiddleware(a, handleSnippetCreateGet(a.templateCache, a.sessionManager), true))
router.Handle("POST /snippet/create", addBaseMiddleware(a, handleSnippetCreatePost(a.logger, a.templateCache, a.formDecoder, a.sessionManager, a.snippetService), true))
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())
router.Handle("GET /user/signup", addBaseMiddleware(a, handleUserSignupGet(a.templateCache, a.sessionManager), false))
router.Handle("POST /user/signup", addBaseMiddleware(a, handleUserSignupPost(a.logger, a.templateCache, a.formDecoder, a.sessionManager, a.userService), false))
router.Handle("GET /user/login", addBaseMiddleware(a, handleUserLoginGet(a.templateCache, a.sessionManager), false))
router.Handle("POST /user/login", addBaseMiddleware(a, handleUserLoginPost(a.logger, a.templateCache, a.sessionManager, a.formDecoder, a.userService), false))
// Requires auth
router.Handle("POST /user/logout", addBaseMiddleware(a, handleUserLogoutPost(a.logger, a.sessionManager), true))
return RecoveryMiddleware(RequestLoggingMiddleware(CommonHeaderMiddleware(router), a.logger))
return mux
}

@ -1,32 +1,41 @@
package server
import (
"database/sql"
"log/slog"
"net/http"
"git.runcible.io/learning/ratchet/internal/model"
"github.com/alexedwards/scs/v2"
"github.com/go-playground/form/v4"
)
type RatchetApp struct {
type RatchetServer struct {
http.Handler
logger *slog.Logger
templateCache *TemplateCache
//Services used by HTTP routes
snippetService model.SnippetServiceInterface
userService model.UserServiceInterface
formDecoder *form.Decoder
sessionManager *scs.SessionManager
snippetService *model.SnippetService
UserService model.UserService
}
// TODO this function presents some challenges because it both instantiates new data objects
// and configures route / middleware setup
func NewRatchetApp(logger *slog.Logger, tc *TemplateCache, snippetService model.SnippetServiceInterface, userService model.UserServiceInterface, sm *scs.SessionManager) *RatchetApp {
rs := new(RatchetApp)
func NewRatchetServer(logger *slog.Logger, tc *TemplateCache, db *sql.DB) *RatchetServer {
rs := new(RatchetServer)
rs.logger = logger
rs.snippetService = snippetService
rs.userService = userService
rs.formDecoder = form.NewDecoder()
rs.snippetService = &model.SnippetService{DB: db}
rs.templateCache = tc
rs.sessionManager = sm
// 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
}

@ -5,15 +5,11 @@ import (
"database/sql"
"fmt"
"html/template"
"io/fs"
"net/http"
"path/filepath"
"time"
"git.runcible.io/learning/ratchet/internal/model"
"git.runcible.io/learning/ratchet/ui"
"github.com/alexedwards/scs/v2"
"github.com/justinas/nosurf"
)
// Define a templateData type to act as the holding structure for
@ -21,23 +17,14 @@ import (
// 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
Form any
Flash string
IsAuthenticated bool
CSRFToken string
CurrentYear int
Snippet model.Snippet
Snippets []model.Snippet
}
// newTemplateData is useful to inject default values. Example CSRF tokens for forms.
func newTemplateData(r *http.Request, sm *scs.SessionManager) templateData {
return templateData{CurrentYear: time.Now().Year(),
Flash: sm.PopString(r.Context(), "flash"),
IsAuthenticated: isAuthenticated(r),
// added to every page because the form for logout can appear on every page
CSRFToken: nosurf.Token(r),
}
func newTemplateData() templateData {
return templateData{CurrentYear: time.Now().Year()}
}
// TEMPLATE FUNCTIONS
@ -48,13 +35,7 @@ func newTemplateData(r *http.Request, sm *scs.SessionManager) templateData {
// Create a humanDate function which returns a nicely formatted string
// representation of a time.Time object.
func humanDate(t time.Time) string {
if t.IsZero() {
return ""
}
// return t.Format("02 Jan 2006 at 15:04")
// always return in UTC
return t.UTC().Format("02 Jan 2006 at 15:04")
return t.Format("02 Jan 2006 at 15:04")
}
// TEMPLATE FILTERS
@ -131,37 +112,6 @@ func InitTemplateCache() (*TemplateCache, error) {
return &cache, nil
}
func InitFSTemplateCache() (*TemplateCache, error) {
cache := TemplateCache{}
pages, err := fs.Glob(ui.Files, "html/pages/*.tmpl")
if err != nil {
return nil, err
}
for _, page := range pages {
name := filepath.Base(page)
// Create a slice container the filepath patterns for the templates we
// want to parse
patterns := []string{
"html/base.go.tmpl",
"html/partials/*.tmpl",
page,
}
tmpl, err := template.New(name).Funcs(templateFuncMap).ParseFS(ui.Files, patterns...)
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]
@ -180,6 +130,7 @@ func renderTemplate(w http.ResponseWriter, r *http.Request, tc *TemplateCache, s
serverError(w, r, err)
return
}
w.WriteHeader(status)
buf.WriteTo(w)

@ -1,49 +0,0 @@
package server
import (
"testing"
"time"
"git.runcible.io/learning/ratchet/internal/assert"
)
func TestHumanDate(t *testing.T) {
// Table driven test
tests := []struct {
name string
tm time.Time
want string
}{
{
name: "UTC",
tm: time.Date(2077, time.April, 12, 23, 0, 0, 0, time.UTC),
want: "12 Apr 2077 at 23:00",
},
{
name: "UTC",
tm: time.Date(2025, time.March, 3, 2, 31, 0, 0, time.UTC),
want: "03 Mar 2025 at 02:31",
},
{
name: "Empty",
tm: time.Time{},
want: "",
},
// CET is one hour ahead of UTC but we print in UTC
{
name: "CET",
tm: time.Date(2024, 3, 17, 10, 15, 0, 0, time.FixedZone("CET", 1*60*60)),
want: "17 Mar 2024 at 09:15",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got := humanDate(test.tm)
assert.Equal(t, got, test.want)
})
}
}

@ -1,86 +0,0 @@
// See also https://www.alexedwards.net/blog/validation-snippets-for-go
// for more validation snippets
//
// A NonFieldError for example would be "Your email or password is incorrect".
// more secure because it does not leak which field was in error. Used in the login
// form
package validator
import (
"regexp"
"slices"
"strings"
"unicode/utf8"
)
type Validator struct {
NonFieldErrors []string
FieldErrors map[string]string
}
// Valid() returns true if the FieldErrors map doesn't contain any entries.
func (v *Validator) Valid() bool {
return len(v.FieldErrors) == 0 && len(v.NonFieldErrors) == 0
}
func (v *Validator) AddNonFieldError(message string) {
v.NonFieldErrors = append(v.NonFieldErrors, message)
}
// AddFieldError() adds an error message to the FieldErrors map (so long as no
// entry already exists for the given key).
func (v *Validator) AddFieldError(key, message string) {
if v.FieldErrors == nil {
v.FieldErrors = make(map[string]string)
}
if _, exists := v.FieldErrors[key]; !exists {
v.FieldErrors[key] = message
}
}
// CheckField() adds an error message to the FieldErrors map only if a
// validation check is not 'ok'.
func (v *Validator) CheckField(ok bool, key, message string) {
if !ok {
v.AddFieldError(key, message)
}
}
// NotBlank() returns true if a value is not an empty string.
func NotBlank(value string) bool {
return strings.TrimSpace(value) != ""
}
// MinChars() returns true if the value contains equal to or greater than n characters
func MinChars(value string, n int) bool {
return utf8.RuneCountInString(value) >= n
}
// MaxChars() returns true if a value contains no more than n characters.
func MaxChars(value string, n int) bool {
return utf8.RuneCountInString(value) <= n
}
// PermittedValue() returns true if a value is in a list of specific permitted
// values.
func PermittedValue[T comparable](value T, permittedValues ...T) bool {
return slices.Contains(permittedValues, value)
}
// Use the regexp.MustCompile() function to parse a regular expression pattern
// for sanity checking the format of an email address. This returns a pointer to
// a 'compiled' regexp.Regexp type, or panics in the event of an error. Parsing
// this pattern once at startup and storing the compiled *regexp.Regexp in a
// variable is more performant than re-parsing the pattern each time we need it.
// This pattern is recommended by the W3C and Web Hypertext Application Technology Working Group for validating email addresses
// https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address
// Because the EmailRX regexp pattern is written as an interpreted string literal, we need to double-escape special characters in the regexp with \\ for it to work correctly
var EmailRX = regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$")
// Matches() returns true if a value matches a provided compiled regular
// expression pattern.
func Matches(value string, rx *regexp.Regexp) bool {
return rx.MatchString(value)
}

@ -1 +0,0 @@
DROP TABLE IF EXISTS sessions;

@ -1,7 +0,0 @@
CREATE TABLE sessions (
token TEXT PRIMARY KEY,
data BLOB NOT NULL,
expiry REAL NOT NULL
);
CREATE INDEX sessions_expiry_idx ON sessions(expiry);

@ -1 +0,0 @@
DROP TABLE IF EXISTS users;

@ -1,16 +0,0 @@
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
hashed_password TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- Add a trigger to keep timestamp updated.
CREATE TRIGGER users_update_timestamp
AFTER UPDATE ON users
FOR EACH ROW
BEGIN
UPDATE users SET updated_at = CURRENT_TIMESTAMP WHERE id = OLD.id;
END;

@ -1,48 +0,0 @@
package migrations
import (
"database/sql"
"embed"
"fmt"
"github.com/golang-migrate/migrate/v4"
"github.com/golang-migrate/migrate/v4/database/sqlite3"
_ "github.com/golang-migrate/migrate/v4/source/file"
"github.com/golang-migrate/migrate/v4/source/iofs"
)
//go:embed *.sql
var migrationFiles embed.FS
func Migrate(db *sql.DB) error {
// Create a database driver for the specific database type
driver, err := sqlite3.WithInstance(db, &sqlite3.Config{})
if err != nil {
return fmt.Errorf("failed to create database driver: %w", err)
}
// Create an IFS source from the embedded files
source, err := iofs.New(migrationFiles, ".")
if err != nil {
return fmt.Errorf("failed to create migration source: %w", err)
}
// Create a new migrate instance
m, err := migrate.NewWithInstance("iofs", source, "sqlite3", driver)
if err != nil {
return fmt.Errorf("failed to create migrate instance: %w", err)
}
// Run migrations
if err := m.Up(); err != nil && err != migrate.ErrNoChange {
return fmt.Errorf("failed to run migrations: %w", err)
}
// set WAL mode
_, err = db.Exec("PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL;")
if err != nil {
return fmt.Errorf("failed to set wall mode: %w", err)
}
return nil
}

@ -1,17 +0,0 @@
package ui
import "embed"
// The below is actually a comment directive. Comment directives must be
// placed immediately above the variable used. The path provided is relative
// to the .go file it is in. You can only embed on global variables at a package
// level. Paths cannot contain . or .. or begin with / So you are effectively
// restricted to embedding files within the same directory as the .go file.
// You can provide multiple passes "static/css" "static/img" "static/js" to help with
// avoiding shipping things like a PostCSS(tailwind) css file.
// Can also us static/css/*.css. "all:static" will embed . and _ files too.
// Lastly the embedded file system is always rooted in the directory that contains
// the embed directive. So in this example the root is in our ui dir.
//go:embed "static" "html"
var Files embed.FS

@ -15,9 +15,6 @@
</header>
{{template "nav" .}}
<main>
{{with .Flash}}
<div class='flash'>{{.}}</div>
{{end}}
{{template "main" .}}
</main>
<footer>Powered by <a href='https://golang.org/'>Go</a> in {{ .CurrentYear }}</footer>

@ -1,39 +0,0 @@
{{define "title"}}Create a New Snippet{{end}}
{{define "main"}}
<form action='/snippet/create' method='POST'>
<!-- Include the CSRF token -->
<input type='hidden' name='csrf_token' value='{{.CSRFToken}}'>
<div>
<label>Title:</label>
<!-- Use the `with` action to render the value of .Form.FieldErrors.title
if it is not empty. -->
{{with .Form.FieldErrors.title}}
<label class='error'>{{.}}</label>
{{end}}
<input type='text' name='title' value="{{.Form.Title}}">
</div>
<div>
<label>Content:</label>
<!-- Likewise render the value of .Form.FieldErrors.content if it is not
empty. -->
{{with .Form.FieldErrors.content}}
<label class='error'>{{.}}</label>
{{end}}
<textarea name='content'>{{.Form.Content}}</textarea>
</div>
<div>
<label>Delete in:</label>
<!-- And render the value of .Form.FieldErrors.expires if it is not empty. -->
{{with .Form.FieldErrors.expires}}
<label class='error'>{{.}}</label>
{{end}}
<input type='radio' name='expires' value='365' {{if (eq .Form.Expires 365)}}checked{{end}}> One Year
<input type='radio' name='expires' value='7' {{if (eq .Form.Expires 7)}}checked{{end}}> One Week
<input type='radio' name='expires' value='1' {{if (eq .Form.Expires 1)}}checked{{end}}> One Day
</div>
<div>
<input type='submit' value='Publish snippet'>
</div>
</form>
{{end}}

@ -1,30 +0,0 @@
{{define "title"}}Login{{end}}
{{define "main"}}
<form action='/user/login' method='POST' novalidate>
<!-- Include the CSRF token -->
<input type='hidden' name='csrf_token' value='{{.CSRFToken}}'>
<!-- Notice that here we are looping over the NonFieldErrors and displaying
them, if any exist -->
{{range .Form.NonFieldErrors}}
<div class='error'>{{.}}</div>
{{end}}
<div>
<label>Email:</label>
{{with .Form.FieldErrors.email}}
<label class='error'>{{.}}</label>
{{end}}
<input type='email' name='email' value='{{.Form.Email}}'>
</div>
<div>
<label>Password:</label>
{{with .Form.FieldErrors.password}}
<label class='error'>{{.}}</label>
{{end}}
<input type='password' name='password'>
</div>
<div>
<input type='submit' value='Login'>
</div>
</form>
{{end}}

@ -1,32 +0,0 @@
{{ define "title"}}Signup{{end}}
{{define "main"}}
<form action='/user/signup' method='POST' novalidate>
<!-- Include the CSRF token -->
<input type='hidden' name='csrf_token' value='{{.CSRFToken}}'>
<div>
<label>Name:</label>
{{with .Form.FieldErrors.name}}
<label class='error'>{{.}}</label>
{{end}}
<input type='text' name='name' value='{{.Form.Name}}'>
</div>
<div>
<label>Email:</label>
{{with .Form.FieldErrors.email}}
<label class='error'>{{.}}</label>
{{end}}
<input type='email' name='email' value='{{.Form.Email}}'>
</div>
<div>
<label>Password:</label>
{{with .Form.FieldErrors.password}}
<label class='error'>{{.}}</label>
{{end}}
<input type='password' name='password'>
</div>
<div>
<input type='submit' value='Signup'>
</div>
</form>
{{end}}

@ -1,22 +1,5 @@
{{define "nav" -}}
<nav>
<div>
<a href='/'>Home</a>
{{ if .IsAuthenticated }}
<a href='/snippet/create'>Create snippet</a>
{{ end }}
</div>
<div>
{{ if .IsAuthenticated }}
<form action='/user/logout' method='POST'>
<!-- Include the CSRF token -->
<input type='hidden' name='csrf_token' value='{{.CSRFToken}}'>
<button>Logout</button>
</form>
{{ else }}
<a href='/user/signup'>Signup</a>
<a href='/user/login'>Login</a>
{{ end }}
</div>
<a href='/'>Home</a>
</nav>
{{- end}}
Loading…
Cancel
Save