├── .gitignore ├── migrations ├── 20240809195516_initial.down.sql └── 20240809195516_initial.up.sql ├── internal ├── config │ ├── doc.go │ ├── database.go │ └── database_test.go ├── middleware │ ├── doc.go │ ├── middleware.go │ ├── nocache.go │ ├── middleware_test.go │ ├── logging.go │ └── ratelimit.go ├── repository │ ├── models.go │ ├── db.go │ └── query.sql.go ├── app │ ├── routes.go │ └── app.go ├── guest │ ├── guest.go │ └── repo.go ├── database │ └── database.go └── handler │ └── home.go ├── static └── css │ ├── main.css │ └── style.css ├── tailwind.config.js ├── query.sql ├── dev.sh ├── README.md ├── sqlc.yaml ├── main.go ├── .dockerignore ├── templates ├── error.html ├── 404.html ├── shortened.html └── index.html ├── README.Docker.md ├── .air.toml ├── go.mod ├── compose.yaml ├── compose.prod.yaml ├── .github └── workflows │ └── deploy.yaml ├── docker-stack.yaml ├── Dockerfile ├── compose.rl.yaml └── go.sum /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | tmp 3 | data 4 | db 5 | -------------------------------------------------------------------------------- /migrations/20240809195516_initial.down.sql: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /internal/config/doc.go: -------------------------------------------------------------------------------- 1 | // Package config provides configuration that is loaded from environment 2 | // variables. 3 | package config 4 | -------------------------------------------------------------------------------- /internal/middleware/doc.go: -------------------------------------------------------------------------------- 1 | // Package middleware contains any and all middleware 2 | // used by the application. 3 | package middleware 4 | -------------------------------------------------------------------------------- /static/css/main.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | html { 6 | color-scheme: dark; 7 | } 8 | 9 | input { 10 | --tw-ring-shadow: 0 0 #000 !important; 11 | } 12 | -------------------------------------------------------------------------------- /internal/middleware/middleware.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import "net/http" 4 | 5 | // Middleware represents the type signature of a middleware 6 | // function. 7 | type Middleware func(http.Handler) http.Handler 8 | -------------------------------------------------------------------------------- /migrations/20240809195516_initial.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE guest ( 2 | id uuid primary key, 3 | message varchar(256) not null, 4 | ip inet not null, 5 | created_at timestamptz not null, 6 | updated_at timestamptz not null 7 | ); 8 | 9 | CREATE INDEX ON guest (created_at); 10 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: ["./templates/*.html"], 4 | theme: { 5 | extend: {}, 6 | }, 7 | plugins: [ 8 | require('@tailwindcss/forms'), 9 | require('@tailwindcss/typography'), 10 | ], 11 | } 12 | -------------------------------------------------------------------------------- /internal/middleware/nocache.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "net/http" 5 | ) 6 | 7 | func NoCache(next http.Handler) http.Handler { 8 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 9 | next.ServeHTTP(w, r) 10 | r.Header.Add("Cache-Control", "max-age=0") 11 | }) 12 | } 13 | -------------------------------------------------------------------------------- /query.sql: -------------------------------------------------------------------------------- 1 | -- name: Insert :one 2 | INSERT INTO guest (id, message, created_at, updated_at, ip) 3 | VALUES ($1, $2, $3, $3, $4) 4 | RETURNING *; 5 | 6 | -- name: FindAll :many 7 | SELECT * 8 | FROM guest 9 | ORDER BY created_at DESC 10 | LIMIT $1; 11 | 12 | -- name: Count :one 13 | SELECT COUNT(*) FROM guest; 14 | -------------------------------------------------------------------------------- /internal/repository/models.go: -------------------------------------------------------------------------------- 1 | // Code generated by sqlc. DO NOT EDIT. 2 | // versions: 3 | // sqlc v1.27.0 4 | 5 | package repository 6 | 7 | import ( 8 | "net" 9 | "time" 10 | 11 | "github.com/google/uuid" 12 | ) 13 | 14 | type Guest struct { 15 | ID uuid.UUID 16 | Message string 17 | Ip net.IP 18 | CreatedAt time.Time 19 | UpdatedAt time.Time 20 | } 21 | -------------------------------------------------------------------------------- /dev.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | air -c ./.air.toml & 4 | tailwindcss \ 5 | -i 'static/css/main.css' \ 6 | -o 'static/css/style.css' \ 7 | --watch & \ 8 | browser-sync start \ 9 | --files 'templates/**/*.html, static/**/*.css' \ 10 | --port 3001 \ 11 | --proxy 'localhost:8080' \ 12 | --middleware 'function(req, res, next) { \ 13 | res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate"); \ 14 | return next(); \ 15 | }' 16 | #& \ 17 | #templ generate -watch 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Guestbook 2 | 3 | This is the code used for the Dreams of Code video on setting up a production ready VPS using Docker, Docker Compose, Traefik, and Watchtower. 4 | 5 | ## Usage 6 | 7 | To deploy this application, you'll need both docker and docker compose installed on your system, and then deploy it on to your 8 | VPS using the `docker compose up` command. 9 | 10 | To install docker compose, please refer to the official docker instructions. 11 | 12 | https://docs.docker.com/engine/install/ubuntu/ 13 | -------------------------------------------------------------------------------- /internal/app/routes.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "html/template" 5 | "net/http" 6 | 7 | "github.com/dreamsofcode-io/guestbook/internal/handler" 8 | ) 9 | 10 | func (a *App) loadRoutes(tmpl *template.Template) { 11 | guestbook := handler.New(a.logger, a.db, tmpl) 12 | 13 | files := http.FileServer(http.Dir("./static")) 14 | 15 | a.router.Handle("GET /static/", http.StripPrefix("/static", files)) 16 | 17 | a.router.Handle("GET /{$}", http.HandlerFunc(guestbook.Home)) 18 | 19 | a.router.Handle("POST /{$}", http.HandlerFunc(guestbook.Create)) 20 | } 21 | -------------------------------------------------------------------------------- /internal/guest/guest.go: -------------------------------------------------------------------------------- 1 | package guest 2 | 3 | import ( 4 | "fmt" 5 | "net" 6 | "time" 7 | 8 | "github.com/google/uuid" 9 | ) 10 | 11 | type Guest struct { 12 | ID uuid.UUID 13 | Message string 14 | CreatedAt time.Time 15 | IP net.IP 16 | } 17 | 18 | func NewGuest(message string, ip net.IP) (Guest, error) { 19 | id, err := uuid.NewV7() 20 | if err != nil { 21 | return Guest{}, fmt.Errorf("failed to create guest: %w", err) 22 | } 23 | 24 | return Guest{ 25 | ID: id, 26 | Message: message, 27 | CreatedAt: time.Now(), 28 | IP: ip, 29 | }, nil 30 | } 31 | -------------------------------------------------------------------------------- /sqlc.yaml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | sql: 3 | - engine: "postgresql" 4 | schema: "./migrations/" 5 | queries: "query.sql" 6 | gen: 7 | go: 8 | package: "repository" 9 | out: "internal/repository" 10 | sql_package: "pgx/v5" 11 | overrides: 12 | - db_type: "uuid" 13 | go_type: 14 | import: "github.com/google/uuid" 15 | type: "UUID" 16 | - db_type: "timestamptz" 17 | go_type: 18 | import: "time" 19 | type: "Time" 20 | - db_type: "inet" 21 | go_type: 22 | import: "net" 23 | type: "IP" 24 | -------------------------------------------------------------------------------- /internal/repository/db.go: -------------------------------------------------------------------------------- 1 | // Code generated by sqlc. DO NOT EDIT. 2 | // versions: 3 | // sqlc v1.27.0 4 | 5 | package repository 6 | 7 | import ( 8 | "context" 9 | 10 | "github.com/jackc/pgx/v5" 11 | "github.com/jackc/pgx/v5/pgconn" 12 | ) 13 | 14 | type DBTX interface { 15 | Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error) 16 | Query(context.Context, string, ...interface{}) (pgx.Rows, error) 17 | QueryRow(context.Context, string, ...interface{}) pgx.Row 18 | } 19 | 20 | func New(db DBTX) *Queries { 21 | return &Queries{db: db} 22 | } 23 | 24 | type Queries struct { 25 | db DBTX 26 | } 27 | 28 | func (q *Queries) WithTx(tx pgx.Tx) *Queries { 29 | return &Queries{ 30 | db: tx, 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "embed" 6 | "log/slog" 7 | "os" 8 | "os/signal" 9 | 10 | "github.com/joho/godotenv" 11 | 12 | "github.com/dreamsofcode-io/guestbook/internal/app" 13 | ) 14 | 15 | //go:embed migrations/*.sql 16 | var migrations embed.FS 17 | 18 | //go:embed templates/*.html 19 | var templates embed.FS 20 | 21 | func main() { 22 | godotenv.Load() 23 | 24 | logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) 25 | ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) 26 | defer cancel() 27 | 28 | a := app.New(logger, migrations, templates) 29 | 30 | if err := a.Start(ctx); err != nil { 31 | logger.Error("failed to start server", slog.Any("error", err)) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Include any files or directories that you don't want to be copied to your 2 | # container here (e.g., local build artifacts, temporary files, etc.). 3 | # 4 | # For more help, visit the .dockerignore file reference guide at 5 | # https://docs.docker.com/go/build-context-dockerignore/ 6 | 7 | **/.DS_Store 8 | **/.classpath 9 | **/.dockerignore 10 | **/.env 11 | **/.git 12 | **/.gitignore 13 | **/.project 14 | **/.settings 15 | **/.toolstarget 16 | **/.vs 17 | **/.vscode 18 | **/*.*proj.user 19 | **/*.dbmdl 20 | **/*.jfm 21 | **/bin 22 | **/charts 23 | **/docker-compose* 24 | **/compose.y*ml 25 | **/Dockerfile* 26 | **/node_modules 27 | **/npm-debug.log 28 | **/obj 29 | **/secrets.dev.yaml 30 | **/values.dev.yaml 31 | LICENSE 32 | README.md 33 | -------------------------------------------------------------------------------- /internal/middleware/middleware_test.go: -------------------------------------------------------------------------------- 1 | package middleware_test 2 | 3 | import ( 4 | "log/slog" 5 | "net/http" 6 | "net/http/httptest" 7 | "os" 8 | "testing" 9 | "time" 10 | 11 | "github.com/dreamsofcode-io/guestbook/internal/middleware" 12 | "github.com/stretchr/testify/assert" 13 | ) 14 | 15 | func TestLogging(t *testing.T) { 16 | logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) 17 | handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 18 | w.WriteHeader(http.StatusOK) 19 | }) 20 | testHandler := middleware.Logging(logger, handler) 21 | 22 | req := httptest.NewRequest("GET", "/", nil) 23 | w := httptest.NewRecorder() 24 | testHandler.ServeHTTP(w, req) 25 | 26 | assert.Equal(t, http.StatusOK, w.Code) 27 | assert.Greater(t, time.Since(req.Context().Value("startTime").(time.Time)), 0) 28 | } 29 | -------------------------------------------------------------------------------- /templates/error.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Guestbook | Error 6 | 7 | 8 | 9 |
10 |
11 |

{{ .StatusCode }}

12 |

{{ .StatusMessage }}

13 |
14 | Back to home 15 |
16 |
17 |
18 | 19 | 20 | -------------------------------------------------------------------------------- /README.Docker.md: -------------------------------------------------------------------------------- 1 | ### Building and running your application 2 | 3 | When you're ready, start your application by running: 4 | `docker compose up --build`. 5 | 6 | Your application will be available at http://localhost:8080. 7 | 8 | ### Deploying your application to the cloud 9 | 10 | First, build your image, e.g.: `docker build -t myapp .`. 11 | If your cloud uses a different CPU architecture than your development 12 | machine (e.g., you are on a Mac M1 and your cloud provider is amd64), 13 | you'll want to build the image for that platform, e.g.: 14 | `docker build --platform=linux/amd64 -t myapp .`. 15 | 16 | Then, push it to your registry, e.g. `docker push myregistry.com/myapp`. 17 | 18 | Consult Docker's [getting started](https://docs.docker.com/go/get-started-sharing/) 19 | docs for more detail on building and pushing. 20 | 21 | ### References 22 | * [Docker's Go guide](https://docs.docker.com/language/golang/) -------------------------------------------------------------------------------- /templates/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Dreemy | Not Found 6 | 7 | 8 | 9 |
10 |
11 |

404

12 |

Page not found

13 |

Sorry, we couldn’t find the page you’re looking for.

14 |
15 | Back to home 16 |
17 |
18 |
19 | 20 | 21 | -------------------------------------------------------------------------------- /.air.toml: -------------------------------------------------------------------------------- 1 | root = "." 2 | testdata_dir = "testdata" 3 | tmp_dir = "tmp" 4 | 5 | [build] 6 | args_bin = [] 7 | bin = "./tmp/main" 8 | cmd = "go generate && go build -o ./tmp/main ." 9 | delay = 1000 10 | exclude_dir = ["assets", "tmp", "vendor", "testdata"] 11 | exclude_file = [] 12 | exclude_regex = ["_test.go"] 13 | exclude_unchanged = false 14 | follow_symlink = false 15 | full_bin = "" 16 | include_dir = [] 17 | include_ext = ["go", "tpl", "tmpl", "html"] 18 | include_file = [] 19 | kill_delay = "0s" 20 | log = "build-errors.log" 21 | poll = false 22 | poll_interval = 0 23 | post_cmd = [] 24 | pre_cmd = [] 25 | rerun = false 26 | rerun_delay = 500 27 | send_interrupt = false 28 | stop_on_error = false 29 | 30 | [color] 31 | app = "" 32 | build = "yellow" 33 | main = "magenta" 34 | runner = "green" 35 | watcher = "cyan" 36 | 37 | [log] 38 | main_only = false 39 | time = false 40 | 41 | [misc] 42 | clean_on_exit = false 43 | 44 | [proxy] 45 | app_port = 0 46 | enabled = false 47 | proxy_port = 0 48 | 49 | [screen] 50 | clear_on_rebuild = false 51 | keep_scroll = true 52 | -------------------------------------------------------------------------------- /templates/shortened.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Dreemy | Short URL 7 | 8 | 18 | 19 | 20 |
21 |

URL Shortener

22 |
23 |
24 |

{{ .URL }}

25 |

