├── .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 |
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 |
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 |
22 |
23 |
24 |
{{ .URL }}
25 |
26 |
Copy
29 |
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 |
29 |
30 | {{ .Total }} messages left by other users!
31 |
32 | {{ if .Guests }}
33 |
34 |
35 |
36 |
37 |
38 |
39 | Message
40 | Timestamp
41 |
42 |
43 |
44 |
45 | {{ range .Guests }}
46 |
47 | {{ .Message }}
48 | {{ .CreatedAt.Format "02 Jan 06 15:04 MST" }}
49 |
50 | {{ end }}
51 |
52 |
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 |
--------------------------------------------------------------------------------