├── .air.toml ├── .env.example ├── .gitignore ├── .vscode └── settings.json ├── Makefile ├── README.md ├── cmd └── migrate │ ├── main.go │ └── migrations │ ├── 20240223100721_create-cars-table.down.sql │ ├── 20240223100721_create-cars-table.up.sql │ ├── 20240223122610_populate-cars-table.down.sql │ └── 20240223122610_populate-cars-table.up.sql ├── components ├── car_tile.templ └── cars_list.templ ├── config └── config.go ├── go.mod ├── go.sum ├── handlers ├── auth.go ├── cars.go ├── handler.go └── home.go ├── main.go ├── postcss.config.js ├── public └── styles.css ├── services └── auth │ ├── auth.go │ └── session.go ├── store ├── storage.go └── store.go ├── tailwind.config.js ├── types └── types.go └── views ├── cars.templ ├── css └── styles.css ├── home.templ ├── home_test.go ├── login.templ ├── page.templ └── test_utils.go /.air.toml: -------------------------------------------------------------------------------- 1 | root = "." 2 | tmp_dir = "bin" 3 | 4 | [build] 5 | bin = "./bin/main" 6 | cmd = "go build -o ./bin/main ." 7 | 8 | delay = 1000 9 | exclude_dir = ["bin", "cmd", "public"] 10 | exclude_file = [] 11 | exclude_regex = [] 12 | exclude_unchanged = false 13 | follow_symlink = false 14 | full_bin = "" 15 | include_dir = [] 16 | include_ext = ["go", "tpl", "tmpl", "templ", "html"] 17 | kill_delay = "0s" 18 | log = "build-errors.log" 19 | send_interrupt = false 20 | stop_on_error = true 21 | 22 | [color] 23 | app = "" 24 | build = "yellow" 25 | main = "magenta" 26 | runner = "green" 27 | watcher = "cyan" 28 | 29 | [log] 30 | time = false 31 | 32 | [misc] 33 | clean_on_exit = false -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # These are the mandatory envs that need to be set for the application to work. 2 | # When deploying to production please add the other envs under /config/config.go. 3 | export DISCORD_CLIENT_ID= 4 | export DISCORD_CLIENT_SECRET= 5 | export GITHUB_CLIENT_ID= 6 | export GITHUB_CLIENT_SECRET= -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *_templ.txt 2 | .envrc 3 | .env 4 | bin 5 | *_templ.go* 6 | tmp -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "templ" 4 | ] 5 | } -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | build: 2 | tailwindcss -i public/css/styles.css -o public/styles.css 3 | @templ generate view 4 | @go build -o bin/fullstackgo main.go 5 | 6 | test: 7 | @go test -v ./... 8 | 9 | run: build 10 | @./bin/fullstackgo 11 | 12 | tailwind: 13 | @tailwindcss -i views/css/styles.css -o public/styles.css --watch 14 | 15 | templ: 16 | @templ generate -watch 17 | 18 | migration: # add migration name at the end (ex: make migration create-cars-table) 19 | @migrate create -ext sql -dir cmd/migrate/migrations $(filter-out $@,$(MAKECMDGOALS)) 20 | 21 | migrate-up: 22 | @go run cmd/migrate/main.go up 23 | 24 | migrate-down: 25 | @go run cmd/migrate/main.go down 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # An example of Authentication with Go 2 | 3 | This projects shows how to build a minimal full stack web app using Go, HTMX, Templ and Tailwindcss **with Authentication (OAuth)**. 4 | 5 | Checkout the [Full Stack GO](https://github.com/sikozonpc/fullstack-go-htmx) project to learn more about the project structure. 6 | 7 | ## Structure 8 | 9 | All the HTML are stored in `*.templ` files in the `/views` and `/components` directories. 10 | The `/handlers` directory contains the Go handlers for the different routes that serve those Templ components. 11 | 12 | ## Installation 13 | 14 | There are a few tools that you need to install to run the project. 15 | So make sure you have the following tools installed on your machine. 16 | 17 | - [Templ (for the UI layer)](https://templ.guide/quick-start/installation) 18 | - [Tailwindcss CLI (CSS styling library)](https://tailwindcss.com/docs/installation) 19 | - [Migrate (for DB migrations)](https://github.com/golang-migrate/migrate/tree/v4.17.0/cmd/migrate) 20 | - [Air (for live reloading)](https://github.com/cosmtrek/air) 21 | 22 | Adittionally, it's recommended to install a syntax highlighting and templ LSP integration: 23 | [the official Templ documentation](https://templ.guide/quick-start/installation#editor-support). 24 | 25 | ## Running the project 26 | 27 | Firstly make sure you have a MySQL database running on your machine or just swap for any storage you like under `/store`. 28 | 29 | Don't forget to check the `.env.example` file and inject those environment variables into your environment, it's optimized for the cloud enviroment so it's recommended to inject them at runtime, for example using [direnv](https://direnv.net/). 30 | 31 | > If you want to inject them manually into a `.env`, install go-dotenv and adjust the `config/config.go` file to read the `.env` file instead. 32 | 33 | Then, for the best development experience, run the project in 3 different processes by running: 34 | 35 | ```bash 36 | air # for the go server live reloading 37 | make tailwind # for the tailwindcss live reloading 38 | make templ # for the templ files live generation and reloading 39 | ``` 40 | -------------------------------------------------------------------------------- /cmd/migrate/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "os" 6 | 7 | "github.com/go-sql-driver/mysql" 8 | "github.com/golang-migrate/migrate/v4" 9 | mysqlMigrate "github.com/golang-migrate/migrate/v4/database/mysql" 10 | _ "github.com/golang-migrate/migrate/v4/source/file" 11 | "github.com/sikozonpc/fullstackgo/config" 12 | "github.com/sikozonpc/fullstackgo/store" 13 | ) 14 | 15 | func main() { 16 | cfg := mysql.Config{ 17 | User: config.Envs.DBUser, 18 | Passwd: config.Envs.DBPassword, 19 | Addr: config.Envs.DBAddress, 20 | DBName: config.Envs.DBName, 21 | Net: "tcp", 22 | AllowNativePasswords: true, 23 | ParseTime: true, 24 | } 25 | 26 | db, err := store.NewMySQLStorage(cfg) 27 | if err != nil { 28 | log.Fatal(err) 29 | } 30 | 31 | driver, err := mysqlMigrate.WithInstance(db, &mysqlMigrate.Config{}) 32 | if err != nil { 33 | log.Fatal(err) 34 | } 35 | 36 | m, err := migrate.NewWithDatabaseInstance( 37 | "file://cmd/migrate/migrations", 38 | "mysql", 39 | driver, 40 | ) 41 | if err != nil { 42 | log.Fatal(err) 43 | } 44 | 45 | v, d, _ := m.Version() 46 | log.Printf("Version: %d, dirty: %v", v, d) 47 | 48 | cmd := os.Args[len(os.Args)-1] 49 | if cmd == "up" { 50 | if err := m.Up(); err != nil && err != migrate.ErrNoChange { 51 | log.Fatal(err) 52 | } 53 | } 54 | if cmd == "down" { 55 | if err := m.Down(); err != nil && err != migrate.ErrNoChange { 56 | log.Fatal(err) 57 | } 58 | } 59 | 60 | } 61 | -------------------------------------------------------------------------------- /cmd/migrate/migrations/20240223100721_create-cars-table.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS cars; -------------------------------------------------------------------------------- /cmd/migrate/migrations/20240223100721_create-cars-table.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS cars ( 2 | id INT UNSIGNED NOT NULL AUTO_INCREMENT, 3 | brand VARCHAR(255) NOT NULL, 4 | make VARCHAR(255) NOT NULL, 5 | model VARCHAR(255) NOT NULL, 6 | year INT UNSIGNED NOT NULL, 7 | imageURL VARCHAR(500) NOT NULL, 8 | createdAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 9 | 10 | PRIMARY KEY (id) 11 | ); -------------------------------------------------------------------------------- /cmd/migrate/migrations/20240223122610_populate-cars-table.down.sql: -------------------------------------------------------------------------------- 1 | DELETE FROM cars; -------------------------------------------------------------------------------- /cmd/migrate/migrations/20240223122610_populate-cars-table.up.sql: -------------------------------------------------------------------------------- 1 | INSERT INTO 2 | cars ( 3 | `brand`, 4 | `make`, 5 | `model`, 6 | `year`, 7 | `imageURL`, 8 | `createdAt` 9 | ) 10 | VALUES 11 | ( 12 | 'Toyota', 13 | 'Corolla', 14 | 'Cross 2.0', 15 | 2022, 16 | 'https://www.auto-data.net/images/f127/Toyota-Corolla-Cross_1.jpg', 17 | '2024-02-23 12:26:10' 18 | ), 19 | ( 20 | 'Toyota', 21 | 'Camry', 22 | 'VIII (XV70)', 23 | 2018, 24 | 'https://www.auto-data.net/images/f5/toyota-camry-viii_thumb.jpg', 25 | '2024-02-23 12:26:10' 26 | ), 27 | ( 28 | 'Mazda', 29 | '3', 30 | 'IV Hatchback', 31 | 2023, 32 | 'https://www.auto-data.net/images/f1/Mazda-3-IV-Hatchback_thumb.jpg', 33 | '2024-02-23 12:26:10' 34 | ), 35 | ( 36 | 'Toyota', 37 | 'Highlander IV', 38 | 'XLE', 39 | 2022, 40 | 'https://www.auto-data.net/images/f41/Toyota-Highlander-IV_thumb_1.jpg', 41 | '2024-02-23 12:26:10' 42 | ), 43 | ( 44 | 'Mazda', 45 | 'CX-5', 46 | 'Touring', 47 | 2022, 48 | 'https://www.auto-data.net/images/f14/mazda-cx-5-ii_thumb.jpg', 49 | '2024-02-23 12:26:10' 50 | ), 51 | ( 52 | 'Mazda', 53 | 'CX-9', 54 | 'Touring', 55 | 2022, 56 | 'https://www.auto-data.net/images/f20/thumb2938903.jpg', 57 | '2024-02-23 12:26:10' 58 | ), 59 | ( 60 | 'Mazda', 61 | 'Mazda6', 62 | 'Sedan Facelift', 63 | 2018, 64 | 'https://www.auto-data.net/images/f113/Mazda-6-III-Sedan-GJ-facelift-2018_thumb.jpg', 65 | '2024-02-23 12:26:10' 66 | ), 67 | ( 68 | 'Mazda', 69 | 'MX-5 Miata', 70 | 'NA', 71 | 1989, 72 | 'https://www.auto-data.net/images/f39/Mazda-MX-5-I-NA_thumb.jpg', 73 | '2024-02-23 12:26:10' 74 | ), 75 | ( 76 | 'Mazda', 77 | 'MX-5 Miata', 78 | 'ND RF', 79 | 2016, 80 | 'https://www.auto-data.net/images/f107/Mazda-MX-5-RF_thumb.jpg', 81 | '2024-02-23 12:26:10' 82 | ), 83 | ( 84 | 'Mazda', 85 | 'CX-30', 86 | 'Select', 87 | 2019, 88 | 'https://www.auto-data.net/images/f95/Mazda-CX-30_thumb.jpg', 89 | '2024-02-23 12:26:10' 90 | ), 91 | ( 92 | 'Mazda', 93 | 'CX-3', 94 | 'Touring', 95 | 2015, 96 | 'https://www.auto-data.net/images/f24/thumb4704989.jpg', 97 | '2024-02-23 12:26:10' 98 | ); -------------------------------------------------------------------------------- /components/car_tile.templ: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | import "github.com/sikozonpc/fullstackgo/types" 4 | import "fmt" 5 | 6 | templ CarTile(car *types.Car) { 7 |
  • 11 | { car.Brand }: { car.Model } { car.Make } from { car.Year } 12 | { 17 | 25 |
  • 26 | } 27 | -------------------------------------------------------------------------------- /components/cars_list.templ: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | import "github.com/sikozonpc/fullstackgo/types" 4 | 5 | templ CarsList(cars []types.Car) { 6 |
      7 | for _, car := range cars { 8 | @CarTile(&car) 9 | } 10 |
    11 | } 12 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strconv" 7 | ) 8 | 9 | type Config struct { 10 | PublicHost string 11 | Port string 12 | DBUser string 13 | DBPassword string 14 | DBAddress string 15 | DBName string 16 | CookiesAuthSecret string 17 | CookiesAuthAgeInSeconds int 18 | CookiesAuthIsSecure bool 19 | CookiesAuthIsHttpOnly bool 20 | DiscordClientID string 21 | DiscordClientSecret string 22 | GithubClientID string 23 | GithubClientSecret string 24 | } 25 | 26 | const ( 27 | twoDaysInSeconds = 60 * 60 * 24 * 2 28 | ) 29 | 30 | var Envs = initConfig() 31 | 32 | func initConfig() Config { 33 | return Config{ 34 | PublicHost: getEnv("PUBLIC_HOST", "http://localhost"), 35 | Port: getEnv("PORT", "8080"), 36 | DBUser: getEnv("DB_USER", "root"), 37 | DBPassword: getEnv("DB_PASSWORD", "mypassword"), 38 | DBAddress: fmt.Sprintf("%s:%s", getEnv("DB_HOST", "127.0.0.1"), getEnv("DB_PORT", "3306")), 39 | DBName: getEnv("DB_NAME", "cars"), 40 | CookiesAuthSecret: getEnv("COOKIES_AUTH_SECRET", "some-very-secret-key"), 41 | CookiesAuthAgeInSeconds: getEnvAsInt("COOKIES_AUTH_AGE_IN_SECONDS", twoDaysInSeconds), 42 | CookiesAuthIsSecure: getEnvAsBool("COOKIES_AUTH_IS_SECURE", false), 43 | CookiesAuthIsHttpOnly: getEnvAsBool("COOKIES_AUTH_IS_HTTP_ONLY", false), 44 | DiscordClientID: getEnvOrError("DISCORD_CLIENT_ID"), 45 | DiscordClientSecret: getEnvOrError("DISCORD_CLIENT_SECRET"), 46 | GithubClientID: getEnvOrError("GITHUB_CLIENT_ID"), 47 | GithubClientSecret: getEnvOrError("GITHUB_CLIENT_SECRET"), 48 | } 49 | } 50 | 51 | func getEnv(key, fallback string) string { 52 | if value, ok := os.LookupEnv(key); ok { 53 | return value 54 | } 55 | 56 | return fallback 57 | } 58 | 59 | func getEnvOrError(key string) string { 60 | if value, ok := os.LookupEnv(key); ok { 61 | return value 62 | } 63 | 64 | panic(fmt.Sprintf("Environment variable %s is not set", key)) 65 | 66 | } 67 | 68 | func getEnvAsInt(key string, fallback int) int { 69 | if value, ok := os.LookupEnv(key); ok { 70 | i, err := strconv.Atoi(value) 71 | if err != nil { 72 | return fallback 73 | } 74 | 75 | return i 76 | } 77 | 78 | return fallback 79 | } 80 | 81 | func getEnvAsBool(key string, fallback bool) bool { 82 | if value, ok := os.LookupEnv(key); ok { 83 | b, err := strconv.ParseBool(value) 84 | if err != nil { 85 | return fallback 86 | } 87 | 88 | return b 89 | } 90 | 91 | return fallback 92 | } 93 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/sikozonpc/fullstackgo 2 | 3 | go 1.21.7 4 | 5 | require ( 6 | github.com/a-h/templ v0.2.543 7 | github.com/go-sql-driver/mysql v1.7.1 8 | github.com/golang-migrate/migrate/v4 v4.17.0 9 | github.com/gorilla/mux v1.8.1 10 | ) 11 | 12 | require ( 13 | cloud.google.com/go/compute v1.23.3 // indirect 14 | cloud.google.com/go/compute/metadata v0.2.3 // indirect 15 | github.com/golang/protobuf v1.5.3 // indirect 16 | github.com/gorilla/context v1.1.1 // indirect 17 | github.com/gorilla/securecookie v1.1.1 // indirect 18 | github.com/gorilla/sessions v1.1.1 // indirect 19 | golang.org/x/oauth2 v0.17.0 // indirect 20 | google.golang.org/appengine v1.6.8 // indirect 21 | google.golang.org/protobuf v1.32.0 // indirect 22 | ) 23 | 24 | require ( 25 | github.com/hashicorp/errwrap v1.1.0 // indirect 26 | github.com/hashicorp/go-multierror v1.1.1 // indirect 27 | github.com/markbates/goth v1.79.0 28 | go.uber.org/atomic v1.10.0 // indirect 29 | ) 30 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | cloud.google.com/go v0.110.10 h1:LXy9GEO+timppncPIAZoOj3l58LIU9k+kn48AN7IO3Y= 2 | cloud.google.com/go/compute v1.23.3 h1:6sVlXXBmbd7jNX0Ipq0trII3e4n1/MsADLK6a+aiVlk= 3 | cloud.google.com/go/compute v1.23.3/go.mod h1:VCgBUoMnIVIR0CscqQiPJLAG25E3ZRZMzcFZeQ+h8CI= 4 | cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY= 5 | cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA= 6 | github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= 7 | github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= 8 | github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= 9 | github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= 10 | github.com/a-h/templ v0.2.543 h1:8YyLvyUtf0/IE2nIwZ62Z/m2o2NqwhnMynzOL78Lzbk= 11 | github.com/a-h/templ v0.2.543/go.mod h1:jP908DQCwI08IrnTalhzSEH9WJqG/Q94+EODQcJGFUA= 12 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 13 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 14 | github.com/dhui/dktest v0.4.0 h1:z05UmuXZHO/bgj/ds2bGMBu8FI4WA+Ag/m3ghL+om7M= 15 | github.com/dhui/dktest v0.4.0/go.mod h1:v/Dbz1LgCBOi2Uki2nUqLBGa83hWBGFMu5MrgMDCc78= 16 | github.com/docker/distribution v2.8.2+incompatible h1:T3de5rq0dB1j30rp0sA2rER+m322EBzniBPB6ZIzuh8= 17 | github.com/docker/distribution v2.8.2+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= 18 | github.com/docker/docker v24.0.7+incompatible h1:Wo6l37AuwP3JaMnZa226lzVXGA3F9Ig1seQen0cKYlM= 19 | github.com/docker/docker v24.0.7+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= 20 | github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ= 21 | github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec= 22 | github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= 23 | github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= 24 | github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI= 25 | github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= 26 | github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 27 | github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 28 | github.com/golang-migrate/migrate/v4 v4.17.0 h1:rd40H3QXU0AA4IoLllFcEAEo9dYKRHYND2gB4p7xcaU= 29 | github.com/golang-migrate/migrate/v4 v4.17.0/go.mod h1:+Cp2mtLP4/aXDTKb9wmXYitdrNx2HGs45rbWAo6OsKM= 30 | github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= 31 | github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= 32 | github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= 33 | github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= 34 | github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 35 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 36 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 37 | github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8= 38 | github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= 39 | github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= 40 | github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= 41 | github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= 42 | github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= 43 | github.com/gorilla/sessions v1.1.1 h1:YMDmfaK68mUixINzY/XjscuJ47uXFWSSHzFbBQM0PrE= 44 | github.com/gorilla/sessions v1.1.1/go.mod h1:8KCfur6+4Mqcc6S0FEfKuN15Vl5MgXW92AE8ovaJD0w= 45 | github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= 46 | github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= 47 | github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= 48 | github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= 49 | github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= 50 | github.com/markbates/goth v1.79.0 h1:fUYi9R6VubVEK2bpmXvIUp7xRcxA68i8ovfUQx/i5Qc= 51 | github.com/markbates/goth v1.79.0/go.mod h1:RBD+tcFnXul2NnYuODhnIweOcuVPkBohLfEvutPekcU= 52 | github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= 53 | github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= 54 | github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= 55 | github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= 56 | github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= 57 | github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= 58 | github.com/opencontainers/image-spec v1.0.2 h1:9yCKha/T5XdGtO0q9Q9a6T5NUCsTn/DrBg0D7ufOcFM= 59 | github.com/opencontainers/image-spec v1.0.2/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0= 60 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 61 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 62 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 63 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 64 | github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY= 65 | github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 66 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 67 | go.uber.org/atomic v1.10.0 h1:9qC72Qh0+3MqyJbAn8YU5xVq1frD8bn3JtD2oXtafVQ= 68 | go.uber.org/atomic v1.10.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= 69 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 70 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 71 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 72 | golang.org/x/mod v0.11.0 h1:bUO06HqtnRcc/7l71XBe4WcqTZ+3AH1J59zWDDwLKgU= 73 | golang.org/x/mod v0.11.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 74 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 75 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 76 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 77 | golang.org/x/net v0.18.0 h1:mIYleuAkSbHh0tCv7RvjL3F6ZVbLjq4+R7zbOn3Kokg= 78 | golang.org/x/net v0.18.0/go.mod h1:/czyP5RqHAH4odGYxBJ1qz0+CE5WZ+2j1YgoEo8F2jQ= 79 | golang.org/x/oauth2 v0.17.0 h1:6m3ZPmLEFdVxKKWnKq4VqZ60gutO35zm+zrAHVmHyDQ= 80 | golang.org/x/oauth2 v0.17.0/go.mod h1:OzPDGQiuQMguemayvdylqddI7qcD9lnSDb+1FiwQ5HA= 81 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 82 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 83 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 84 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 85 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 86 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 87 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 88 | golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= 89 | golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 90 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 91 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 92 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 93 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 94 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 95 | golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= 96 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 97 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 98 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 99 | golang.org/x/tools v0.10.0 h1:tvDr/iQoUqNdohiYm0LmmKcBk+q86lb9EprIUFhHHGg= 100 | golang.org/x/tools v0.10.0/go.mod h1:UJwyiVBsOA2uwvK/e5OY3GTpDUJriEd+/YlqAwLPmyM= 101 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 102 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 103 | google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= 104 | google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= 105 | google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= 106 | google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= 107 | google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I= 108 | google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= 109 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 110 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 111 | -------------------------------------------------------------------------------- /handlers/auth.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net/http" 7 | 8 | "github.com/markbates/goth/gothic" 9 | "github.com/sikozonpc/fullstackgo/views" 10 | ) 11 | 12 | 13 | func (h *Handler) HandleLogin(w http.ResponseWriter, r *http.Request) { 14 | views.Login().Render(r.Context(), w) 15 | } 16 | 17 | func (h *Handler) HandleProviderLogin(w http.ResponseWriter, r *http.Request) { 18 | // try to get the user without re-authenticating 19 | if u, err := gothic.CompleteUserAuth(w, r); err == nil { 20 | log.Printf("User already authenticated! %v", u) 21 | 22 | views.Login().Render(r.Context(), w) 23 | } else { 24 | gothic.BeginAuthHandler(w, r) 25 | } 26 | } 27 | 28 | func (h *Handler) HandleAuthCallbackFunction(w http.ResponseWriter, r *http.Request) { 29 | user, err := gothic.CompleteUserAuth(w, r) 30 | if err != nil { 31 | fmt.Fprintln(w, err) 32 | return 33 | } 34 | 35 | err = h.auth.StoreUserSession(w, r, user) 36 | if err != nil { 37 | log.Println(err) 38 | return 39 | } 40 | 41 | w.Header().Set("Location", "/") 42 | w.WriteHeader(http.StatusTemporaryRedirect) 43 | } 44 | 45 | func (h *Handler) HandleLogout(w http.ResponseWriter, r *http.Request) { 46 | log.Println("Logging out...") 47 | 48 | err := gothic.Logout(w, r) 49 | if err != nil { 50 | log.Println(err) 51 | return 52 | } 53 | 54 | h.auth.RemoveUserSession(w, r) 55 | 56 | w.Header().Set("Location", "/") 57 | w.WriteHeader(http.StatusTemporaryRedirect) 58 | } 59 | -------------------------------------------------------------------------------- /handlers/cars.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | 7 | "github.com/gorilla/mux" 8 | "github.com/sikozonpc/fullstackgo/components" 9 | "github.com/sikozonpc/fullstackgo/types" 10 | "github.com/sikozonpc/fullstackgo/views" 11 | ) 12 | 13 | func (h *Handler) HandleListCars(w http.ResponseWriter, r *http.Request) { 14 | isAddingCar := r.URL.Query().Get("isAddingCar") == "true" 15 | 16 | cars, err := h.store.GetCars() 17 | if err != nil { 18 | log.Println(err) 19 | return 20 | } 21 | 22 | user, err := h.auth.GetSessionUser(r) 23 | if err != nil { 24 | log.Println(err) 25 | return 26 | } 27 | 28 | views.Cars(cars, isAddingCar, user).Render(r.Context(), w) 29 | } 30 | 31 | func (h *Handler) HandleAddCar(w http.ResponseWriter, r *http.Request) { 32 | car := &types.Car{ 33 | Brand: r.FormValue("brand"), 34 | Make: r.FormValue("model"), 35 | Model: r.FormValue("make"), 36 | Year: r.FormValue("year"), 37 | ImageURL: r.FormValue("imageURL"), 38 | } 39 | 40 | newCar, err := h.store.CreateCar(car) 41 | if err != nil { 42 | log.Println(err) 43 | return 44 | } 45 | 46 | components.CarTile(newCar).Render(r.Context(), w) 47 | } 48 | 49 | func (h *Handler) HandleSearchCar(w http.ResponseWriter, r *http.Request) { 50 | text := r.FormValue("search") 51 | 52 | cars, err := h.store.FindCarsByNameMakeOrBrand(text) 53 | if err != nil { 54 | log.Println(err) 55 | return 56 | } 57 | 58 | components.CarsList(cars).Render(r.Context(), w) 59 | } 60 | 61 | func (h *Handler) HandleDeleteCar(w http.ResponseWriter, r *http.Request) { 62 | vars := mux.Vars(r) 63 | id := vars["id"] 64 | 65 | err := h.store.DeleteCar(id) 66 | if err != nil { 67 | log.Println(err) 68 | return 69 | } 70 | 71 | w.WriteHeader(http.StatusOK) 72 | } 73 | -------------------------------------------------------------------------------- /handlers/handler.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "github.com/sikozonpc/fullstackgo/services/auth" 5 | "github.com/sikozonpc/fullstackgo/store" 6 | ) 7 | 8 | type Handler struct { 9 | store *store.Storage 10 | auth *auth.AuthService 11 | } 12 | 13 | func New(store *store.Storage, auth *auth.AuthService) *Handler { 14 | return &Handler{ 15 | store: store, 16 | auth: auth, 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /handlers/home.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | 7 | "github.com/sikozonpc/fullstackgo/views" 8 | ) 9 | 10 | func (h *Handler) HandleHome(w http.ResponseWriter, r *http.Request) { 11 | user, err := h.auth.GetSessionUser(r) 12 | if err != nil { 13 | log.Println(err) 14 | return 15 | } 16 | 17 | views.Home(user).Render(r.Context(), w) 18 | } 19 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "log" 7 | "net/http" 8 | 9 | "github.com/go-sql-driver/mysql" 10 | "github.com/gorilla/mux" 11 | "github.com/sikozonpc/fullstackgo/config" 12 | "github.com/sikozonpc/fullstackgo/handlers" 13 | "github.com/sikozonpc/fullstackgo/services/auth" 14 | "github.com/sikozonpc/fullstackgo/store" 15 | ) 16 | 17 | func main() { 18 | cfg := mysql.Config{ 19 | User: config.Envs.DBUser, 20 | Passwd: config.Envs.DBPassword, 21 | Addr: config.Envs.DBAddress, 22 | DBName: config.Envs.DBName, 23 | Net: "tcp", 24 | AllowNativePasswords: true, 25 | ParseTime: true, 26 | } 27 | 28 | db, err := store.NewMySQLStorage(cfg) 29 | if err != nil { 30 | log.Fatal(err) 31 | } 32 | 33 | s := store.NewStore(db) 34 | 35 | initStorage(db) 36 | 37 | sessionStore := auth.NewCookieStore(auth.SessionOptions{ 38 | CookiesKey: config.Envs.CookiesAuthSecret, 39 | MaxAge: config.Envs.CookiesAuthAgeInSeconds, 40 | Secure: config.Envs.CookiesAuthIsSecure, 41 | HttpOnly: config.Envs.CookiesAuthIsHttpOnly, 42 | }) 43 | authService := auth.NewAuthService(sessionStore) 44 | 45 | router := mux.NewRouter() 46 | 47 | handler := handlers.New(s, authService) 48 | 49 | // Cars 50 | router.HandleFunc("/", auth.RequireAuth(handler.HandleHome, authService)).Methods("GET") 51 | router.HandleFunc("/cars", auth.RequireAuth(handler.HandleListCars, authService)).Methods("GET") 52 | router.HandleFunc("/cars", auth.RequireAuth(handler.HandleAddCar, authService)).Methods("POST") 53 | router.HandleFunc("/cars/{id}", auth.RequireAuth(handler.HandleDeleteCar, authService)).Methods("DELETE") 54 | router.HandleFunc("/cars/search", auth.RequireAuth(handler.HandleSearchCar, authService)).Methods("GET") 55 | 56 | // Auth 57 | router.HandleFunc("/auth/{provider}", handler.HandleProviderLogin).Methods("GET") 58 | router.HandleFunc("/auth/{provider}/callback", handler.HandleAuthCallbackFunction).Methods("GET") 59 | router.HandleFunc("/auth/logout/{provider}", handler.HandleLogout).Methods("GET") 60 | router.HandleFunc("/login", handler.HandleLogin).Methods("GET") 61 | 62 | // Static Files 63 | router.PathPrefix("/public/").Handler(http.StripPrefix("/public/", http.FileServer(http.Dir("public")))) 64 | 65 | log.Printf("Server: Listening on %s:%s\n", config.Envs.PublicHost, config.Envs.Port) 66 | log.Fatalln(http.ListenAndServe(fmt.Sprintf(":%s", config.Envs.Port), router)) 67 | } 68 | 69 | func initStorage(db *sql.DB) { 70 | err := db.Ping() 71 | if err != nil { 72 | log.Fatal(err) 73 | } 74 | 75 | log.Println("DB: Successfully connected!") 76 | } 77 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /public/styles.css: -------------------------------------------------------------------------------- 1 | /* 2 | ! tailwindcss v3.4.1 | 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 | color: inherit; 215 | /* 1 */ 216 | margin: 0; 217 | /* 2 */ 218 | padding: 0; 219 | /* 3 */ 220 | } 221 | 222 | /* 223 | Remove the inheritance of text transform in Edge and Firefox. 224 | */ 225 | 226 | button, 227 | select { 228 | text-transform: none; 229 | } 230 | 231 | /* 232 | 1. Correct the inability to style clickable types in iOS and Safari. 233 | 2. Remove default button styles. 234 | */ 235 | 236 | button, 237 | [type='button'], 238 | [type='reset'], 239 | [type='submit'] { 240 | -webkit-appearance: button; 241 | /* 1 */ 242 | background-color: transparent; 243 | /* 2 */ 244 | background-image: none; 245 | /* 2 */ 246 | } 247 | 248 | /* 249 | Use the modern Firefox focus style for all focusable elements. 250 | */ 251 | 252 | :-moz-focusring { 253 | outline: auto; 254 | } 255 | 256 | /* 257 | Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737) 258 | */ 259 | 260 | :-moz-ui-invalid { 261 | box-shadow: none; 262 | } 263 | 264 | /* 265 | Add the correct vertical alignment in Chrome and Firefox. 266 | */ 267 | 268 | progress { 269 | vertical-align: baseline; 270 | } 271 | 272 | /* 273 | Correct the cursor style of increment and decrement buttons in Safari. 274 | */ 275 | 276 | ::-webkit-inner-spin-button, 277 | ::-webkit-outer-spin-button { 278 | height: auto; 279 | } 280 | 281 | /* 282 | 1. Correct the odd appearance in Chrome and Safari. 283 | 2. Correct the outline style in Safari. 284 | */ 285 | 286 | [type='search'] { 287 | -webkit-appearance: textfield; 288 | /* 1 */ 289 | outline-offset: -2px; 290 | /* 2 */ 291 | } 292 | 293 | /* 294 | Remove the inner padding in Chrome and Safari on macOS. 295 | */ 296 | 297 | ::-webkit-search-decoration { 298 | -webkit-appearance: none; 299 | } 300 | 301 | /* 302 | 1. Correct the inability to style clickable types in iOS and Safari. 303 | 2. Change font properties to `inherit` in Safari. 304 | */ 305 | 306 | ::-webkit-file-upload-button { 307 | -webkit-appearance: button; 308 | /* 1 */ 309 | font: inherit; 310 | /* 2 */ 311 | } 312 | 313 | /* 314 | Add the correct display in Chrome and Safari. 315 | */ 316 | 317 | summary { 318 | display: list-item; 319 | } 320 | 321 | /* 322 | Removes the default spacing and border for appropriate elements. 323 | */ 324 | 325 | blockquote, 326 | dl, 327 | dd, 328 | h1, 329 | h2, 330 | h3, 331 | h4, 332 | h5, 333 | h6, 334 | hr, 335 | figure, 336 | p, 337 | pre { 338 | margin: 0; 339 | } 340 | 341 | fieldset { 342 | margin: 0; 343 | padding: 0; 344 | } 345 | 346 | legend { 347 | padding: 0; 348 | } 349 | 350 | ol, 351 | ul, 352 | menu { 353 | list-style: none; 354 | margin: 0; 355 | padding: 0; 356 | } 357 | 358 | /* 359 | Reset default styling for dialogs. 360 | */ 361 | 362 | dialog { 363 | padding: 0; 364 | } 365 | 366 | /* 367 | Prevent resizing textareas horizontally by default. 368 | */ 369 | 370 | textarea { 371 | resize: vertical; 372 | } 373 | 374 | /* 375 | 1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300) 376 | 2. Set the default placeholder color to the user's configured gray 400 color. 377 | */ 378 | 379 | input::-moz-placeholder, textarea::-moz-placeholder { 380 | opacity: 1; 381 | /* 1 */ 382 | color: #9ca3af; 383 | /* 2 */ 384 | } 385 | 386 | input::placeholder, 387 | textarea::placeholder { 388 | opacity: 1; 389 | /* 1 */ 390 | color: #9ca3af; 391 | /* 2 */ 392 | } 393 | 394 | /* 395 | Set the default cursor for buttons. 396 | */ 397 | 398 | button, 399 | [role="button"] { 400 | cursor: pointer; 401 | } 402 | 403 | /* 404 | Make sure disabled buttons don't get the pointer cursor. 405 | */ 406 | 407 | :disabled { 408 | cursor: default; 409 | } 410 | 411 | /* 412 | 1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14) 413 | 2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210) 414 | This can trigger a poorly considered lint error in some tools but is included by design. 415 | */ 416 | 417 | img, 418 | svg, 419 | video, 420 | canvas, 421 | audio, 422 | iframe, 423 | embed, 424 | object { 425 | display: block; 426 | /* 1 */ 427 | vertical-align: middle; 428 | /* 2 */ 429 | } 430 | 431 | /* 432 | Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14) 433 | */ 434 | 435 | img, 436 | video { 437 | max-width: 100%; 438 | height: auto; 439 | } 440 | 441 | /* Make elements with the HTML hidden attribute stay hidden by default */ 442 | 443 | [hidden] { 444 | display: none; 445 | } 446 | 447 | *, ::before, ::after { 448 | --tw-border-spacing-x: 0; 449 | --tw-border-spacing-y: 0; 450 | --tw-translate-x: 0; 451 | --tw-translate-y: 0; 452 | --tw-rotate: 0; 453 | --tw-skew-x: 0; 454 | --tw-skew-y: 0; 455 | --tw-scale-x: 1; 456 | --tw-scale-y: 1; 457 | --tw-pan-x: ; 458 | --tw-pan-y: ; 459 | --tw-pinch-zoom: ; 460 | --tw-scroll-snap-strictness: proximity; 461 | --tw-gradient-from-position: ; 462 | --tw-gradient-via-position: ; 463 | --tw-gradient-to-position: ; 464 | --tw-ordinal: ; 465 | --tw-slashed-zero: ; 466 | --tw-numeric-figure: ; 467 | --tw-numeric-spacing: ; 468 | --tw-numeric-fraction: ; 469 | --tw-ring-inset: ; 470 | --tw-ring-offset-width: 0px; 471 | --tw-ring-offset-color: #fff; 472 | --tw-ring-color: rgb(59 130 246 / 0.5); 473 | --tw-ring-offset-shadow: 0 0 #0000; 474 | --tw-ring-shadow: 0 0 #0000; 475 | --tw-shadow: 0 0 #0000; 476 | --tw-shadow-colored: 0 0 #0000; 477 | --tw-blur: ; 478 | --tw-brightness: ; 479 | --tw-contrast: ; 480 | --tw-grayscale: ; 481 | --tw-hue-rotate: ; 482 | --tw-invert: ; 483 | --tw-saturate: ; 484 | --tw-sepia: ; 485 | --tw-drop-shadow: ; 486 | --tw-backdrop-blur: ; 487 | --tw-backdrop-brightness: ; 488 | --tw-backdrop-contrast: ; 489 | --tw-backdrop-grayscale: ; 490 | --tw-backdrop-hue-rotate: ; 491 | --tw-backdrop-invert: ; 492 | --tw-backdrop-opacity: ; 493 | --tw-backdrop-saturate: ; 494 | --tw-backdrop-sepia: ; 495 | } 496 | 497 | ::backdrop { 498 | --tw-border-spacing-x: 0; 499 | --tw-border-spacing-y: 0; 500 | --tw-translate-x: 0; 501 | --tw-translate-y: 0; 502 | --tw-rotate: 0; 503 | --tw-skew-x: 0; 504 | --tw-skew-y: 0; 505 | --tw-scale-x: 1; 506 | --tw-scale-y: 1; 507 | --tw-pan-x: ; 508 | --tw-pan-y: ; 509 | --tw-pinch-zoom: ; 510 | --tw-scroll-snap-strictness: proximity; 511 | --tw-gradient-from-position: ; 512 | --tw-gradient-via-position: ; 513 | --tw-gradient-to-position: ; 514 | --tw-ordinal: ; 515 | --tw-slashed-zero: ; 516 | --tw-numeric-figure: ; 517 | --tw-numeric-spacing: ; 518 | --tw-numeric-fraction: ; 519 | --tw-ring-inset: ; 520 | --tw-ring-offset-width: 0px; 521 | --tw-ring-offset-color: #fff; 522 | --tw-ring-color: rgb(59 130 246 / 0.5); 523 | --tw-ring-offset-shadow: 0 0 #0000; 524 | --tw-ring-shadow: 0 0 #0000; 525 | --tw-shadow: 0 0 #0000; 526 | --tw-shadow-colored: 0 0 #0000; 527 | --tw-blur: ; 528 | --tw-brightness: ; 529 | --tw-contrast: ; 530 | --tw-grayscale: ; 531 | --tw-hue-rotate: ; 532 | --tw-invert: ; 533 | --tw-saturate: ; 534 | --tw-sepia: ; 535 | --tw-drop-shadow: ; 536 | --tw-backdrop-blur: ; 537 | --tw-backdrop-brightness: ; 538 | --tw-backdrop-contrast: ; 539 | --tw-backdrop-grayscale: ; 540 | --tw-backdrop-hue-rotate: ; 541 | --tw-backdrop-invert: ; 542 | --tw-backdrop-opacity: ; 543 | --tw-backdrop-saturate: ; 544 | --tw-backdrop-sepia: ; 545 | } 546 | 547 | .m-4 { 548 | margin: 1rem; 549 | } 550 | 551 | .m-auto { 552 | margin: auto; 553 | } 554 | 555 | .mx-2 { 556 | margin-left: 0.5rem; 557 | margin-right: 0.5rem; 558 | } 559 | 560 | .my-4 { 561 | margin-top: 1rem; 562 | margin-bottom: 1rem; 563 | } 564 | 565 | .my-8 { 566 | margin-top: 2rem; 567 | margin-bottom: 2rem; 568 | } 569 | 570 | .mb-4 { 571 | margin-bottom: 1rem; 572 | } 573 | 574 | .ml-2 { 575 | margin-left: 0.5rem; 576 | } 577 | 578 | .ml-6 { 579 | margin-left: 1.5rem; 580 | } 581 | 582 | .ml-auto { 583 | margin-left: auto; 584 | } 585 | 586 | .flex { 587 | display: flex; 588 | } 589 | 590 | .h-32 { 591 | height: 8rem; 592 | } 593 | 594 | .h-8 { 595 | height: 2rem; 596 | } 597 | 598 | .w-1\/4 { 599 | width: 25%; 600 | } 601 | 602 | .w-2\/3 { 603 | width: 66.666667%; 604 | } 605 | 606 | .w-8 { 607 | width: 2rem; 608 | } 609 | 610 | .w-full { 611 | width: 100%; 612 | } 613 | 614 | .flex-col { 615 | flex-direction: column; 616 | } 617 | 618 | .flex-wrap { 619 | flex-wrap: wrap; 620 | } 621 | 622 | .content-center { 623 | align-content: center; 624 | } 625 | 626 | .items-center { 627 | align-items: center; 628 | } 629 | 630 | .justify-center { 631 | justify-content: center; 632 | } 633 | 634 | .justify-between { 635 | justify-content: space-between; 636 | } 637 | 638 | .justify-around { 639 | justify-content: space-around; 640 | } 641 | 642 | .rounded { 643 | border-radius: 0.25rem; 644 | } 645 | 646 | .rounded-full { 647 | border-radius: 9999px; 648 | } 649 | 650 | .rounded-md { 651 | border-radius: 0.375rem; 652 | } 653 | 654 | .border-gray-300 { 655 | --tw-border-opacity: 1; 656 | border-color: rgb(209 213 219 / var(--tw-border-opacity)); 657 | } 658 | 659 | .bg-blue-500 { 660 | --tw-bg-opacity: 1; 661 | background-color: rgb(59 130 246 / var(--tw-bg-opacity)); 662 | } 663 | 664 | .bg-gray-200 { 665 | --tw-bg-opacity: 1; 666 | background-color: rgb(229 231 235 / var(--tw-bg-opacity)); 667 | } 668 | 669 | .bg-gray-800 { 670 | --tw-bg-opacity: 1; 671 | background-color: rgb(31 41 55 / var(--tw-bg-opacity)); 672 | } 673 | 674 | .object-cover { 675 | -o-object-fit: cover; 676 | object-fit: cover; 677 | } 678 | 679 | .p-4 { 680 | padding: 1rem; 681 | } 682 | 683 | .px-2 { 684 | padding-left: 0.5rem; 685 | padding-right: 0.5rem; 686 | } 687 | 688 | .px-4 { 689 | padding-left: 1rem; 690 | padding-right: 1rem; 691 | } 692 | 693 | .py-2 { 694 | padding-top: 0.5rem; 695 | padding-bottom: 0.5rem; 696 | } 697 | 698 | .text-center { 699 | text-align: center; 700 | } 701 | 702 | .text-3xl { 703 | font-size: 1.875rem; 704 | line-height: 2.25rem; 705 | } 706 | 707 | .text-sm { 708 | font-size: 0.875rem; 709 | line-height: 1.25rem; 710 | } 711 | 712 | .text-xl { 713 | font-size: 1.25rem; 714 | line-height: 1.75rem; 715 | } 716 | 717 | .font-bold { 718 | font-weight: 700; 719 | } 720 | 721 | .text-blue-300 { 722 | --tw-text-opacity: 1; 723 | color: rgb(147 197 253 / var(--tw-text-opacity)); 724 | } 725 | 726 | .text-gray-900 { 727 | --tw-text-opacity: 1; 728 | color: rgb(17 24 39 / var(--tw-text-opacity)); 729 | } 730 | 731 | .text-red-400 { 732 | --tw-text-opacity: 1; 733 | color: rgb(248 113 113 / var(--tw-text-opacity)); 734 | } 735 | 736 | .text-red-500 { 737 | --tw-text-opacity: 1; 738 | color: rgb(239 68 68 / var(--tw-text-opacity)); 739 | } 740 | 741 | .text-white { 742 | --tw-text-opacity: 1; 743 | color: rgb(255 255 255 / var(--tw-text-opacity)); 744 | } 745 | 746 | .shadow-md { 747 | --tw-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); 748 | --tw-shadow-colored: 0 4px 6px -1px var(--tw-shadow-color), 0 2px 4px -2px var(--tw-shadow-color); 749 | box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); 750 | } 751 | 752 | .hover\:bg-blue-700:hover { 753 | --tw-bg-opacity: 1; 754 | background-color: rgb(29 78 216 / var(--tw-bg-opacity)); 755 | } -------------------------------------------------------------------------------- /services/auth/auth.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net/http" 7 | 8 | "github.com/gorilla/sessions" 9 | "github.com/markbates/goth" 10 | "github.com/markbates/goth/gothic" 11 | "github.com/markbates/goth/providers/discord" 12 | "github.com/markbates/goth/providers/github" 13 | "github.com/sikozonpc/fullstackgo/config" 14 | ) 15 | 16 | type AuthService struct{} 17 | 18 | func NewAuthService(store sessions.Store) *AuthService { 19 | gothic.Store = store 20 | 21 | goth.UseProviders( 22 | github.New( 23 | config.Envs.GithubClientID, 24 | config.Envs.GithubClientID, 25 | buildCallbackURL("github"), 26 | ), 27 | discord.New( 28 | config.Envs.DiscordClientID, 29 | config.Envs.DiscordClientSecret, 30 | buildCallbackURL("discord"), 31 | ), 32 | ) 33 | 34 | return &AuthService{} 35 | } 36 | 37 | func (s *AuthService) GetSessionUser(r *http.Request) (goth.User, error) { 38 | session, err := gothic.Store.Get(r, SessionName) 39 | if err != nil { 40 | return goth.User{}, err 41 | } 42 | 43 | u := session.Values["user"] 44 | if u == nil { 45 | return goth.User{}, fmt.Errorf("user is not authenticated! %v", u) 46 | } 47 | 48 | return u.(goth.User), nil 49 | } 50 | 51 | func (s *AuthService) StoreUserSession(w http.ResponseWriter, r *http.Request, user goth.User) error { 52 | // Get a session. We're ignoring the error resulted from decoding an 53 | // existing session: Get() always returns a session, even if empty. 54 | session, _ := gothic.Store.Get(r, SessionName) 55 | 56 | session.Values["user"] = user 57 | 58 | err := session.Save(r, w) 59 | if err != nil { 60 | http.Error(w, err.Error(), http.StatusInternalServerError) 61 | return err 62 | } 63 | 64 | return nil 65 | } 66 | 67 | func (s *AuthService) RemoveUserSession(w http.ResponseWriter, r *http.Request) { 68 | session, err := gothic.Store.Get(r, SessionName) 69 | if err != nil { 70 | log.Println(err) 71 | http.Error(w, err.Error(), http.StatusInternalServerError) 72 | return 73 | } 74 | 75 | session.Values["user"] = goth.User{} 76 | // delete the cookie immediately 77 | session.Options.MaxAge = -1 78 | 79 | session.Save(r, w) 80 | } 81 | 82 | func RequireAuth(handlerFunc http.HandlerFunc, auth *AuthService) http.HandlerFunc { 83 | return func(w http.ResponseWriter, r *http.Request) { 84 | session, err := auth.GetSessionUser(r) 85 | if err != nil { 86 | log.Println("User is not authenticated!") 87 | http.Redirect(w, r, "/login", http.StatusTemporaryRedirect) 88 | return 89 | } 90 | 91 | log.Printf("user is authenticated! user: %v!", session.FirstName) 92 | 93 | handlerFunc(w, r) 94 | } 95 | } 96 | 97 | func buildCallbackURL(provider string) string { 98 | return fmt.Sprintf("%s:%s/auth/%s/callback", config.Envs.PublicHost, config.Envs.Port, provider) 99 | } 100 | -------------------------------------------------------------------------------- /services/auth/session.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import "github.com/gorilla/sessions" 4 | 5 | const ( 6 | SessionName = "session" 7 | ) 8 | 9 | type SessionOptions struct { 10 | CookiesKey string 11 | MaxAge int 12 | HttpOnly bool // Should be true if the site is served over HTTP (development environment) 13 | Secure bool // Should be true if the site is served over HTTPS (production environment) 14 | } 15 | 16 | func NewCookieStore(opts SessionOptions) *sessions.CookieStore { 17 | store := sessions.NewCookieStore([]byte(opts.CookiesKey)) 18 | 19 | store.MaxAge(opts.MaxAge) 20 | store.Options.Path = "/" 21 | store.Options.HttpOnly = opts.HttpOnly 22 | store.Options.Secure = opts.Secure 23 | 24 | return store 25 | } 26 | -------------------------------------------------------------------------------- /store/storage.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "database/sql" 5 | "log" 6 | 7 | "github.com/go-sql-driver/mysql" 8 | ) 9 | 10 | 11 | func NewMySQLStorage(cfg mysql.Config) (*sql.DB, error) { 12 | db, err := sql.Open("mysql", cfg.FormatDSN()) 13 | if err != nil { 14 | log.Fatal(err) 15 | } 16 | 17 | return db, nil 18 | } 19 | -------------------------------------------------------------------------------- /store/store.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "database/sql" 5 | 6 | "github.com/sikozonpc/fullstackgo/types" 7 | ) 8 | 9 | type Storage struct { 10 | db *sql.DB 11 | } 12 | 13 | type Store interface { 14 | CreateCar(car *types.Car) (*types.Car, error) 15 | GetCars() ([]types.Car, error) 16 | DeleteCar(id string) error 17 | FindCarsByNameMakeOrBrand(search string) ([]types.Car, error) 18 | } 19 | 20 | func NewStore(db *sql.DB) *Storage { 21 | return &Storage{ 22 | db: db, 23 | } 24 | } 25 | 26 | func (s *Storage) DeleteCar(id string) error { 27 | _, err := s.db.Exec("DELETE FROM cars WHERE id = ?", id) 28 | return err 29 | } 30 | 31 | func (s *Storage) CreateCar(c *types.Car) (*types.Car, error) { 32 | row, err := s.db.Exec("INSERT INTO cars (brand, make, model, year, imageURL) VALUES (?, ?, ?, ?, ?)", c.Brand, c.Make, c.Model, c.Year, c.ImageURL) 33 | if err != nil { 34 | return nil, err 35 | } 36 | 37 | id, err := row.LastInsertId() 38 | if err != nil { 39 | return nil, err 40 | } 41 | c.ID = int(id) 42 | 43 | return c, nil 44 | } 45 | 46 | func (s *Storage) GetCars() ([]types.Car, error) { 47 | rows, err := s.db.Query("SELECT * FROM cars") 48 | if err != nil { 49 | return nil, err 50 | } 51 | defer rows.Close() 52 | 53 | var cars []types.Car 54 | for rows.Next() { 55 | car, err := scanCar(rows) 56 | if err != nil { 57 | return nil, err 58 | } 59 | cars = append(cars, car) 60 | } 61 | 62 | return cars, nil 63 | } 64 | 65 | func (s *Storage) FindCarsByNameMakeOrBrand(search string) ([]types.Car, error) { 66 | rows, err := s.db.Query("SELECT * FROM cars WHERE brand LIKE ? OR model LIKE ? OR make LIKE ?", "%"+search+"%", "%"+search+"%", "%"+search+"%") 67 | if err != nil { 68 | return nil, err 69 | } 70 | defer rows.Close() 71 | 72 | var cars []types.Car 73 | for rows.Next() { 74 | car, err := scanCar(rows) 75 | if err != nil { 76 | return nil, err 77 | } 78 | cars = append(cars, car) 79 | } 80 | 81 | return cars, nil 82 | } 83 | 84 | func scanCar(row *sql.Rows) (types.Car, error) { 85 | var car types.Car 86 | err := row.Scan(&car.ID, &car.Brand, &car.Make, &car.Model, &car.Year, &car.ImageURL, &car.CreatedAt) 87 | return car, err 88 | } 89 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: ["./views/**/*.templ}", "./**/*.templ"], 4 | theme: { 5 | extend: {}, 6 | }, 7 | plugins: [], 8 | } 9 | 10 | -------------------------------------------------------------------------------- /types/types.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | type Car struct { 4 | ID int `json:"id"` 5 | Brand string `json:"brand"` 6 | Make string `json:"make"` 7 | Model string `json:"model"` 8 | Year string `json:"year"` 9 | ImageURL string `json:"imageURL"` 10 | CreatedAt string `json:"createdAt"` 11 | } 12 | -------------------------------------------------------------------------------- /views/cars.templ: -------------------------------------------------------------------------------- 1 | package views 2 | 3 | import "github.com/sikozonpc/fullstackgo/types" 4 | import "github.com/sikozonpc/fullstackgo/components" 5 | import "strconv" 6 | import "github.com/markbates/goth" 7 | 8 | templ Cars(cars []types.Car, isAddingCar bool, user goth.User) { 9 | @Page(true, user) { 10 |
    11 |

    12 | Cars List 13 |

    14 |
    15 | if isAddingCar { 16 |
    21 | 27 | 33 | 39 | 45 | 51 | 56 |
    57 | } else { 58 | 70 | } 71 | 85 |
    86 | @components.CarsList(cars) 87 |
    88 | } 89 | } 90 | -------------------------------------------------------------------------------- /views/css/styles.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; -------------------------------------------------------------------------------- /views/home.templ: -------------------------------------------------------------------------------- 1 | package views 2 | 3 | import "github.com/markbates/goth" 4 | 5 | templ Home(user goth.User) { 6 | @Page(true, user) { 7 |

    8 | Car Show Example App 9 |

    10 | 15 |
    16 |

    17 | This is a simple example app to demonstrate the use of Go + HTMX + Templ. 18 |

    19 |
    20 | } 21 | } 22 | -------------------------------------------------------------------------------- /views/home_test.go: -------------------------------------------------------------------------------- 1 | package views 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "github.com/markbates/goth" 8 | ) 9 | 10 | func TestHomePage(t *testing.T) { 11 | comp, err := componentToString(Home(goth.User{Name: "John Doe"})) 12 | if err != nil { 13 | t.Error(err) 14 | } 15 | 16 | if !strings.Contains(comp, "Car Show Example App") { 17 | t.Errorf("Expected Car Show Example App', got '%s'", comp) 18 | } 19 | 20 | if !strings.Contains(comp, "John Doe") { 21 | t.Errorf("Expected 'John Doe', got '%s'", comp) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /views/login.templ: -------------------------------------------------------------------------------- 1 | package views 2 | 3 | import "github.com/markbates/goth" 4 | 5 | templ Login() { 6 | @Page(false, goth.User{}) { 7 |
    8 |

    9 | Login 10 |

    11 | 19 |
    20 | } 21 | } 22 | -------------------------------------------------------------------------------- /views/page.templ: -------------------------------------------------------------------------------- 1 | package views 2 | 3 | import "github.com/markbates/goth" 4 | import "fmt" 5 | 6 | templ Page(nav bool, user goth.User) { 7 | 8 | 9 | 10 | Car Show 11 | 12 | 13 | 14 | 15 | 16 | 17 | if nav { 18 | 32 | } 33 | { children... } 34 | 35 | 36 | } 37 | -------------------------------------------------------------------------------- /views/test_utils.go: -------------------------------------------------------------------------------- 1 | package views 2 | 3 | import ( 4 | "context" 5 | "io" 6 | 7 | "github.com/a-h/templ" 8 | ) 9 | 10 | // componentToString renders a component to a string. 11 | // This is a rudimentary and exemplary way to test if a component renders a bit of text correctly. 12 | func componentToString(component templ.Component) (string, error) { 13 | r, w := io.Pipe() 14 | 15 | go func() { 16 | component.Render(context.Background(), w) 17 | w.Close() 18 | }() 19 | 20 | data, err := io.ReadAll(r) 21 | 22 | return string(data), err 23 | } 24 | --------------------------------------------------------------------------------