26 | 30 |
31 |
32 | 33 | 34 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/dreamsofcode-io/guestbook 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.23.2 6 | 7 | require ( 8 | github.com/TwiN/go-away v1.6.13 9 | github.com/golang-migrate/migrate/v4 v4.17.1 10 | github.com/google/uuid v1.6.0 11 | github.com/jackc/pgx/v4 v4.18.3 12 | github.com/jackc/pgx/v5 v5.5.4 13 | github.com/joho/godotenv v1.5.1 14 | github.com/stretchr/testify v1.9.0 15 | ) 16 | 17 | require ( 18 | github.com/cespare/xxhash/v2 v2.2.0 // indirect 19 | github.com/davecgh/go-spew v1.1.1 // indirect 20 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect 21 | github.com/hashicorp/errwrap v1.1.0 // indirect 22 | github.com/hashicorp/go-multierror v1.1.1 // indirect 23 | github.com/jackc/chunkreader/v2 v2.0.1 // indirect 24 | github.com/jackc/pgconn v1.14.3 // indirect 25 | github.com/jackc/pgio v1.0.0 // indirect 26 | github.com/jackc/pgpassfile v1.0.0 // indirect 27 | github.com/jackc/pgproto3/v2 v2.3.3 // indirect 28 | github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect 29 | github.com/jackc/pgtype v1.14.0 // indirect 30 | github.com/jackc/puddle v1.3.0 // indirect 31 | github.com/jackc/puddle/v2 v2.2.1 // indirect 32 | github.com/lib/pq v1.10.9 // indirect 33 | github.com/pmezard/go-difflib v1.0.0 // indirect 34 | github.com/redis/go-redis/v9 v9.6.1 // indirect 35 | github.com/rogpeppe/go-internal v1.13.1 // indirect 36 | github.com/x-way/crawlerdetect v0.2.24 // indirect 37 | go.uber.org/atomic v1.7.0 // indirect 38 | golang.org/x/crypto v0.20.0 // indirect 39 | golang.org/x/sync v0.8.0 // indirect 40 | golang.org/x/text v0.17.0 // indirect 41 | gopkg.in/yaml.v3 v3.0.1 // indirect 42 | ) 43 | -------------------------------------------------------------------------------- /internal/middleware/logging.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "html/template" 5 | "log/slog" 6 | "net/http" 7 | "time" 8 | ) 9 | 10 | type wrappedWriter struct { 11 | http.ResponseWriter 12 | statusCode int 13 | } 14 | 15 | func (w *wrappedWriter) WriteHeader(statusCode int) { 16 | w.ResponseWriter.WriteHeader(statusCode) 17 | w.statusCode = statusCode 18 | } 19 | 20 | type errorPage struct { 21 | StatusCode int 22 | StatusMessage string 23 | } 24 | 25 | func HandleBadCode(tmpl *template.Template, next http.Handler) http.Handler { 26 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 27 | wrapped := &wrappedWriter{ 28 | ResponseWriter: w, 29 | statusCode: http.StatusOK, 30 | } 31 | 32 | next.ServeHTTP(wrapped, r) 33 | 34 | if wrapped.statusCode >= 400 { 35 | tmpl.ExecuteTemplate(w, "error.html", errorPage{ 36 | StatusCode: wrapped.statusCode, 37 | StatusMessage: http.StatusText(wrapped.statusCode), 38 | }) 39 | } 40 | }) 41 | } 42 | 43 | func Logging(logger *slog.Logger, next http.Handler) http.Handler { 44 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 45 | start := time.Now() 46 | 47 | wrapped := &wrappedWriter{ 48 | ResponseWriter: w, 49 | statusCode: http.StatusOK, 50 | } 51 | 52 | next.ServeHTTP(wrapped, r) 53 | 54 | logger.Info( 55 | "handled request", 56 | slog.Int("statusCode", wrapped.statusCode), 57 | slog.String("remoteAddr", r.RemoteAddr), 58 | slog.String("xffHeader", r.Header.Get("X-Forwarded-For")), 59 | slog.String("method", r.Method), 60 | slog.String("path", r.URL.Path), 61 | slog.Any("duration", time.Since(start)), 62 | ) 63 | }) 64 | } 65 | -------------------------------------------------------------------------------- /internal/guest/repo.go: -------------------------------------------------------------------------------- 1 | package guest 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/jackc/pgx/v5/pgxpool" 8 | ) 9 | 10 | type Repo struct { 11 | db *pgxpool.Pool 12 | } 13 | 14 | func NewRepo(db *pgxpool.Pool) *Repo { 15 | return &Repo{ 16 | db: db, 17 | } 18 | } 19 | 20 | var insertSQL = ` 21 | INSERT INTO guest (id, message, created_at, updated_at, ip) 22 | VALUES ($1, $2, $3, $3, $4) 23 | ` 24 | 25 | func (r *Repo) Insert(ctx context.Context, guest Guest) error { 26 | _, err := r.db.Exec( 27 | ctx, insertSQL, guest.ID, guest.Message, guest.CreatedAt.UTC(), 28 | guest.IP, 29 | ) 30 | if err != nil { 31 | return fmt.Errorf("execute sql: %w", err) 32 | } 33 | 34 | return nil 35 | } 36 | 37 | var selectSQL = ` 38 | SELECT id, message, created_at, ip 39 | FROM guest 40 | ORDER BY created_at DESC 41 | LIMIT $1 42 | ` 43 | 44 | func (r *Repo) FindAll(ctx context.Context, count int) ([]Guest, error) { 45 | rows, err := r.db.Query(ctx, selectSQL, count) 46 | if err != nil { 47 | return nil, fmt.Errorf("query: %w", err) 48 | } 49 | 50 | res := []Guest{} 51 | 52 | for rows.Next() { 53 | var guest Guest 54 | 55 | err = rows.Scan(&guest.ID, &guest.Message, &guest.CreatedAt, &guest.IP) 56 | if err != nil { 57 | fmt.Println(err) 58 | continue 59 | } 60 | 61 | guest.CreatedAt = guest.CreatedAt.UTC() 62 | 63 | res = append(res, guest) 64 | } 65 | 66 | return res, nil 67 | } 68 | 69 | var countSQL = ` 70 | SELECT COUNT(*) FROM guest 71 | ` 72 | 73 | func (r *Repo) Count(ctx context.Context) (int, error) { 74 | count := 0 75 | 76 | err := r.db.QueryRow(ctx, countSQL).Scan(&count) 77 | if err != nil { 78 | return 0, fmt.Errorf("query row: %w", err) 79 | } 80 | 81 | return count, nil 82 | } 83 | -------------------------------------------------------------------------------- /internal/repository/query.sql.go: -------------------------------------------------------------------------------- 1 | // Code generated by sqlc. DO NOT EDIT. 2 | // versions: 3 | // sqlc v1.27.0 4 | // source: query.sql 5 | 6 | package repository 7 | 8 | import ( 9 | "context" 10 | "net" 11 | "time" 12 | 13 | "github.com/google/uuid" 14 | ) 15 | 16 | const count = `-- name: Count :one 17 | SELECT COUNT(*) FROM guest 18 | ` 19 | 20 | func (q *Queries) Count(ctx context.Context) (int64, error) { 21 | row := q.db.QueryRow(ctx, count) 22 | var count int64 23 | err := row.Scan(&count) 24 | return count, err 25 | } 26 | 27 | const findAll = `-- name: FindAll :many 28 | SELECT id, message, ip, created_at, updated_at 29 | FROM guest 30 | ORDER BY created_at DESC 31 | LIMIT $1 32 | ` 33 | 34 | func (q *Queries) FindAll(ctx context.Context, limit int32) ([]Guest, error) { 35 | rows, err := q.db.Query(ctx, findAll, limit) 36 | if err != nil { 37 | return nil, err 38 | } 39 | defer rows.Close() 40 | var items []Guest 41 | for rows.Next() { 42 | var i Guest 43 | if err := rows.Scan( 44 | &i.ID, 45 | &i.Message, 46 | &i.Ip, 47 | &i.CreatedAt, 48 | &i.UpdatedAt, 49 | ); err != nil { 50 | return nil, err 51 | } 52 | items = append(items, i) 53 | } 54 | if err := rows.Err(); err != nil { 55 | return nil, err 56 | } 57 | return items, nil 58 | } 59 | 60 | const insert = `-- name: Insert :one 61 | INSERT INTO guest (id, message, created_at, updated_at, ip) 62 | VALUES ($1, $2, $3, $3, $4) 63 | RETURNING id, message, ip, created_at, updated_at 64 | ` 65 | 66 | type InsertParams struct { 67 | ID uuid.UUID 68 | Message string 69 | CreatedAt time.Time 70 | Ip net.IP 71 | } 72 | 73 | func (q *Queries) Insert(ctx context.Context, arg InsertParams) (Guest, error) { 74 | row := q.db.QueryRow(ctx, insert, 75 | arg.ID, 76 | arg.Message, 77 | arg.CreatedAt, 78 | arg.Ip, 79 | ) 80 | var i Guest 81 | err := row.Scan( 82 | &i.ID, 83 | &i.Message, 84 | &i.Ip, 85 | &i.CreatedAt, 86 | &i.UpdatedAt, 87 | ) 88 | return i, err 89 | } 90 | -------------------------------------------------------------------------------- /compose.yaml: -------------------------------------------------------------------------------- 1 | # Comments are provided throughout this file to help you get started. 2 | # If you need more help, visit the Docker Compose reference guide at 3 | # https://docs.docker.com/go/compose-spec-reference/ 4 | 5 | # Here the instructions define your application as a service called "server". 6 | # This service is built from the Dockerfile in the current directory. 7 | # You can add other services your application may depend on here, such as a 8 | # database or a cache. For examples, see the Awesome Compose repository: 9 | # https://github.com/docker/awesome-compose 10 | services: 11 | server: 12 | image: ghcr.io/dreamsofcode-io/guestbook/guestbook 13 | container_name: guestbook 14 | ports: 15 | - 8080:8080 16 | secrets: 17 | - db-password 18 | environment: 19 | - POSTGRES_HOST=db 20 | - POSTGRES_PASSWORD_FILE=/run/secrets/db-password 21 | - POSTGRES_USER=postgres 22 | - POSTGRES_DB=guestbook 23 | - POSTGRES_PORT=5432 24 | - POSTGRES_SSLMODE=disable 25 | depends_on: 26 | db: 27 | condition: service_healthy 28 | 29 | # The commented out section below is an example of how to define a PostgreSQL 30 | # database that your application can use. `depends_on` tells Docker Compose to 31 | # start the database before your application. The `db-data` volume persists the 32 | # database data between container restarts. The `db-password` secret is used 33 | # to set the database password. You must create `db/password.txt` and add 34 | # a password of your choosing to it before running `docker compose up`. 35 | db: 36 | image: postgres 37 | restart: always 38 | user: postgres 39 | secrets: 40 | - db-password 41 | volumes: 42 | - db-data:/var/lib/postgresql/data 43 | environment: 44 | - POSTGRES_DB=guestbook 45 | - POSTGRES_PASSWORD_FILE=/run/secrets/db-password 46 | expose: 47 | - 5432 48 | healthcheck: 49 | test: [ "CMD", "pg_isready" ] 50 | interval: 10s 51 | timeout: 5s 52 | retries: 5 53 | volumes: 54 | db-data: 55 | secrets: 56 | db-password: 57 | file: db/password.txt 58 | -------------------------------------------------------------------------------- /internal/app/app.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "html/template" 8 | "io/fs" 9 | "log/slog" 10 | "net/http" 11 | "os" 12 | "time" 13 | 14 | "github.com/jackc/pgx/v5/pgxpool" 15 | "github.com/redis/go-redis/v9" 16 | 17 | "github.com/dreamsofcode-io/guestbook/internal/database" 18 | "github.com/dreamsofcode-io/guestbook/internal/middleware" 19 | ) 20 | 21 | type App struct { 22 | logger *slog.Logger 23 | router *http.ServeMux 24 | db *pgxpool.Pool 25 | rdb *redis.Client 26 | migrations fs.FS 27 | templates fs.FS 28 | } 29 | 30 | func New(logger *slog.Logger, migrations fs.FS, templates fs.FS) *App { 31 | router := http.NewServeMux() 32 | 33 | redisAddr, exists := os.LookupEnv("REDIS_ADDR") 34 | if !exists { 35 | redisAddr = "localhost:6379" 36 | } 37 | 38 | app := &App{ 39 | logger: logger, 40 | router: router, 41 | rdb: redis.NewClient(&redis.Options{ 42 | Addr: redisAddr, 43 | }), 44 | migrations: migrations, 45 | templates: templates, 46 | } 47 | 48 | return app 49 | } 50 | 51 | func (a *App) Start(ctx context.Context) error { 52 | db, err := database.Connect(ctx, a.logger, a.migrations) 53 | if err != nil { 54 | return fmt.Errorf("failed to connect to db: %w", err) 55 | } 56 | 57 | a.db = db 58 | 59 | tmpl := template.Must(template.New("").ParseFS(a.templates, "templates/*")) 60 | 61 | a.loadRoutes(tmpl) 62 | 63 | server := http.Server{ 64 | Addr: ":8080", 65 | Handler: middleware.Logging(a.logger, middleware.HandleBadCode(tmpl, a.router)), 66 | } 67 | 68 | done := make(chan struct{}) 69 | go func() { 70 | err := server.ListenAndServe() 71 | if err != nil && !errors.Is(err, http.ErrServerClosed) { 72 | a.logger.Error("failed to listen and serve", slog.Any("error", err)) 73 | } 74 | close(done) 75 | }() 76 | 77 | a.logger.Info("Server listening", slog.String("addr", ":8080")) 78 | select { 79 | case <-done: 80 | break 81 | case <-ctx.Done(): 82 | ctx, cancel := context.WithTimeout(context.Background(), time.Second*30) 83 | server.Shutdown(ctx) 84 | cancel() 85 | } 86 | 87 | return nil 88 | } 89 | -------------------------------------------------------------------------------- /compose.prod.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | watchtower: 3 | image: containrrr/watchtower 4 | command: 5 | - "--label-enable" 6 | - "--interval" 7 | - "30" 8 | - "--rolling-restart" 9 | volumes: 10 | - /var/run/docker.sock:/var/run/docker.sock 11 | reverse-proxy: 12 | image: traefik:v3.1 13 | command: 14 | - "--providers.docker" 15 | - "--providers.docker.exposedbydefault=false" 16 | - "--entryPoints.websecure.address=:443" 17 | - "--certificatesresolvers.myresolver.acme.tlschallenge=true" 18 | - "--certificatesresolvers.myresolver.acme.email=elliott@zenful.cloud" 19 | - "--certificatesresolvers.myresolver.acme.storage=/letsencrypt/acme.json" 20 | - "--entrypoints.web.address=:80" 21 | - "--entrypoints.web.http.redirections.entrypoint.to=websecure" 22 | - "--entrypoints.web.http.redirections.entrypoint.scheme=https" 23 | ports: 24 | - "80:80" 25 | - "443:443" 26 | volumes: 27 | - letsencrypt:/letsencrypt 28 | - /var/run/docker.sock:/var/run/docker.sock 29 | guestbook: 30 | image: ghcr.io/dreamsofcode-io/guestbook:prod 31 | labels: 32 | - "traefik.enable=true" 33 | - "traefik.http.routers.guestbook.rule=Host(`zenful.cloud`)" 34 | - "traefik.http.routers.guestbook.entrypoints=websecure" 35 | - "traefik.http.routers.guestbook.tls.certresolver=myresolver" 36 | - "com.centurylinklabs.watchtower.enable=true" 37 | secrets: 38 | - db-password 39 | environment: 40 | - POSTGRES_HOST=db 41 | - POSTGRES_PASSWORD_FILE=/run/secrets/db-password 42 | - POSTGRES_USER=postgres 43 | - POSTGRES_DB=guestbook 44 | - POSTGRES_PORT=5432 45 | - POSTGRES_SSLMODE=disable 46 | deploy: 47 | mode: replicated 48 | replicas: 3 49 | restart: always 50 | depends_on: 51 | db: 52 | condition: service_healthy 53 | db: 54 | image: postgres 55 | restart: always 56 | user: postgres 57 | secrets: 58 | - db-password 59 | volumes: 60 | - db-data:/var/lib/postgresql/data 61 | environment: 62 | - POSTGRES_DB=guestbook 63 | - POSTGRES_PASSWORD_FILE=/run/secrets/db-password 64 | expose: 65 | - 5432 66 | healthcheck: 67 | test: [ "CMD", "pg_isready" ] 68 | interval: 10s 69 | timeout: 5s 70 | retries: 5 71 | volumes: 72 | db-data: 73 | letsencrypt: 74 | secrets: 75 | db-password: 76 | file: db/password.txt 77 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yaml: -------------------------------------------------------------------------------- 1 | name: Deploy to Server 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | permissions: 9 | packages: write 10 | 11 | jobs: 12 | commit-hash: 13 | runs-on: ubuntu-latest 14 | outputs: 15 | commit_hash: ${{ steps.get_commit.outputs.commit_hash }} 16 | steps: 17 | - uses: actions/checkout@v4 18 | - name: Get commit hash 19 | id: get_commit 20 | run: echo "::set-output name=commit_hash::$(git rev-parse HEAD)" 21 | 22 | build-and-test: 23 | runs-on: ubuntu-latest 24 | 25 | steps: 26 | - uses: actions/checkout@v4 27 | - name: Setup Go 28 | uses: actions/setup-go@v5 29 | with: 30 | go-version: '1.22.x' 31 | - name: Install dependencies 32 | run: go get . 33 | - name: Build 34 | run: go build -v ./... 35 | - name: Test with the Go CLI 36 | run: go test 37 | 38 | build-and-push-image: 39 | needs: 40 | - build-and-test 41 | - commit-hash 42 | runs-on: ubuntu-latest 43 | steps: 44 | - name: Checkout repository 45 | uses: actions/checkout@v3 46 | 47 | - name: Set short git commit SHA 48 | id: vars 49 | run: | 50 | calculatedSha=$(git rev-parse --short ${{ github.sha }}) 51 | echo "COMMIT_SHORT_SHA=$calculatedSha" >> $GITHUB_ENV 52 | 53 | - name: Log in to the Container registry 54 | uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9 55 | with: 56 | registry: https://ghcr.io 57 | username: ${{ github.actor }} 58 | password: ${{ secrets.GITHUB_TOKEN }} 59 | 60 | - name: Build and push Docker image 61 | uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc 62 | with: 63 | context: . 64 | push: true 65 | tags: ghcr.io/dreamsofcode-io/guestbook:${{ needs.commit-hash.outputs.commit_hash }} 66 | 67 | deploy: 68 | runs-on: ubuntu-latest 69 | needs: 70 | - build-and-push-image 71 | - commit-hash 72 | 73 | steps: 74 | - name: Checkout code 75 | uses: actions/checkout@v2 76 | - name: create env file 77 | run: | 78 | echo "GIT_COMMIT_HASH=${{ github.sha }}" >> env 79 | - name: 'Docker Stack Deploy' 80 | uses: cssnr/stack-deploy-action@v1 81 | with: 82 | name: 'guestbook' 83 | file: 'docker-stack.yaml' 84 | host: zenful.cloud 85 | user: deploytest 86 | ssh_key: ${{ secrets.DEPLOY_SSH_KEY }} 87 | env_file: './env' 88 | -------------------------------------------------------------------------------- /internal/database/database.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "io/fs" 8 | "log/slog" 9 | "os" 10 | 11 | "github.com/golang-migrate/migrate/v4" 12 | _ "github.com/golang-migrate/migrate/v4/database/postgres" 13 | "github.com/golang-migrate/migrate/v4/source/iofs" 14 | "github.com/jackc/pgx/v5/pgxpool" 15 | 16 | "github.com/dreamsofcode-io/guestbook/internal/config" 17 | ) 18 | 19 | var ( 20 | ErrMissingMigrationsPath = errors.New("MIGRATIONS_PATH env missing") 21 | ErrMissingDatabaseURL = errors.New("DATABASE_URL env missing") 22 | ) 23 | 24 | func loadConfigFromURL() (*pgxpool.Config, error) { 25 | dbURL, ok := os.LookupEnv("DATABASE_URL") 26 | if !ok { 27 | return nil, fmt.Errorf("Must set DATABASE_URL env var") 28 | } 29 | 30 | config, err := pgxpool.ParseConfig(dbURL) 31 | if err != nil { 32 | return nil, fmt.Errorf("failed to parse config: %w", err) 33 | } 34 | 35 | return config, nil 36 | } 37 | 38 | func loadConfig() (*pgxpool.Config, error) { 39 | cfg, err := config.NewDatabase() 40 | if err != nil { 41 | return loadConfigFromURL() 42 | } 43 | 44 | return pgxpool.ParseConfig(fmt.Sprintf( 45 | "user=%s password=%s host=%s port=%d dbname=%s sslmode=%s", 46 | cfg.Username, cfg.Password, cfg.Host, cfg.Port, cfg.DBName, cfg.SSLMode, 47 | )) 48 | } 49 | 50 | func dbURL() (string, error) { 51 | cfg, err := config.NewDatabase() 52 | if err != nil { 53 | dbURL, ok := os.LookupEnv("DATABASE_URL") 54 | if !ok { 55 | return "", fmt.Errorf("Must set DATABASE_URL env var") 56 | } 57 | 58 | return dbURL, nil 59 | } 60 | 61 | return cfg.URL(), nil 62 | } 63 | 64 | func Connect(ctx context.Context, logger *slog.Logger, migrations fs.FS) (*pgxpool.Pool, error) { 65 | config, err := loadConfig() 66 | if err != nil { 67 | return nil, err 68 | } 69 | 70 | conn, err := pgxpool.NewWithConfig(ctx, config) 71 | if err != nil { 72 | return nil, fmt.Errorf("could not connect to database: %w", err) 73 | } 74 | 75 | logger.Debug("Running migrations") 76 | 77 | url, err := dbURL() 78 | if err != nil { 79 | return nil, err 80 | } 81 | 82 | source, err := iofs.New(migrations, "migrations") 83 | if err != nil { 84 | return nil, fmt.Errorf("failed to create source: %w", err) 85 | } 86 | 87 | migrator, err := migrate.NewWithSourceInstance("iofs", source, url) 88 | if err != nil { 89 | return nil, fmt.Errorf("migrate new: %s", err) 90 | } 91 | 92 | if err := migrator.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) { 93 | return nil, fmt.Errorf("failed to migrate database: %w", err) 94 | } 95 | 96 | return conn, nil 97 | } 98 | -------------------------------------------------------------------------------- /internal/middleware/ratelimit.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "math" 5 | "net/http" 6 | "regexp" 7 | "strconv" 8 | "strings" 9 | "time" 10 | 11 | "github.com/redis/go-redis/v9" 12 | ) 13 | 14 | type RateLimiter struct { 15 | Period time.Duration 16 | MaxRate int64 17 | Store *redis.Client 18 | } 19 | 20 | var re = regexp.MustCompile(`\s?,\s?`) 21 | 22 | func (rl *RateLimiter) writeRateLimitHeaders( 23 | w http.ResponseWriter, 24 | used int64, 25 | expireTime time.Duration, 26 | ) { 27 | limit := rl.MaxRate 28 | remaining := int64(math.Max(float64(limit-used), 0)) 29 | reset := int64(math.Ceil(expireTime.Seconds())) 30 | 31 | w.Header().Add("X-RateLimit-Limit", strconv.FormatInt(limit, 10)) 32 | w.Header().Add("X-RateLimit-Remaining", strconv.FormatInt(remaining, 10)) 33 | w.Header().Add("X-RateLimit-Reset", strconv.FormatInt(reset, 10)) 34 | } 35 | 36 | func (rl *RateLimiter) Middleware(next http.Handler) http.Handler { 37 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 38 | // Obtain the clientIP from the XFF header 39 | clientIP := re.Split(r.Header.Get("X-Forwarded-For"), -1)[0] 40 | 41 | // If the xff header is empty, obtain the IP from the remoteAddr 42 | if clientIP == "" { 43 | parts := strings.Split(r.RemoteAddr, ":") 44 | clientIP = strings.Join(parts[0:len(parts)-1], ":") 45 | } 46 | 47 | // Get the current time to use for the event 48 | now := time.Now() 49 | 50 | // Add the current event to the store 51 | rl.Store.ZAdd(r.Context(), clientIP, redis.Z{ 52 | Member: now.UnixMicro(), 53 | Score: float64(now.UnixMicro()), 54 | }) 55 | 56 | // Calculate the cutoff 57 | cutoff := now.Add(rl.Period * -1).UnixMicro() 58 | 59 | // Remove all events that are before the cutoff 60 | rl.Store.ZRemRangeByScore(r.Context(), clientIP, "-inf", strconv.FormatInt(cutoff, 10)) 61 | 62 | // Pull the remaining events from the sorted set 63 | events, _ := rl.Store.ZRange(r.Context(), clientIP, 0, -1).Result() 64 | 65 | // Get the earliest event time 66 | earliestMicro, _ := strconv.ParseInt(events[0], 10, 64) 67 | earliest := time.UnixMicro(earliestMicro) 68 | 69 | // Calculate how long until it resets and how many events have occurred 70 | resets := rl.Period - time.Since(earliest) 71 | eventCount := int64(len(events)) 72 | 73 | // write the rate limit headers 74 | rl.writeRateLimitHeaders(w, eventCount, resets) 75 | 76 | // Check if client has exceeded the max rate 77 | if eventCount > rl.MaxRate { 78 | w.WriteHeader(http.StatusTooManyRequests) 79 | return 80 | } 81 | 82 | // Call the next handler if rate is not exceeded 83 | next.ServeHTTP(w, r) 84 | }) 85 | } 86 | -------------------------------------------------------------------------------- /docker-stack.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | reverse-proxy: 3 | image: traefik:v3.1 4 | command: 5 | - "--providers.docker" 6 | - "--providers.docker.exposedbydefault=false" 7 | - "--entryPoints.websecure.address=:443" 8 | - "--certificatesresolvers.myresolver.acme.tlschallenge=true" 9 | - "--certificatesresolvers.myresolver.acme.email=elliott@zenful.cloud" 10 | - "--certificatesresolvers.myresolver.acme.storage=/letsencrypt/acme.json" 11 | - "--entrypoints.web.address=:80" 12 | - "--entrypoints.web.http.redirections.entrypoint.to=websecure" 13 | - "--entrypoints.web.http.redirections.entrypoint.scheme=https" 14 | ports: 15 | - mode: host 16 | protocol: tcp 17 | published: 80 18 | target: 80 19 | - mode: host 20 | protocol: tcp 21 | published: 443 22 | target: 443 23 | volumes: 24 | - letsencrypt:/letsencrypt 25 | - /var/run/docker.sock:/var/run/docker.sock 26 | guestbook: 27 | image: ghcr.io/dreamsofcode-io/guestbook:${GIT_COMMIT_HASH:-prod} 28 | labels: 29 | - "traefik.enable=true" 30 | - "traefik.http.middlewares.guestbook-ratelimit.ratelimit.average=20" 31 | - "traefik.http.routers.guestbook.rule=Host(`zenful.cloud`) && !Method(`POST`)" 32 | - "traefik.http.services.guestbook.loadbalancer.server.port=8080" 33 | - "traefik.http.routers.guestbook.entrypoints=websecure" 34 | - "traefik.http.routers.guestbook.tls.certresolver=myresolver" 35 | - "traefik.http.routers.guestbook.middlewares=guestbook-ratelimit" 36 | # Define separate router for POST methods 37 | - "traefik.http.middlewares.guestbook-ratelimit-post.ratelimit.average=1" 38 | - "traefik.http.middlewares.guestbook-ratelimit-post.ratelimit.period=1m" 39 | - "traefik.http.routers.guestbook-post.rule=Host(`zenful.cloud`) && Method(`POST`)" 40 | - "traefik.http.routers.guestbook-post.middlewares=guestbook-ratelimit-post" 41 | - "traefik.http.routers.guestbook-post.entrypoints=websecure" 42 | - "traefik.http.routers.guestbook-post.tls.certresolver=myresolver" 43 | # Proxy 44 | - "traefik.http.routers.proxy.rule=Host(`proxy.dreamsofcode.io`)" 45 | - "traefik.http.routers.proxy.entrypoints=websecure" 46 | - "traefik.http.routers.proxy.tls.certresolver=myresolver" 47 | secrets: 48 | - db-password 49 | environment: 50 | - POSTGRES_HOST=db 51 | - POSTGRES_PASSWORD_FILE=/run/secrets/db-password 52 | - POSTGRES_USER=postgres 53 | - POSTGRES_DB=guestbook 54 | - POSTGRES_PORT=5432 55 | - POSTGRES_SSLMODE=disable 56 | deploy: 57 | mode: replicated 58 | replicas: 3 59 | restart: always 60 | depends_on: 61 | - db 62 | db: 63 | image: postgres:16 64 | restart: always 65 | user: postgres 66 | volumes: 67 | - db-data:/var/lib/postgresql/data 68 | secrets: 69 | - db-password 70 | environment: 71 | - POSTGRES_DB=guestbook 72 | - POSTGRES_PASSWORD_FILE=/run/secrets/db-password 73 | expose: 74 | - 5432 75 | healthcheck: 76 | test: [ "CMD", "pg_isready" ] 77 | interval: 10s 78 | timeout: 5s 79 | retries: 5 80 | volumes: 81 | db-data: 82 | letsencrypt: 83 | secrets: 84 | db-password: 85 | external: true 86 | -------------------------------------------------------------------------------- /templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Guest Book 7 | 8 | 9 | 10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |

Guest Book

18 |
19 |
20 |
21 |
22 |
23 | 24 | 25 |
26 |
27 | 28 |
29 |

30 | {{ .Total }} messages left by other users! 31 |

32 | {{ if .Guests }} 33 |
34 |
35 |
36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | {{ range .Guests }} 46 | 47 | 48 | 49 | 50 | {{ end }} 51 | 52 |
MessageTimestamp
{{ .Message }}{{ .CreatedAt.Format "02 Jan 06 15:04 MST" }}
53 |
54 |
55 |
56 | {{ end }} 57 |
58 |
59 |
60 |
61 |
62 | 63 | 64 | -------------------------------------------------------------------------------- /internal/handler/home.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "fmt" 5 | "html/template" 6 | "log/slog" 7 | "net" 8 | "net/http" 9 | "strings" 10 | 11 | goaway "github.com/TwiN/go-away" 12 | "github.com/jackc/pgx/v5/pgxpool" 13 | // "github.com/x-way/crawlerdetect" 14 | 15 | "github.com/dreamsofcode-io/guestbook/internal/guest" 16 | "github.com/dreamsofcode-io/guestbook/internal/repository" 17 | ) 18 | 19 | type Guestbook struct { 20 | logger *slog.Logger 21 | tmpl *template.Template 22 | repo *repository.Queries 23 | } 24 | 25 | func New( 26 | logger *slog.Logger, db *pgxpool.Pool, tmpl *template.Template, 27 | ) *Guestbook { 28 | return &Guestbook{ 29 | tmpl: tmpl, 30 | repo: repository.New(db), 31 | logger: logger, 32 | } 33 | } 34 | 35 | type indexPage struct { 36 | Guests []repository.Guest 37 | Total int64 38 | } 39 | 40 | type errorPage struct { 41 | ErrorMessage string 42 | } 43 | 44 | func (h *Guestbook) Home(w http.ResponseWriter, r *http.Request) { 45 | guests, err := h.repo.FindAll(r.Context(), 200) 46 | if err != nil { 47 | h.logger.Error("failed to find guests", slog.Any("error", err)) 48 | w.WriteHeader(http.StatusInternalServerError) 49 | return 50 | } 51 | 52 | count, err := h.repo.Count(r.Context()) 53 | if err != nil { 54 | h.logger.Error("failed to get count", slog.Any("error", err)) 55 | w.WriteHeader(http.StatusInternalServerError) 56 | return 57 | } 58 | 59 | w.Header().Add("Content-Type", "text/html") 60 | h.tmpl.ExecuteTemplate(w, "index.html", indexPage{ 61 | Guests: guests, 62 | Total: count, 63 | }) 64 | } 65 | 66 | func (h *Guestbook) Create(w http.ResponseWriter, r *http.Request) { 67 | // if crawlerdetect.IsCrawler(r.Header.Get("User-Agent")) { 68 | // w.WriteHeader(http.StatusUnauthorized) 69 | // return 70 | // } 71 | // 72 | if err := r.ParseForm(); err != nil { 73 | h.logger.Error("failed to parse form", slog.Any("error", err)) 74 | w.WriteHeader(http.StatusInternalServerError) 75 | return 76 | } 77 | 78 | msg, ok := r.Form["message"] 79 | if !ok { 80 | w.WriteHeader(http.StatusBadRequest) 81 | return 82 | } 83 | 84 | message := strings.Join(msg, " ") 85 | 86 | if strings.TrimSpace(message) == "" { 87 | w.WriteHeader(http.StatusBadRequest) 88 | h.tmpl.ExecuteTemplate(w, "error.html", errorPage{ 89 | ErrorMessage: "Blank messages don't count", 90 | }) 91 | 92 | return 93 | } 94 | 95 | splits := strings.Split(r.RemoteAddr, ":") 96 | ipStr := strings.Trim(strings.Join(splits[:len(splits)-1], ":"), "[]") 97 | ip := net.ParseIP(ipStr) 98 | 99 | if goaway.IsProfane(message) { 100 | w.WriteHeader(http.StatusBadRequest) 101 | h.tmpl.ExecuteTemplate(w, "error.html", errorPage{ 102 | ErrorMessage: fmt.Sprintf( 103 | "Please don't use profanity. Your IP has been tracked %s", 104 | ipStr, 105 | ), 106 | }) 107 | return 108 | } 109 | 110 | guest, err := guest.NewGuest(message, ip) 111 | if err != nil { 112 | h.logger.Error("failed to create guest", slog.Any("error", err)) 113 | w.WriteHeader(http.StatusInternalServerError) 114 | return 115 | } 116 | 117 | _, err = h.repo.Insert(r.Context(), repository.InsertParams{ 118 | ID: guest.ID, 119 | Message: guest.Message, 120 | CreatedAt: guest.CreatedAt, 121 | Ip: guest.IP, 122 | }) 123 | if err != nil { 124 | h.logger.Error("failed to insert guest", slog.Any("error", err)) 125 | w.WriteHeader(http.StatusInternalServerError) 126 | return 127 | } 128 | 129 | http.Redirect(w, r, "/", http.StatusFound) 130 | } 131 | -------------------------------------------------------------------------------- /internal/config/database.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strconv" 7 | "strings" 8 | ) 9 | 10 | // Database holds the configuration data for setting up a connection to a 11 | // database. 12 | type Database struct { 13 | Username string 14 | Password string 15 | Host string 16 | Port uint16 17 | DBName string 18 | SSLMode string 19 | } 20 | 21 | func loadPassword() (string, error) { 22 | password, ok := os.LookupEnv("POSTGRES_PASSWORD") 23 | if ok { 24 | return password, nil 25 | } 26 | 27 | passwordFile, ok := os.LookupEnv("POSTGRES_PASSWORD_FILE") 28 | if !ok { 29 | return "", fmt.Errorf("no POSTGRES_PASSWORD or POSTGRES_PASSWORD_FILE env var set") 30 | } 31 | 32 | data, err := os.ReadFile(passwordFile) 33 | if err != nil { 34 | return "", fmt.Errorf("failed to read from password file: %w", err) 35 | } 36 | 37 | return strings.TrimSpace(string(data)), nil 38 | } 39 | 40 | // NewDatabase creates a database configuration based on the environment 41 | // variables required. If any env variables are not set or are invalid then 42 | // this method will throw an error. 43 | func NewDatabase() (*Database, error) { 44 | username, ok := os.LookupEnv("POSTGRES_USER") 45 | if !ok { 46 | return nil, fmt.Errorf("no POSTGRES_USER env variable set") 47 | } 48 | 49 | password, err := loadPassword() 50 | if err != nil { 51 | return nil, fmt.Errorf("loading password: %w", err) 52 | } 53 | 54 | host, ok := os.LookupEnv("POSTGRES_HOST") 55 | if !ok { 56 | return nil, fmt.Errorf("no POSTGRES_HOST env variable set") 57 | } 58 | 59 | portStr, ok := os.LookupEnv("POSTGRES_PORT") 60 | if !ok { 61 | return nil, fmt.Errorf("no POSTGRES_PORT env variable set") 62 | } 63 | 64 | port, err := strconv.Atoi(portStr) 65 | if err != nil { 66 | return nil, fmt.Errorf("failed to convert port to int: %w", err) 67 | } 68 | 69 | dbname, ok := os.LookupEnv("POSTGRES_DB") 70 | if !ok { 71 | return nil, fmt.Errorf("no POSTGRES_DATABASE env variable set") 72 | } 73 | 74 | sslmode, ok := os.LookupEnv("POSTGRES_SSLMODE") 75 | if !ok { 76 | return nil, fmt.Errorf("no SSLMode env variable set") 77 | } 78 | 79 | config := &Database{ 80 | Username: username, 81 | Password: password, 82 | Host: host, 83 | Port: uint16(port), 84 | DBName: dbname, 85 | SSLMode: sslmode, 86 | } 87 | 88 | err = config.Validate() 89 | if err != nil { 90 | return nil, fmt.Errorf("failed to validate config: %w", err) 91 | } 92 | 93 | return config, nil 94 | } 95 | 96 | // URL generates a database connection url from the configuration fields. 97 | func (c *Database) URL() string { 98 | return fmt.Sprintf( 99 | "postgresql://%s:%s@%s:%d/%s?sslmode=%s", 100 | c.Username, 101 | c.Password, 102 | c.Host, 103 | c.Port, 104 | c.DBName, 105 | c.SSLMode, 106 | ) 107 | } 108 | 109 | // Validate checks a Database configuration to ensure it's values are 110 | // valid for connecting to a database. 111 | func (c *Database) Validate() error { 112 | if c.DBName == "" { 113 | return fmt.Errorf("invalid database name") 114 | } 115 | 116 | if c.Host == "" { 117 | return fmt.Errorf("invalid host") 118 | } 119 | 120 | if c.Username == "" { 121 | return fmt.Errorf("invalid username") 122 | } 123 | 124 | if c.Password == "" { 125 | return fmt.Errorf("invalid password") 126 | } 127 | 128 | if c.Port == 0 { 129 | return fmt.Errorf("invalid port") 130 | } 131 | 132 | if c.SSLMode == "" { 133 | return fmt.Errorf("invalid sslmode") 134 | } 135 | 136 | return nil 137 | } 138 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1 2 | 3 | # Comments are provided throughout this file to help you get started. 4 | # If you need more help, visit the Dockerfile reference guide at 5 | # https://docs.docker.com/go/dockerfile-reference/ 6 | 7 | # Want to help us make this template better? Share your feedback here: https://forms.gle/ybq9Krt8jtBL3iCk7 8 | 9 | ################################################################################ 10 | # Create a stage for building the application. 11 | ARG GO_VERSION=1.23.2 12 | FROM --platform=$BUILDPLATFORM golang:${GO_VERSION} AS build 13 | LABEL org.opencontainers.image.source=https://github.com/dreamsofcode-io/guestbook 14 | WORKDIR /src 15 | 16 | # Download dependencies as a separate step to take advantage of Docker's caching. 17 | # Leverage a cache mount to /go/pkg/mod/ to speed up subsequent builds. 18 | # Leverage bind mounts to go.sum and go.mod to avoid having to copy them into 19 | # the container. 20 | RUN --mount=type=cache,target=/go/pkg/mod/ \ 21 | --mount=type=bind,source=go.sum,target=go.sum \ 22 | --mount=type=bind,source=go.mod,target=go.mod \ 23 | go mod download -x 24 | 25 | # This is the architecture you're building for, which is passed in by the builder. 26 | # Placing it here allows the previous steps to be cached across architectures. 27 | ARG TARGETARCH 28 | 29 | # Build the application. 30 | # Leverage a cache mount to /go/pkg/mod/ to speed up subsequent builds. 31 | # Leverage a bind mount to the current directory to avoid having to copy the 32 | # source code into the container. 33 | RUN --mount=type=cache,target=/go/pkg/mod/ \ 34 | --mount=type=bind,target=. \ 35 | CGO_ENABLED=0 GOARCH=$TARGETARCH go build -o /bin/server . 36 | 37 | ################################################################################ 38 | # Create a new stage for running the application that contains the minimal 39 | # runtime dependencies for the application. This often uses a different base 40 | # image from the build stage where the necessary files are copied from the build 41 | # stage. 42 | # 43 | # The example below uses the alpine image as the foundation for running the app. 44 | # By specifying the "latest" tag, it will also use whatever happens to be the 45 | # most recent version of that image when you build your Dockerfile. If 46 | # reproducability is important, consider using a versioned tag 47 | # (e.g., alpine:3.17.2) or SHA (e.g., alpine@sha256:c41ab5c992deb4fe7e5da09f67a8804a46bd0592bfdf0b1847dde0e0889d2bff). 48 | FROM alpine:latest AS final 49 | 50 | LABEL org.opencontainers.image.source=https://github.com/dreamsofcode-io/guestbook 51 | # Install any runtime dependencies that are needed to run your application. 52 | # Leverage a cache mount to /var/cache/apk/ to speed up subsequent builds. 53 | RUN --mount=type=cache,target=/var/cache/apk \ 54 | apk --update add \ 55 | ca-certificates \ 56 | tzdata \ 57 | && \ 58 | update-ca-certificates 59 | 60 | # Create a non-privileged user that the app will run under. 61 | # See https://docs.docker.com/go/dockerfile-user-best-practices/ 62 | ARG UID=10001 63 | RUN adduser \ 64 | --disabled-password \ 65 | --gecos "" \ 66 | --home "/nonexistent" \ 67 | --shell "/sbin/nologin" \ 68 | --no-create-home \ 69 | --uid "${UID}" \ 70 | appuser 71 | USER appuser 72 | 73 | # Copy the executable from the "build" stage. 74 | COPY --from=build /bin/server /bin/ 75 | COPY ./templates ./templates 76 | COPY ./static ./static 77 | 78 | # Expose the port that the application listens on. 79 | EXPOSE 8080 80 | 81 | # What the container should run when it is started. 82 | ENTRYPOINT [ "/bin/server" ] 83 | -------------------------------------------------------------------------------- /compose.rl.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | watchtower: 3 | image: containrrr/watchtower 4 | command: 5 | - "--label-enable" 6 | - "--interval" 7 | - "30" 8 | - "--rolling-restart" 9 | volumes: 10 | - /var/run/docker.sock:/var/run/docker.sock 11 | reverse-proxy: 12 | image: traefik:v3.1 13 | command: 14 | - "--log.level=ERROR" 15 | - "--accesslog=true" 16 | - "--providers.docker" 17 | - "--providers.docker.exposedbydefault=false" 18 | - "--entryPoints.websecure.address=:443" 19 | - "--certificatesresolvers.myresolver.acme.tlschallenge=true" 20 | - "--certificatesresolvers.myresolver.acme.email=elliott@zenful.cloud" 21 | - "--certificatesresolvers.myresolver.acme.storage=/letsencrypt/acme.json" 22 | - "--entrypoints.web.address=:80" 23 | - "--entrypoints.web.http.redirections.entrypoint.to=websecure" 24 | - "--entrypoints.web.http.redirections.entrypoint.scheme=https" 25 | - "--entryPoints.web.forwardedHeaders.insecure" 26 | - "--entryPoints.websecure.forwardedHeaders.insecure" 27 | ports: 28 | - "80:80" 29 | - "443:443" 30 | volumes: 31 | - letsencrypt:/letsencrypt 32 | - /var/run/docker.sock:/var/run/docker.sock 33 | guestbook: 34 | image: ghcr.io/dreamsofcode-io/guestbook:prod 35 | labels: 36 | - "traefik.enable=true" 37 | - "traefik.http.middlewares.guestbook-ratelimit.ratelimit.average=20" 38 | - "traefik.http.routers.guestbook.rule=Host(`zenful.cloud`) && !Method(`POST`)" 39 | - "traefik.http.routers.guestbook.entrypoints=websecure" 40 | - "traefik.http.routers.guestbook.tls.certresolver=myresolver" 41 | - "traefik.http.routers.guestbook.middlewares=guestbook-ratelimit" 42 | # Define separate router for POST methods 43 | - "traefik.http.middlewares.guestbook-ratelimit-post.ratelimit.average=1" 44 | - "traefik.http.middlewares.guestbook-ratelimit-post.ratelimit.period=1m" 45 | - "traefik.http.routers.guestbook-post.rule=Host(`zenful.cloud`) && Method(`POST`)" 46 | - "traefik.http.routers.guestbook-post.middlewares=guestbook-ratelimit-post" 47 | - "traefik.http.routers.guestbook-post.entrypoints=websecure" 48 | - "traefik.http.routers.guestbook-post.tls.certresolver=myresolver" 49 | # Proxy 50 | - "traefik.http.routers.proxy.rule=Host(`proxy.dreamsofcode.io`)" 51 | - "traefik.http.routers.proxy.entrypoints=websecure" 52 | - "traefik.http.routers.proxy.tls.certresolver=myresolver" 53 | # Enable watchtower 54 | - "com.centurylinklabs.watchtower.enable=true" 55 | secrets: 56 | - db-password 57 | environment: 58 | - POSTGRES_HOST=db 59 | - POSTGRES_PASSWORD_FILE=/run/secrets/db-password 60 | - POSTGRES_USER=postgres 61 | - POSTGRES_DB=guestbook 62 | - POSTGRES_PORT=5432 63 | - POSTGRES_SSLMODE=disable 64 | deploy: 65 | mode: replicated 66 | replicas: 3 67 | restart: always 68 | depends_on: 69 | db: 70 | condition: service_healthy 71 | db: 72 | image: postgres 73 | restart: always 74 | user: postgres 75 | secrets: 76 | - db-password 77 | volumes: 78 | - db-data:/var/lib/postgresql/data 79 | environment: 80 | - POSTGRES_DB=guestbook 81 | - POSTGRES_PASSWORD_FILE=/run/secrets/db-password 82 | expose: 83 | - 5432 84 | healthcheck: 85 | test: [ "CMD", "pg_isready" ] 86 | interval: 10s 87 | timeout: 5s 88 | retries: 5 89 | 90 | dragonfly: 91 | image: 'docker.dragonflydb.io/dragonflydb/dragonfly' 92 | ulimits: 93 | memlock: -1 94 | network_mode: "host" 95 | volumes: 96 | - dragonflydata:/data 97 | 98 | volumes: 99 | db-data: 100 | letsencrypt: 101 | dragonflydata: 102 | 103 | secrets: 104 | db-password: 105 | file: db/password.txt 106 | -------------------------------------------------------------------------------- /internal/config/database_test.go: -------------------------------------------------------------------------------- 1 | package config_test 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | 10 | "github.com/dreamsofcode-io/guestbook/internal/config" 11 | ) 12 | 13 | func TestCreatingNewValidation(t *testing.T) { 14 | envs := []string{ 15 | "PGUSER", "PGPASSWORD", "PGPORT", "PGDATABASE", "PGHOST", 16 | } 17 | 18 | fullSetup := func() { 19 | os.Setenv("PGUSER", "user") 20 | os.Setenv("PGPASSWORD", "password") 21 | os.Setenv("PGPORT", "5432") 22 | os.Setenv("PGDATABASE", "database") 23 | os.Setenv("PGHOST", "database.com") 24 | } 25 | 26 | clear := func() { 27 | for _, env := range envs { 28 | os.Unsetenv(env) 29 | } 30 | } 31 | 32 | testCases := []struct { 33 | Description string 34 | Setup func() 35 | ExpectedCfg *config.Database 36 | ExpectedErr error 37 | }{ 38 | { 39 | Description: "testing a complete env setup", 40 | Setup: func() { 41 | fullSetup() 42 | }, 43 | ExpectedCfg: &config.Database{ 44 | Username: "user", 45 | Password: "password", 46 | Port: 5432, 47 | Host: "database.com", 48 | DBName: "database", 49 | }, 50 | }, 51 | { 52 | Description: "no setup", 53 | Setup: func() { 54 | clear() 55 | }, 56 | ExpectedErr: fmt.Errorf("no PGUSER env variable set"), 57 | }, 58 | { 59 | Description: "no pg user", 60 | Setup: func() { 61 | fullSetup() 62 | os.Unsetenv("PGUSER") 63 | }, 64 | ExpectedErr: fmt.Errorf("no PGUSER env variable set"), 65 | }, 66 | { 67 | Description: "no pg password", 68 | Setup: func() { 69 | fullSetup() 70 | os.Unsetenv("PGPASSWORD") 71 | }, 72 | ExpectedErr: fmt.Errorf("no PGPASSWORD env variable set"), 73 | }, 74 | { 75 | Description: "no pg host", 76 | Setup: func() { 77 | fullSetup() 78 | os.Unsetenv("PGHOST") 79 | }, 80 | ExpectedErr: fmt.Errorf("no PGHOST env variable set"), 81 | }, 82 | { 83 | Description: "no pg port", 84 | Setup: func() { 85 | fullSetup() 86 | os.Unsetenv("PGPORT") 87 | }, 88 | ExpectedErr: fmt.Errorf("no PGPORT env variable set"), 89 | }, 90 | { 91 | Description: "no pg database", 92 | Setup: func() { 93 | fullSetup() 94 | os.Unsetenv("PGDATABASE") 95 | }, 96 | ExpectedErr: fmt.Errorf("no PGDATABASE env variable set"), 97 | }, 98 | { 99 | Description: "invalid port setup", 100 | Setup: func() { 101 | fullSetup() 102 | os.Setenv("PGPORT", "helloworld") 103 | }, 104 | ExpectedErr: fmt.Errorf( 105 | "failed to convert port to int: strconv.Atoi: parsing \"helloworld\": invalid syntax", 106 | ), 107 | }, 108 | { 109 | Description: "empty db name", 110 | Setup: func() { 111 | fullSetup() 112 | os.Setenv("PGDATABASE", "") 113 | }, 114 | ExpectedErr: fmt.Errorf( 115 | "failed to validate config: invalid database name", 116 | ), 117 | }, 118 | } 119 | 120 | for _, test := range testCases { 121 | test := test 122 | t.Run(test.Description, func(t *testing.T) { 123 | test.Setup() 124 | 125 | cfg, err := config.NewDatabase() 126 | 127 | if test.ExpectedErr != nil { 128 | assert.EqualError(t, err, test.ExpectedErr.Error()) 129 | } else { 130 | assert.NoError(t, err) 131 | } 132 | 133 | assert.Equal(t, test.ExpectedCfg, cfg) 134 | }) 135 | } 136 | } 137 | 138 | func TestConfigValidation(t *testing.T) { 139 | valid := config.Database{ 140 | Username: "pguser", 141 | Password: "pgpassword", 142 | Host: "pghost", 143 | Port: 5432, 144 | DBName: "pgdatabase", 145 | } 146 | 147 | t.Run("Test valid", func(t *testing.T) { 148 | valid := valid 149 | assert.NoError(t, valid.Validate()) 150 | }) 151 | 152 | t.Run("Test invalid username", func(t *testing.T) { 153 | valid := valid 154 | valid.Username = "" 155 | assert.EqualError(t, valid.Validate(), "invalid username") 156 | }) 157 | 158 | t.Run("Test invalid password", func(t *testing.T) { 159 | valid := valid 160 | valid.Password = "" 161 | assert.EqualError(t, valid.Validate(), "invalid password") 162 | }) 163 | 164 | t.Run("Test invalid host", func(t *testing.T) { 165 | valid := valid 166 | valid.Host = "" 167 | assert.EqualError(t, valid.Validate(), "invalid host") 168 | }) 169 | 170 | t.Run("Test invalid port", func(t *testing.T) { 171 | valid := valid 172 | valid.Port = 0 173 | assert.EqualError(t, valid.Validate(), "invalid port") 174 | }) 175 | 176 | t.Run("Test invalid name", func(t *testing.T) { 177 | valid := valid 178 | valid.DBName = "" 179 | assert.EqualError(t, valid.Validate(), "invalid database name") 180 | }) 181 | } 182 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= 2 | github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= 3 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 4 | github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= 5 | github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= 6 | github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= 7 | github.com/TwiN/go-away v1.6.13 h1:aB6l/FPXmA5ds+V7I9zdhxzpsLLUvVtEuS++iU/ZmgE= 8 | github.com/TwiN/go-away v1.6.13/go.mod h1:MpvIC9Li3minq+CGgbgUDvQ9tDaeW35k5IXZrF9MVas= 9 | github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= 10 | github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 11 | github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I= 12 | github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ= 13 | github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= 14 | github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= 15 | github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= 16 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 17 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 18 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 19 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= 20 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= 21 | github.com/dhui/dktest v0.4.1 h1:/w+IWuDXVymg3IrRJCHHOkMK10m9aNVMOyD0X12YVTg= 22 | github.com/dhui/dktest v0.4.1/go.mod h1:DdOqcUpL7vgyP4GlF3X3w7HbSlz8cEQzwewPveYEQbA= 23 | github.com/docker/distribution v2.8.2+incompatible h1:T3de5rq0dB1j30rp0sA2rER+m322EBzniBPB6ZIzuh8= 24 | github.com/docker/distribution v2.8.2+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= 25 | github.com/docker/docker v24.0.9+incompatible h1:HPGzNmwfLZWdxHqK9/II92pyi1EpYKsAqcl4G0Of9v0= 26 | github.com/docker/docker v24.0.9+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= 27 | github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ= 28 | github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec= 29 | github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= 30 | github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= 31 | github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= 32 | github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= 33 | github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= 34 | github.com/gofrs/uuid v4.0.0+incompatible h1:1SD/1F5pU8p29ybwgQSwpQk+mwdRrXCYuPhW6m+TnJw= 35 | github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= 36 | github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 37 | github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 38 | github.com/golang-migrate/migrate/v4 v4.17.1 h1:4zQ6iqL6t6AiItphxJctQb3cFqWiSpMnX7wLTPnnYO4= 39 | github.com/golang-migrate/migrate/v4 v4.17.1/go.mod h1:m8hinFyWBn0SA4QKHuKh175Pm9wjmxj3S2Mia7dbXzM= 40 | github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= 41 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 42 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 43 | github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= 44 | github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= 45 | github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= 46 | github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= 47 | github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= 48 | github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo= 49 | github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk= 50 | github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8= 51 | github.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk= 52 | github.com/jackc/pgconn v0.0.0-20190420214824-7e0022ef6ba3/go.mod h1:jkELnwuX+w9qN5YIfX0fl88Ehu4XC3keFuOJJk9pcnA= 53 | github.com/jackc/pgconn v0.0.0-20190824142844-760dd75542eb/go.mod h1:lLjNuW/+OfW9/pnVKPazfWOgNfH2aPem8YQ7ilXGvJE= 54 | github.com/jackc/pgconn v0.0.0-20190831204454-2fabfa3c18b7/go.mod h1:ZJKsE/KZfsUgOEh9hBm+xYTstcNHg7UPMVJqRfQxq4s= 55 | github.com/jackc/pgconn v1.8.0/go.mod h1:1C2Pb36bGIP9QHGBYCjnyhqu7Rv3sGshaQUvmfGIB/o= 56 | github.com/jackc/pgconn v1.9.0/go.mod h1:YctiPyvzfU11JFxoXokUOOKQXQmDMoJL9vJzHH8/2JY= 57 | github.com/jackc/pgconn v1.9.1-0.20210724152538-d89c8390a530/go.mod h1:4z2w8XhRbP1hYxkpTuBjTS3ne3J48K83+u0zoyvg2pI= 58 | github.com/jackc/pgconn v1.14.3 h1:bVoTr12EGANZz66nZPkMInAV/KHD2TxH9npjXXgiB3w= 59 | github.com/jackc/pgconn v1.14.3/go.mod h1:RZbme4uasqzybK2RK5c65VsHxoyaml09lx3tXOcO/VM= 60 | github.com/jackc/pgio v1.0.0 h1:g12B9UwVnzGhueNavwioyEEpAmqMe1E/BN9ES+8ovkE= 61 | github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8= 62 | github.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2/go.mod h1:fGZlG77KXmcq05nJLRkk0+p82V8B8Dw8KN2/V9c/OAE= 63 | github.com/jackc/pgmock v0.0.0-20201204152224-4fe30f7445fd/go.mod h1:hrBW0Enj2AZTNpt/7Y5rr2xe/9Mn757Wtb2xeBzPv2c= 64 | github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65 h1:DadwsjnMwFjfWc9y5Wi/+Zz7xoE5ALHsRQlOctkOiHc= 65 | github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65/go.mod h1:5R2h2EEX+qri8jOWMbJCtaPWkrrNc7OHwsp2TCqp7ak= 66 | github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= 67 | github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= 68 | github.com/jackc/pgproto3 v1.1.0/go.mod h1:eR5FA3leWg7p9aeAqi37XOTgTIbkABlvcPB3E5rlc78= 69 | github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190420180111-c116219b62db/go.mod h1:bhq50y+xrl9n5mRYyCBFKkpRVTLYJVWeCc+mEAI3yXA= 70 | github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190609003834-432c2951c711/go.mod h1:uH0AWtUmuShn0bcesswc4aBTWGvw0cAxIJp+6OB//Wg= 71 | github.com/jackc/pgproto3/v2 v2.0.0-rc3/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM= 72 | github.com/jackc/pgproto3/v2 v2.0.0-rc3.0.20190831210041-4c03ce451f29/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM= 73 | github.com/jackc/pgproto3/v2 v2.0.6/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= 74 | github.com/jackc/pgproto3/v2 v2.1.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= 75 | github.com/jackc/pgproto3/v2 v2.3.3 h1:1HLSx5H+tXR9pW3in3zaztoEwQYRC9SQaYUHjTSUOag= 76 | github.com/jackc/pgproto3/v2 v2.3.3/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= 77 | github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E= 78 | github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= 79 | github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= 80 | github.com/jackc/pgtype v0.0.0-20190421001408-4ed0de4755e0/go.mod h1:hdSHsc1V01CGwFsrv11mJRHWJ6aifDLfdV3aVjFF0zg= 81 | github.com/jackc/pgtype v0.0.0-20190824184912-ab885b375b90/go.mod h1:KcahbBH1nCMSo2DXpzsoWOAfFkdEtEJpPbVLq8eE+mc= 82 | github.com/jackc/pgtype v0.0.0-20190828014616-a8802b16cc59/go.mod h1:MWlu30kVJrUS8lot6TQqcg7mtthZ9T0EoIBFiJcmcyw= 83 | github.com/jackc/pgtype v1.8.1-0.20210724151600-32e20a603178/go.mod h1:C516IlIV9NKqfsMCXTdChteoXmwgUceqaLfjg2e3NlM= 84 | github.com/jackc/pgtype v1.14.0 h1:y+xUdabmyMkJLyApYuPj38mW+aAIqCe5uuBB51rH3Vw= 85 | github.com/jackc/pgtype v1.14.0/go.mod h1:LUMuVrfsFfdKGLw+AFFVv6KtHOFMwRgDDzBt76IqCA4= 86 | github.com/jackc/pgx/v4 v4.0.0-20190420224344-cc3461e65d96/go.mod h1:mdxmSJJuR08CZQyj1PVQBHy9XOp5p8/SHH6a0psbY9Y= 87 | github.com/jackc/pgx/v4 v4.0.0-20190421002000-1b8f0016e912/go.mod h1:no/Y67Jkk/9WuGR0JG/JseM9irFbnEPbuWV2EELPNuM= 88 | github.com/jackc/pgx/v4 v4.0.0-pre1.0.20190824185557-6972a5742186/go.mod h1:X+GQnOEnf1dqHGpw7JmHqHc1NxDoalibchSk9/RWuDc= 89 | github.com/jackc/pgx/v4 v4.12.1-0.20210724153913-640aa07df17c/go.mod h1:1QD0+tgSXP7iUjYm9C1NxKhny7lq6ee99u/z+IHFcgs= 90 | github.com/jackc/pgx/v4 v4.18.3 h1:dE2/TrEsGX3RBprb3qryqSV9Y60iZN1C6i8IrmW9/BA= 91 | github.com/jackc/pgx/v4 v4.18.3/go.mod h1:Ey4Oru5tH5sB6tV7hDmfWFahwF15Eb7DNXlRKx2CkVw= 92 | github.com/jackc/pgx/v5 v5.5.4 h1:Xp2aQS8uXButQdnCMWNmvx6UysWQQC+u1EoizjguY+8= 93 | github.com/jackc/pgx/v5 v5.5.4/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= 94 | github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= 95 | github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= 96 | github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= 97 | github.com/jackc/puddle v1.3.0 h1:eHK/5clGOatcjX3oWGBO/MpxpbHzSwud5EWTSCI+MX0= 98 | github.com/jackc/puddle v1.3.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= 99 | github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= 100 | github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= 101 | github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 102 | github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 103 | github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 104 | github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= 105 | github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= 106 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 107 | github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= 108 | github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= 109 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 110 | github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw= 111 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= 112 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 113 | github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= 114 | github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= 115 | github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= 116 | github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 117 | github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= 118 | github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 119 | github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ= 120 | github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= 121 | github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= 122 | github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= 123 | github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= 124 | github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= 125 | github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= 126 | github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= 127 | github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= 128 | github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= 129 | github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= 130 | github.com/opencontainers/image-spec v1.0.2 h1:9yCKha/T5XdGtO0q9Q9a6T5NUCsTn/DrBg0D7ufOcFM= 131 | github.com/opencontainers/image-spec v1.0.2/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0= 132 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 133 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 134 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 135 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 136 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 137 | github.com/redis/go-redis/v9 v9.6.1 h1:HHDteefn6ZkTtY5fGUE8tj8uy85AHk6zP7CpzIAM0y4= 138 | github.com/redis/go-redis/v9 v9.6.1/go.mod h1:0C0c6ycQsdpVNQpxb1njEQIqkx5UcsM8FJCQLgE9+RA= 139 | github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= 140 | github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= 141 | github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= 142 | github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= 143 | github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU= 144 | github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc= 145 | github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= 146 | github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4= 147 | github.com/shopspring/decimal v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXYbsQ= 148 | github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= 149 | github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= 150 | github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= 151 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 152 | github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 153 | github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= 154 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 155 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 156 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 157 | github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= 158 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 159 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 160 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 161 | github.com/x-way/crawlerdetect v0.2.24 h1:kZDzSeiXB64M+Bknopn5GddHT+LBocD61jEjqDOufLE= 162 | github.com/x-way/crawlerdetect v0.2.24/go.mod h1:s6iUJZPq/WNBJThPRK+zk8ah7iIbGUZn9nYWMls3YP0= 163 | github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= 164 | go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= 165 | go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= 166 | go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= 167 | go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= 168 | go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw= 169 | go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= 170 | go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= 171 | go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4= 172 | go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU= 173 | go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= 174 | go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= 175 | go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= 176 | go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM= 177 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 178 | golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE= 179 | golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 180 | golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 181 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 182 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 183 | golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= 184 | golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 185 | golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 186 | golang.org/x/crypto v0.20.0 h1:jmAMJJZXr5KiCw05dfYK9QnqaqKLYXijU23lsEdcQqg= 187 | golang.org/x/crypto v0.20.0/go.mod h1:Xwo95rrVNIoSMx9wa1JroENMToLWn3RNVrTBpLHgZPQ= 188 | golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 189 | golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= 190 | golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= 191 | golang.org/x/mod v0.18.0 h1:5+9lSbEzPSdWkH32vYPBwEpX8KwDbM52Ud9xBUvNlb0= 192 | golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 193 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 194 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 195 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 196 | golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 197 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 198 | golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= 199 | golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= 200 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 201 | golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= 202 | golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 203 | golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 204 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 205 | golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 206 | golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 207 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 208 | golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 209 | golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 210 | golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 211 | golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 212 | golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 213 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 214 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 215 | golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= 216 | golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 217 | golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= 218 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 219 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 220 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 221 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 222 | golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 223 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 224 | golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc= 225 | golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= 226 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 227 | golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 228 | golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= 229 | golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= 230 | golang.org/x/tools v0.0.0-20190823170909-c4a336ef6a2f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 231 | golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 232 | golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 233 | golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 234 | golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA= 235 | golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c= 236 | golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 237 | golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 238 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 239 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 240 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 241 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 242 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 243 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 244 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 245 | gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= 246 | gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s= 247 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 248 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 249 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 250 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 251 | honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= 252 | -------------------------------------------------------------------------------- /static/css/style.css: -------------------------------------------------------------------------------- 1 | /* 2 | ! tailwindcss v3.4.3 | MIT License | https://tailwindcss.com 3 | */ 4 | 5 | /* 6 | 1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4) 7 | 2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116) 8 | */ 9 | 10 | *, 11 | ::before, 12 | ::after { 13 | box-sizing: border-box; 14 | /* 1 */ 15 | border-width: 0; 16 | /* 2 */ 17 | border-style: solid; 18 | /* 2 */ 19 | border-color: #e5e7eb; 20 | /* 2 */ 21 | } 22 | 23 | ::before, 24 | ::after { 25 | --tw-content: ''; 26 | } 27 | 28 | /* 29 | 1. Use a consistent sensible line-height in all browsers. 30 | 2. Prevent adjustments of font size after orientation changes in iOS. 31 | 3. Use a more readable tab size. 32 | 4. Use the user's configured `sans` font-family by default. 33 | 5. Use the user's configured `sans` font-feature-settings by default. 34 | 6. Use the user's configured `sans` font-variation-settings by default. 35 | 7. Disable tap highlights on iOS 36 | */ 37 | 38 | html, 39 | :host { 40 | line-height: 1.5; 41 | /* 1 */ 42 | -webkit-text-size-adjust: 100%; 43 | /* 2 */ 44 | -moz-tab-size: 4; 45 | /* 3 */ 46 | -o-tab-size: 4; 47 | tab-size: 4; 48 | /* 3 */ 49 | font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; 50 | /* 4 */ 51 | font-feature-settings: normal; 52 | /* 5 */ 53 | font-variation-settings: normal; 54 | /* 6 */ 55 | -webkit-tap-highlight-color: transparent; 56 | /* 7 */ 57 | } 58 | 59 | /* 60 | 1. Remove the margin in all browsers. 61 | 2. Inherit line-height from `html` so users can set them as a class directly on the `html` element. 62 | */ 63 | 64 | body { 65 | margin: 0; 66 | /* 1 */ 67 | line-height: inherit; 68 | /* 2 */ 69 | } 70 | 71 | /* 72 | 1. Add the correct height in Firefox. 73 | 2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655) 74 | 3. Ensure horizontal rules are visible by default. 75 | */ 76 | 77 | hr { 78 | height: 0; 79 | /* 1 */ 80 | color: inherit; 81 | /* 2 */ 82 | border-top-width: 1px; 83 | /* 3 */ 84 | } 85 | 86 | /* 87 | Add the correct text decoration in Chrome, Edge, and Safari. 88 | */ 89 | 90 | abbr:where([title]) { 91 | -webkit-text-decoration: underline dotted; 92 | text-decoration: underline dotted; 93 | } 94 | 95 | /* 96 | Remove the default font size and weight for headings. 97 | */ 98 | 99 | h1, 100 | h2, 101 | h3, 102 | h4, 103 | h5, 104 | h6 { 105 | font-size: inherit; 106 | font-weight: inherit; 107 | } 108 | 109 | /* 110 | Reset links to optimize for opt-in styling instead of opt-out. 111 | */ 112 | 113 | a { 114 | color: inherit; 115 | text-decoration: inherit; 116 | } 117 | 118 | /* 119 | Add the correct font weight in Edge and Safari. 120 | */ 121 | 122 | b, 123 | strong { 124 | font-weight: bolder; 125 | } 126 | 127 | /* 128 | 1. Use the user's configured `mono` font-family by default. 129 | 2. Use the user's configured `mono` font-feature-settings by default. 130 | 3. Use the user's configured `mono` font-variation-settings by default. 131 | 4. Correct the odd `em` font sizing in all browsers. 132 | */ 133 | 134 | code, 135 | kbd, 136 | samp, 137 | pre { 138 | font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; 139 | /* 1 */ 140 | font-feature-settings: normal; 141 | /* 2 */ 142 | font-variation-settings: normal; 143 | /* 3 */ 144 | font-size: 1em; 145 | /* 4 */ 146 | } 147 | 148 | /* 149 | Add the correct font size in all browsers. 150 | */ 151 | 152 | small { 153 | font-size: 80%; 154 | } 155 | 156 | /* 157 | Prevent `sub` and `sup` elements from affecting the line height in all browsers. 158 | */ 159 | 160 | sub, 161 | sup { 162 | font-size: 75%; 163 | line-height: 0; 164 | position: relative; 165 | vertical-align: baseline; 166 | } 167 | 168 | sub { 169 | bottom: -0.25em; 170 | } 171 | 172 | sup { 173 | top: -0.5em; 174 | } 175 | 176 | /* 177 | 1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297) 178 | 2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016) 179 | 3. Remove gaps between table borders by default. 180 | */ 181 | 182 | table { 183 | text-indent: 0; 184 | /* 1 */ 185 | border-color: inherit; 186 | /* 2 */ 187 | border-collapse: collapse; 188 | /* 3 */ 189 | } 190 | 191 | /* 192 | 1. Change the font styles in all browsers. 193 | 2. Remove the margin in Firefox and Safari. 194 | 3. Remove default padding in all browsers. 195 | */ 196 | 197 | button, 198 | input, 199 | optgroup, 200 | select, 201 | textarea { 202 | font-family: inherit; 203 | /* 1 */ 204 | font-feature-settings: inherit; 205 | /* 1 */ 206 | font-variation-settings: inherit; 207 | /* 1 */ 208 | font-size: 100%; 209 | /* 1 */ 210 | font-weight: inherit; 211 | /* 1 */ 212 | line-height: inherit; 213 | /* 1 */ 214 | letter-spacing: inherit; 215 | /* 1 */ 216 | color: inherit; 217 | /* 1 */ 218 | margin: 0; 219 | /* 2 */ 220 | padding: 0; 221 | /* 3 */ 222 | } 223 | 224 | /* 225 | Remove the inheritance of text transform in Edge and Firefox. 226 | */ 227 | 228 | button, 229 | select { 230 | text-transform: none; 231 | } 232 | 233 | /* 234 | 1. Correct the inability to style clickable types in iOS and Safari. 235 | 2. Remove default button styles. 236 | */ 237 | 238 | button, 239 | input:where([type='button']), 240 | input:where([type='reset']), 241 | input:where([type='submit']) { 242 | -webkit-appearance: button; 243 | /* 1 */ 244 | background-color: transparent; 245 | /* 2 */ 246 | background-image: none; 247 | /* 2 */ 248 | } 249 | 250 | /* 251 | Use the modern Firefox focus style for all focusable elements. 252 | */ 253 | 254 | :-moz-focusring { 255 | outline: auto; 256 | } 257 | 258 | /* 259 | Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737) 260 | */ 261 | 262 | :-moz-ui-invalid { 263 | box-shadow: none; 264 | } 265 | 266 | /* 267 | Add the correct vertical alignment in Chrome and Firefox. 268 | */ 269 | 270 | progress { 271 | vertical-align: baseline; 272 | } 273 | 274 | /* 275 | Correct the cursor style of increment and decrement buttons in Safari. 276 | */ 277 | 278 | ::-webkit-inner-spin-button, 279 | ::-webkit-outer-spin-button { 280 | height: auto; 281 | } 282 | 283 | /* 284 | 1. Correct the odd appearance in Chrome and Safari. 285 | 2. Correct the outline style in Safari. 286 | */ 287 | 288 | [type='search'] { 289 | -webkit-appearance: textfield; 290 | /* 1 */ 291 | outline-offset: -2px; 292 | /* 2 */ 293 | } 294 | 295 | /* 296 | Remove the inner padding in Chrome and Safari on macOS. 297 | */ 298 | 299 | ::-webkit-search-decoration { 300 | -webkit-appearance: none; 301 | } 302 | 303 | /* 304 | 1. Correct the inability to style clickable types in iOS and Safari. 305 | 2. Change font properties to `inherit` in Safari. 306 | */ 307 | 308 | ::-webkit-file-upload-button { 309 | -webkit-appearance: button; 310 | /* 1 */ 311 | font: inherit; 312 | /* 2 */ 313 | } 314 | 315 | /* 316 | Add the correct display in Chrome and Safari. 317 | */ 318 | 319 | summary { 320 | display: list-item; 321 | } 322 | 323 | /* 324 | Removes the default spacing and border for appropriate elements. 325 | */ 326 | 327 | blockquote, 328 | dl, 329 | dd, 330 | h1, 331 | h2, 332 | h3, 333 | h4, 334 | h5, 335 | h6, 336 | hr, 337 | figure, 338 | p, 339 | pre { 340 | margin: 0; 341 | } 342 | 343 | fieldset { 344 | margin: 0; 345 | padding: 0; 346 | } 347 | 348 | legend { 349 | padding: 0; 350 | } 351 | 352 | ol, 353 | ul, 354 | menu { 355 | list-style: none; 356 | margin: 0; 357 | padding: 0; 358 | } 359 | 360 | /* 361 | Reset default styling for dialogs. 362 | */ 363 | 364 | dialog { 365 | padding: 0; 366 | } 367 | 368 | /* 369 | Prevent resizing textareas horizontally by default. 370 | */ 371 | 372 | textarea { 373 | resize: vertical; 374 | } 375 | 376 | /* 377 | 1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300) 378 | 2. Set the default placeholder color to the user's configured gray 400 color. 379 | */ 380 | 381 | input::-moz-placeholder, textarea::-moz-placeholder { 382 | opacity: 1; 383 | /* 1 */ 384 | color: #9ca3af; 385 | /* 2 */ 386 | } 387 | 388 | input::placeholder, 389 | textarea::placeholder { 390 | opacity: 1; 391 | /* 1 */ 392 | color: #9ca3af; 393 | /* 2 */ 394 | } 395 | 396 | /* 397 | Set the default cursor for buttons. 398 | */ 399 | 400 | button, 401 | [role="button"] { 402 | cursor: pointer; 403 | } 404 | 405 | /* 406 | Make sure disabled buttons don't get the pointer cursor. 407 | */ 408 | 409 | :disabled { 410 | cursor: default; 411 | } 412 | 413 | /* 414 | 1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14) 415 | 2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210) 416 | This can trigger a poorly considered lint error in some tools but is included by design. 417 | */ 418 | 419 | img, 420 | svg, 421 | video, 422 | canvas, 423 | audio, 424 | iframe, 425 | embed, 426 | object { 427 | display: block; 428 | /* 1 */ 429 | vertical-align: middle; 430 | /* 2 */ 431 | } 432 | 433 | /* 434 | Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14) 435 | */ 436 | 437 | img, 438 | video { 439 | max-width: 100%; 440 | height: auto; 441 | } 442 | 443 | /* Make elements with the HTML hidden attribute stay hidden by default */ 444 | 445 | [hidden] { 446 | display: none; 447 | } 448 | 449 | [type='text'],[type='email'],[type='url'],[type='password'],[type='number'],[type='date'],[type='datetime-local'],[type='month'],[type='search'],[type='tel'],[type='time'],[type='week'],[multiple],textarea,select { 450 | -webkit-appearance: none; 451 | -moz-appearance: none; 452 | appearance: none; 453 | background-color: #fff; 454 | border-color: #6b7280; 455 | border-width: 1px; 456 | border-radius: 0px; 457 | padding-top: 0.5rem; 458 | padding-right: 0.75rem; 459 | padding-bottom: 0.5rem; 460 | padding-left: 0.75rem; 461 | font-size: 1rem; 462 | line-height: 1.5rem; 463 | --tw-shadow: 0 0 #0000; 464 | } 465 | 466 | [type='text']:focus, [type='email']:focus, [type='url']:focus, [type='password']:focus, [type='number']:focus, [type='date']:focus, [type='datetime-local']:focus, [type='month']:focus, [type='search']:focus, [type='tel']:focus, [type='time']:focus, [type='week']:focus, [multiple]:focus, textarea:focus, select:focus { 467 | outline: 2px solid transparent; 468 | outline-offset: 2px; 469 | --tw-ring-inset: var(--tw-empty,/*!*/ /*!*/); 470 | --tw-ring-offset-width: 0px; 471 | --tw-ring-offset-color: #fff; 472 | --tw-ring-color: #2563eb; 473 | --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); 474 | --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color); 475 | box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); 476 | border-color: #2563eb; 477 | } 478 | 479 | input::-moz-placeholder, textarea::-moz-placeholder { 480 | color: #6b7280; 481 | opacity: 1; 482 | } 483 | 484 | input::placeholder,textarea::placeholder { 485 | color: #6b7280; 486 | opacity: 1; 487 | } 488 | 489 | ::-webkit-datetime-edit-fields-wrapper { 490 | padding: 0; 491 | } 492 | 493 | ::-webkit-date-and-time-value { 494 | min-height: 1.5em; 495 | } 496 | 497 | ::-webkit-datetime-edit,::-webkit-datetime-edit-year-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-meridiem-field { 498 | padding-top: 0; 499 | padding-bottom: 0; 500 | } 501 | 502 | select { 503 | background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e"); 504 | background-position: right 0.5rem center; 505 | background-repeat: no-repeat; 506 | background-size: 1.5em 1.5em; 507 | padding-right: 2.5rem; 508 | -webkit-print-color-adjust: exact; 509 | print-color-adjust: exact; 510 | } 511 | 512 | [multiple] { 513 | background-image: initial; 514 | background-position: initial; 515 | background-repeat: unset; 516 | background-size: initial; 517 | padding-right: 0.75rem; 518 | -webkit-print-color-adjust: unset; 519 | print-color-adjust: unset; 520 | } 521 | 522 | [type='checkbox'],[type='radio'] { 523 | -webkit-appearance: none; 524 | -moz-appearance: none; 525 | appearance: none; 526 | padding: 0; 527 | -webkit-print-color-adjust: exact; 528 | print-color-adjust: exact; 529 | display: inline-block; 530 | vertical-align: middle; 531 | background-origin: border-box; 532 | -webkit-user-select: none; 533 | -moz-user-select: none; 534 | user-select: none; 535 | flex-shrink: 0; 536 | height: 1rem; 537 | width: 1rem; 538 | color: #2563eb; 539 | background-color: #fff; 540 | border-color: #6b7280; 541 | border-width: 1px; 542 | --tw-shadow: 0 0 #0000; 543 | } 544 | 545 | [type='checkbox'] { 546 | border-radius: 0px; 547 | } 548 | 549 | [type='radio'] { 550 | border-radius: 100%; 551 | } 552 | 553 | [type='checkbox']:focus,[type='radio']:focus { 554 | outline: 2px solid transparent; 555 | outline-offset: 2px; 556 | --tw-ring-inset: var(--tw-empty,/*!*/ /*!*/); 557 | --tw-ring-offset-width: 2px; 558 | --tw-ring-offset-color: #fff; 559 | --tw-ring-color: #2563eb; 560 | --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); 561 | --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color); 562 | box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); 563 | } 564 | 565 | [type='checkbox']:checked,[type='radio']:checked { 566 | border-color: transparent; 567 | background-color: currentColor; 568 | background-size: 100% 100%; 569 | background-position: center; 570 | background-repeat: no-repeat; 571 | } 572 | 573 | [type='checkbox']:checked { 574 | background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e"); 575 | } 576 | 577 | [type='radio']:checked { 578 | background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3ccircle cx='8' cy='8' r='3'/%3e%3c/svg%3e"); 579 | } 580 | 581 | [type='checkbox']:checked:hover,[type='checkbox']:checked:focus,[type='radio']:checked:hover,[type='radio']:checked:focus { 582 | border-color: transparent; 583 | background-color: currentColor; 584 | } 585 | 586 | [type='checkbox']:indeterminate { 587 | background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3e%3cpath stroke='white' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 8h8'/%3e%3c/svg%3e"); 588 | border-color: transparent; 589 | background-color: currentColor; 590 | background-size: 100% 100%; 591 | background-position: center; 592 | background-repeat: no-repeat; 593 | } 594 | 595 | [type='checkbox']:indeterminate:hover,[type='checkbox']:indeterminate:focus { 596 | border-color: transparent; 597 | background-color: currentColor; 598 | } 599 | 600 | [type='file'] { 601 | background: unset; 602 | border-color: inherit; 603 | border-width: 0; 604 | border-radius: 0; 605 | padding: 0; 606 | font-size: unset; 607 | line-height: inherit; 608 | } 609 | 610 | [type='file']:focus { 611 | outline: 1px solid ButtonText; 612 | outline: 1px auto -webkit-focus-ring-color; 613 | } 614 | 615 | *, ::before, ::after { 616 | --tw-border-spacing-x: 0; 617 | --tw-border-spacing-y: 0; 618 | --tw-translate-x: 0; 619 | --tw-translate-y: 0; 620 | --tw-rotate: 0; 621 | --tw-skew-x: 0; 622 | --tw-skew-y: 0; 623 | --tw-scale-x: 1; 624 | --tw-scale-y: 1; 625 | --tw-pan-x: ; 626 | --tw-pan-y: ; 627 | --tw-pinch-zoom: ; 628 | --tw-scroll-snap-strictness: proximity; 629 | --tw-gradient-from-position: ; 630 | --tw-gradient-via-position: ; 631 | --tw-gradient-to-position: ; 632 | --tw-ordinal: ; 633 | --tw-slashed-zero: ; 634 | --tw-numeric-figure: ; 635 | --tw-numeric-spacing: ; 636 | --tw-numeric-fraction: ; 637 | --tw-ring-inset: ; 638 | --tw-ring-offset-width: 0px; 639 | --tw-ring-offset-color: #fff; 640 | --tw-ring-color: rgb(59 130 246 / 0.5); 641 | --tw-ring-offset-shadow: 0 0 #0000; 642 | --tw-ring-shadow: 0 0 #0000; 643 | --tw-shadow: 0 0 #0000; 644 | --tw-shadow-colored: 0 0 #0000; 645 | --tw-blur: ; 646 | --tw-brightness: ; 647 | --tw-contrast: ; 648 | --tw-grayscale: ; 649 | --tw-hue-rotate: ; 650 | --tw-invert: ; 651 | --tw-saturate: ; 652 | --tw-sepia: ; 653 | --tw-drop-shadow: ; 654 | --tw-backdrop-blur: ; 655 | --tw-backdrop-brightness: ; 656 | --tw-backdrop-contrast: ; 657 | --tw-backdrop-grayscale: ; 658 | --tw-backdrop-hue-rotate: ; 659 | --tw-backdrop-invert: ; 660 | --tw-backdrop-opacity: ; 661 | --tw-backdrop-saturate: ; 662 | --tw-backdrop-sepia: ; 663 | --tw-contain-size: ; 664 | --tw-contain-layout: ; 665 | --tw-contain-paint: ; 666 | --tw-contain-style: ; 667 | } 668 | 669 | ::backdrop { 670 | --tw-border-spacing-x: 0; 671 | --tw-border-spacing-y: 0; 672 | --tw-translate-x: 0; 673 | --tw-translate-y: 0; 674 | --tw-rotate: 0; 675 | --tw-skew-x: 0; 676 | --tw-skew-y: 0; 677 | --tw-scale-x: 1; 678 | --tw-scale-y: 1; 679 | --tw-pan-x: ; 680 | --tw-pan-y: ; 681 | --tw-pinch-zoom: ; 682 | --tw-scroll-snap-strictness: proximity; 683 | --tw-gradient-from-position: ; 684 | --tw-gradient-via-position: ; 685 | --tw-gradient-to-position: ; 686 | --tw-ordinal: ; 687 | --tw-slashed-zero: ; 688 | --tw-numeric-figure: ; 689 | --tw-numeric-spacing: ; 690 | --tw-numeric-fraction: ; 691 | --tw-ring-inset: ; 692 | --tw-ring-offset-width: 0px; 693 | --tw-ring-offset-color: #fff; 694 | --tw-ring-color: rgb(59 130 246 / 0.5); 695 | --tw-ring-offset-shadow: 0 0 #0000; 696 | --tw-ring-shadow: 0 0 #0000; 697 | --tw-shadow: 0 0 #0000; 698 | --tw-shadow-colored: 0 0 #0000; 699 | --tw-blur: ; 700 | --tw-brightness: ; 701 | --tw-contrast: ; 702 | --tw-grayscale: ; 703 | --tw-hue-rotate: ; 704 | --tw-invert: ; 705 | --tw-saturate: ; 706 | --tw-sepia: ; 707 | --tw-drop-shadow: ; 708 | --tw-backdrop-blur: ; 709 | --tw-backdrop-brightness: ; 710 | --tw-backdrop-contrast: ; 711 | --tw-backdrop-grayscale: ; 712 | --tw-backdrop-hue-rotate: ; 713 | --tw-backdrop-invert: ; 714 | --tw-backdrop-opacity: ; 715 | --tw-backdrop-saturate: ; 716 | --tw-backdrop-sepia: ; 717 | --tw-contain-size: ; 718 | --tw-contain-layout: ; 719 | --tw-contain-paint: ; 720 | --tw-contain-style: ; 721 | } 722 | 723 | .relative { 724 | position: relative; 725 | } 726 | 727 | .isolate { 728 | isolation: isolate; 729 | } 730 | 731 | .-mx-4 { 732 | margin-left: -1rem; 733 | margin-right: -1rem; 734 | } 735 | 736 | .-my-2 { 737 | margin-top: -0.5rem; 738 | margin-bottom: -0.5rem; 739 | } 740 | 741 | .mx-auto { 742 | margin-left: auto; 743 | margin-right: auto; 744 | } 745 | 746 | .mt-10 { 747 | margin-top: 2.5rem; 748 | } 749 | 750 | .mt-4 { 751 | margin-top: 1rem; 752 | } 753 | 754 | .block { 755 | display: block; 756 | } 757 | 758 | .inline-block { 759 | display: inline-block; 760 | } 761 | 762 | .flex { 763 | display: flex; 764 | } 765 | 766 | .table { 767 | display: table; 768 | } 769 | 770 | .flow-root { 771 | display: flow-root; 772 | } 773 | 774 | .h-full { 775 | height: 100%; 776 | } 777 | 778 | .h-screen { 779 | height: 100vh; 780 | } 781 | 782 | .min-h-full { 783 | min-height: 100%; 784 | } 785 | 786 | .min-h-screen { 787 | min-height: 100vh; 788 | } 789 | 790 | .w-full { 791 | width: 100%; 792 | } 793 | 794 | .w-min { 795 | width: -moz-min-content; 796 | width: min-content; 797 | } 798 | 799 | .min-w-full { 800 | min-width: 100%; 801 | } 802 | 803 | .max-w-7xl { 804 | max-width: 80rem; 805 | } 806 | 807 | .flex-shrink-0 { 808 | flex-shrink: 0; 809 | } 810 | 811 | .table-auto { 812 | table-layout: auto; 813 | } 814 | 815 | .flex-row { 816 | flex-direction: row; 817 | } 818 | 819 | .flex-col { 820 | flex-direction: column; 821 | } 822 | 823 | .items-center { 824 | align-items: center; 825 | } 826 | 827 | .justify-center { 828 | justify-content: center; 829 | } 830 | 831 | .space-y-12 > :not([hidden]) ~ :not([hidden]) { 832 | --tw-space-y-reverse: 0; 833 | margin-top: calc(3rem * calc(1 - var(--tw-space-y-reverse))); 834 | margin-bottom: calc(3rem * var(--tw-space-y-reverse)); 835 | } 836 | 837 | .space-y-6 > :not([hidden]) ~ :not([hidden]) { 838 | --tw-space-y-reverse: 0; 839 | margin-top: calc(1.5rem * calc(1 - var(--tw-space-y-reverse))); 840 | margin-bottom: calc(1.5rem * var(--tw-space-y-reverse)); 841 | } 842 | 843 | .divide-y > :not([hidden]) ~ :not([hidden]) { 844 | --tw-divide-y-reverse: 0; 845 | border-top-width: calc(1px * calc(1 - var(--tw-divide-y-reverse))); 846 | border-bottom-width: calc(1px * var(--tw-divide-y-reverse)); 847 | } 848 | 849 | .divide-gray-700 > :not([hidden]) ~ :not([hidden]) { 850 | --tw-divide-opacity: 1; 851 | border-color: rgb(55 65 81 / var(--tw-divide-opacity)); 852 | } 853 | 854 | .divide-gray-800 > :not([hidden]) ~ :not([hidden]) { 855 | --tw-divide-opacity: 1; 856 | border-color: rgb(31 41 55 / var(--tw-divide-opacity)); 857 | } 858 | 859 | .overflow-x-auto { 860 | overflow-x: auto; 861 | } 862 | 863 | .whitespace-nowrap { 864 | white-space: nowrap; 865 | } 866 | 867 | .text-nowrap { 868 | text-wrap: nowrap; 869 | } 870 | 871 | .rounded { 872 | border-radius: 0.25rem; 873 | } 874 | 875 | .rounded-md { 876 | border-radius: 0.375rem; 877 | } 878 | 879 | .rounded-l-none { 880 | border-top-left-radius: 0px; 881 | border-bottom-left-radius: 0px; 882 | } 883 | 884 | .rounded-r-none { 885 | border-top-right-radius: 0px; 886 | border-bottom-right-radius: 0px; 887 | } 888 | 889 | .border-0 { 890 | border-width: 0px; 891 | } 892 | 893 | .border-4 { 894 | border-width: 4px; 895 | } 896 | 897 | .border-b { 898 | border-bottom-width: 1px; 899 | } 900 | 901 | .border-teal-500 { 902 | --tw-border-opacity: 1; 903 | border-color: rgb(20 184 166 / var(--tw-border-opacity)); 904 | } 905 | 906 | .bg-blue-800 { 907 | --tw-bg-opacity: 1; 908 | background-color: rgb(30 64 175 / var(--tw-bg-opacity)); 909 | } 910 | 911 | .bg-gray-950 { 912 | --tw-bg-opacity: 1; 913 | background-color: rgb(3 7 18 / var(--tw-bg-opacity)); 914 | } 915 | 916 | .bg-teal-500 { 917 | --tw-bg-opacity: 1; 918 | background-color: rgb(20 184 166 / var(--tw-bg-opacity)); 919 | } 920 | 921 | .px-3 { 922 | padding-left: 0.75rem; 923 | padding-right: 0.75rem; 924 | } 925 | 926 | .px-4 { 927 | padding-left: 1rem; 928 | padding-right: 1rem; 929 | } 930 | 931 | .px-6 { 932 | padding-left: 1.5rem; 933 | padding-right: 1.5rem; 934 | } 935 | 936 | .py-1 { 937 | padding-top: 0.25rem; 938 | padding-bottom: 0.25rem; 939 | } 940 | 941 | .py-10 { 942 | padding-top: 2.5rem; 943 | padding-bottom: 2.5rem; 944 | } 945 | 946 | .py-2 { 947 | padding-top: 0.5rem; 948 | padding-bottom: 0.5rem; 949 | } 950 | 951 | .py-3 { 952 | padding-top: 0.75rem; 953 | padding-bottom: 0.75rem; 954 | } 955 | 956 | .py-3\.5 { 957 | padding-top: 0.875rem; 958 | padding-bottom: 0.875rem; 959 | } 960 | 961 | .py-32 { 962 | padding-top: 8rem; 963 | padding-bottom: 8rem; 964 | } 965 | 966 | .py-4 { 967 | padding-top: 1rem; 968 | padding-bottom: 1rem; 969 | } 970 | 971 | .pl-4 { 972 | padding-left: 1rem; 973 | } 974 | 975 | .pr-3 { 976 | padding-right: 0.75rem; 977 | } 978 | 979 | .pt-32 { 980 | padding-top: 8rem; 981 | } 982 | 983 | .text-left { 984 | text-align: left; 985 | } 986 | 987 | .text-center { 988 | text-align: center; 989 | } 990 | 991 | .align-middle { 992 | vertical-align: middle; 993 | } 994 | 995 | .font-mono { 996 | font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; 997 | } 998 | 999 | .text-2xl { 1000 | font-size: 1.5rem; 1001 | line-height: 2rem; 1002 | } 1003 | 1004 | .text-3xl { 1005 | font-size: 1.875rem; 1006 | line-height: 2.25rem; 1007 | } 1008 | 1009 | .text-4xl { 1010 | font-size: 2.25rem; 1011 | line-height: 2.5rem; 1012 | } 1013 | 1014 | .text-base { 1015 | font-size: 1rem; 1016 | line-height: 1.5rem; 1017 | } 1018 | 1019 | .text-lg { 1020 | font-size: 1.125rem; 1021 | line-height: 1.75rem; 1022 | } 1023 | 1024 | .text-sm { 1025 | font-size: 0.875rem; 1026 | line-height: 1.25rem; 1027 | } 1028 | 1029 | .text-xl { 1030 | font-size: 1.25rem; 1031 | line-height: 1.75rem; 1032 | } 1033 | 1034 | .font-bold { 1035 | font-weight: 700; 1036 | } 1037 | 1038 | .font-medium { 1039 | font-weight: 500; 1040 | } 1041 | 1042 | .font-semibold { 1043 | font-weight: 600; 1044 | } 1045 | 1046 | .leading-6 { 1047 | line-height: 1.5rem; 1048 | } 1049 | 1050 | .leading-7 { 1051 | line-height: 1.75rem; 1052 | } 1053 | 1054 | .leading-8 { 1055 | line-height: 2rem; 1056 | } 1057 | 1058 | .tracking-tight { 1059 | letter-spacing: -0.025em; 1060 | } 1061 | 1062 | .text-gray-300 { 1063 | --tw-text-opacity: 1; 1064 | color: rgb(209 213 219 / var(--tw-text-opacity)); 1065 | } 1066 | 1067 | .text-gray-400 { 1068 | --tw-text-opacity: 1; 1069 | color: rgb(156 163 175 / var(--tw-text-opacity)); 1070 | } 1071 | 1072 | .text-gray-900 { 1073 | --tw-text-opacity: 1; 1074 | color: rgb(17 24 39 / var(--tw-text-opacity)); 1075 | } 1076 | 1077 | .text-white { 1078 | --tw-text-opacity: 1; 1079 | color: rgb(255 255 255 / var(--tw-text-opacity)); 1080 | } 1081 | 1082 | .text-white\/70 { 1083 | color: rgb(255 255 255 / 0.7); 1084 | } 1085 | 1086 | .shadow-sm { 1087 | --tw-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05); 1088 | --tw-shadow-colored: 0 1px 2px 0 var(--tw-shadow-color); 1089 | box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); 1090 | } 1091 | 1092 | .ring-1 { 1093 | --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); 1094 | --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color); 1095 | box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000); 1096 | } 1097 | 1098 | .ring-inset { 1099 | --tw-ring-inset: inset; 1100 | } 1101 | 1102 | .ring-gray-300 { 1103 | --tw-ring-opacity: 1; 1104 | --tw-ring-color: rgb(209 213 219 / var(--tw-ring-opacity)); 1105 | } 1106 | 1107 | html { 1108 | color-scheme: dark; 1109 | } 1110 | 1111 | input { 1112 | --tw-ring-shadow: 0 0 #000 !important; 1113 | } 1114 | 1115 | .placeholder\:text-gray-400::-moz-placeholder { 1116 | --tw-text-opacity: 1; 1117 | color: rgb(156 163 175 / var(--tw-text-opacity)); 1118 | } 1119 | 1120 | .placeholder\:text-gray-400::placeholder { 1121 | --tw-text-opacity: 1; 1122 | color: rgb(156 163 175 / var(--tw-text-opacity)); 1123 | } 1124 | 1125 | .hover\:border-teal-700:hover { 1126 | --tw-border-opacity: 1; 1127 | border-color: rgb(15 118 110 / var(--tw-border-opacity)); 1128 | } 1129 | 1130 | .hover\:bg-blue-400:hover { 1131 | --tw-bg-opacity: 1; 1132 | background-color: rgb(96 165 250 / var(--tw-bg-opacity)); 1133 | } 1134 | 1135 | .hover\:bg-teal-700:hover { 1136 | --tw-bg-opacity: 1; 1137 | background-color: rgb(15 118 110 / var(--tw-bg-opacity)); 1138 | } 1139 | 1140 | .focus\:ring-2:focus { 1141 | --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); 1142 | --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color); 1143 | box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000); 1144 | } 1145 | 1146 | .focus\:ring-inset:focus { 1147 | --tw-ring-inset: inset; 1148 | } 1149 | 1150 | .focus\:ring-indigo-600:focus { 1151 | --tw-ring-opacity: 1; 1152 | --tw-ring-color: rgb(79 70 229 / var(--tw-ring-opacity)); 1153 | } 1154 | 1155 | .focus-visible\:outline:focus-visible { 1156 | outline-style: solid; 1157 | } 1158 | 1159 | .focus-visible\:outline-2:focus-visible { 1160 | outline-width: 2px; 1161 | } 1162 | 1163 | .focus-visible\:outline-offset-2:focus-visible { 1164 | outline-offset: 2px; 1165 | } 1166 | 1167 | .focus-visible\:outline-indigo-500:focus-visible { 1168 | outline-color: #6366f1; 1169 | } 1170 | 1171 | @media (min-width: 640px) { 1172 | .sm\:-mx-6 { 1173 | margin-left: -1.5rem; 1174 | margin-right: -1.5rem; 1175 | } 1176 | 1177 | .sm\:mt-6 { 1178 | margin-top: 1.5rem; 1179 | } 1180 | 1181 | .sm\:flex { 1182 | display: flex; 1183 | } 1184 | 1185 | .sm\:flex-auto { 1186 | flex: 1 1 auto; 1187 | } 1188 | 1189 | .sm\:items-center { 1190 | align-items: center; 1191 | } 1192 | 1193 | .sm\:px-6 { 1194 | padding-left: 1.5rem; 1195 | padding-right: 1.5rem; 1196 | } 1197 | 1198 | .sm\:py-40 { 1199 | padding-top: 10rem; 1200 | padding-bottom: 10rem; 1201 | } 1202 | 1203 | .sm\:pl-0 { 1204 | padding-left: 0px; 1205 | } 1206 | 1207 | .sm\:pt-40 { 1208 | padding-top: 10rem; 1209 | } 1210 | 1211 | .sm\:text-5xl { 1212 | font-size: 3rem; 1213 | line-height: 1; 1214 | } 1215 | 1216 | .sm\:leading-6 { 1217 | line-height: 1.5rem; 1218 | } 1219 | } 1220 | 1221 | @media (min-width: 1024px) { 1222 | .lg\:-mx-8 { 1223 | margin-left: -2rem; 1224 | margin-right: -2rem; 1225 | } 1226 | 1227 | .lg\:px-8 { 1228 | padding-left: 2rem; 1229 | padding-right: 2rem; 1230 | } 1231 | } 1232 | --------------------------------------------------------------------------------