diff --git a/.gitignore b/.gitignore index e0341b1..451d781 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,7 @@ dist/ # Database -gorm.db +zdravko.db + +# Config +.env diff --git a/README.md b/README.md index f6c44ef..50f3993 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,9 @@ Demo is available at https://zdravko.fly.dev. * [justfile](https://github.com/casey/just) ```sh +# Configure +cp example.env .env + # Start development environment just run ``` diff --git a/cmd/server/main.go b/cmd/server/main.go index 76c2023..51226dc 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -3,37 +3,53 @@ package main import ( "log" "net/http" - "os" "github.com/gorilla/mux" "code.tjo.space/mentos1386/zdravko/internal" - "code.tjo.space/mentos1386/zdravko/internal/pages" - "code.tjo.space/mentos1386/zdravko/internal/static" + "code.tjo.space/mentos1386/zdravko/internal/handlers" + "code.tjo.space/mentos1386/zdravko/web/static" ) func main() { - port := os.Getenv("PORT") - if port == "" { - port = "8000" - } + config := internal.NewConfig() r := mux.NewRouter() - db, err := internal.ConnectToDatabase() + db, query, err := internal.ConnectToDatabase(config.SQLITE_DB_PATH) if err != nil { log.Fatal(err) } log.Println("Connected to database") - page := pages.NewPageHandler(db) + h := handlers.NewBaseHandler(db, query, config) + + // Health + r.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + d, err := db.DB() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + err = d.Ping() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + _, err = w.Write([]byte("OK")) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + }) // Server static files r.PathPrefix("/static/").Handler(http.StripPrefix("/static/", http.FileServer(http.FS(static.Static)))) - r.HandleFunc("/", page.Index).Methods("GET") - r.HandleFunc("/settings", page.Settings).Methods("GET") + r.HandleFunc("/", h.Index).Methods("GET") + r.HandleFunc("/settings", h.Settings).Methods("GET") - log.Println("Server started on", port) - log.Fatal(http.ListenAndServe(":"+port, r)) + // OAuth2 + r.HandleFunc("/oauth2/login", h.OAuth2LoginGET).Methods("GET") + r.HandleFunc("/oauth2/callback", h.OAuth2CallbackGET).Methods("GET") + + log.Println("Server started on", config.PORT) + log.Fatal(http.ListenAndServe(":"+config.PORT, r)) } diff --git a/example.env b/example.env new file mode 100644 index 0000000..5c2b6db --- /dev/null +++ b/example.env @@ -0,0 +1,18 @@ +# PORT +PORT=8000 +ROOT_URL=http://localhost:8000 + +# SQLite +SQLITE_DB_PATH=zdravko.db + +# Session +SESSION_SECRET=your_secret + +# OAUTH2 +# The redirect/callback url is ${ROOT_URL}/auth/callback +OAUTH2_CLIENT_ID=your_client_id +OAUTH2_CLIENT_SECRET=your_client_secret +OAUTH2_SCOPES=openid,profile,email +OAUTH2_ENDPOINT_TOKEN_URL=https://your_oauth2_provider/token +OAUTH2_ENDPOINT_AUTH_URL=https://your_oauth2_provider/auth +OAUTH2_ENDPOINT_USER_INFO_URL=https://your_oauth2_provider/userinfo diff --git a/fly.toml b/fly.toml index be08db7..dd0dd7e 100644 --- a/fly.toml +++ b/fly.toml @@ -9,6 +9,13 @@ primary_region = 'waw' [env] PORT = '8080' + ROOT_URL = 'https://zdravko.fly.dev' + SQLITE_DB_PATH = 'zdravko.db' + # Other are defined in secrets + OAUTH2_SCOPES = 'openid,profile,email' + OAUTH2_ENDPOINT_TOKEN_URL = 'https://id.tjo.space/application/o/token/' + OAUTH2_ENDPOINT_AUTH_URL = 'https://id.tjo.space/application/o/authorize/' + OAUTH2_ENDPOINT_USER_INFO_URL = 'https://id.tjo.space/application/o/userinfo/' [processes] server = "server" diff --git a/go.mod b/go.mod index 743cc1b..27532ef 100644 --- a/go.mod +++ b/go.mod @@ -38,11 +38,13 @@ require ( go.temporal.io/api v1.24.0 // indirect go.uber.org/atomic v1.9.0 // indirect golang.org/x/mod v0.15.0 // indirect - golang.org/x/net v0.20.0 // indirect + golang.org/x/net v0.21.0 // indirect + golang.org/x/oauth2 v0.17.0 // indirect golang.org/x/sys v0.17.0 // indirect golang.org/x/text v0.14.0 // indirect golang.org/x/time v0.3.0 // indirect golang.org/x/tools v0.17.0 // indirect + google.golang.org/appengine v1.6.7 // indirect google.golang.org/genproto v0.0.0-20230815205213-6bfd019c3878 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20230815205213-6bfd019c3878 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20230815205213-6bfd019c3878 // indirect diff --git a/go.sum b/go.sum index 40bb020..fccf296 100644 --- a/go.sum +++ b/go.sum @@ -1130,6 +1130,7 @@ golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0 golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc= golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= +golang.org/x/crypto v0.19.0 h1:ENy+Az/9Y1vSrlrvBSyna3PITt4tiZLf7sgCjZBX7Wo= golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= @@ -1253,6 +1254,8 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI= golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo= golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= +golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -1283,6 +1286,8 @@ golang.org/x/oauth2 v0.5.0/go.mod h1:9/XBHVqLaWO3/BRHs5jbpYCnOZVjj5V0ndyaAM7KB4I golang.org/x/oauth2 v0.6.0/go.mod h1:ycmewcwgD4Rpr3eZJLSB4Kyyljb3qDh40vJ8STE5HKw= golang.org/x/oauth2 v0.7.0/go.mod h1:hPLQkd9LyjfXTiRohC/41GhcFqxisoUQ99sCUOHO9x4= golang.org/x/oauth2 v0.8.0/go.mod h1:yr7u4HXZRm1R1kBWqr/xKNqewf0plRYoB7sla+BCIXE= +golang.org/x/oauth2 v0.17.0 h1:6m3ZPmLEFdVxKKWnKq4VqZ60gutO35zm+zrAHVmHyDQ= +golang.org/x/oauth2 v0.17.0/go.mod h1:OzPDGQiuQMguemayvdylqddI7qcD9lnSDb+1FiwQ5HA= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -1572,6 +1577,7 @@ google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7 google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/genproto v0.0.0-20180518175338-11a468237815/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= diff --git a/internal/config.go b/internal/config.go new file mode 100644 index 0000000..b9a3a40 --- /dev/null +++ b/internal/config.go @@ -0,0 +1,53 @@ +package internal + +import ( + "os" + "strings" +) + +type Config struct { + PORT string + ROOT_URL string // Needed for oauth2 redirect + + SQLITE_DB_PATH string + + SESSION_SECRET string + + OAUTH2_CLIENT_ID string + OAUTH2_CLIENT_SECRET string + OAUTH2_SCOPES []string + OAUTH2_ENDPOINT_TOKEN_URL string + OAUTH2_ENDPOINT_AUTH_URL string + OAUTH2_ENDPOINT_USER_INFO_URL string +} + +func getEnv(key, fallback string) string { + if value, ok := os.LookupEnv(key); ok { + return value + } + return fallback +} + +func getEnvRequired(key string) string { + if value, ok := os.LookupEnv(key); ok { + return value + } + panic("Environment variable " + key + " is required") +} + +func NewConfig() *Config { + return &Config{ + PORT: getEnv("PORT", "8000"), + ROOT_URL: getEnvRequired("ROOT_URL"), + + SQLITE_DB_PATH: getEnv("SQLITE_DB_PATH", "zdravko.db"), + SESSION_SECRET: getEnvRequired("SESSION_SECRET"), + + OAUTH2_CLIENT_ID: getEnvRequired("OAUTH2_CLIENT_ID"), + OAUTH2_CLIENT_SECRET: getEnvRequired("OAUTH2_CLIENT_SECRET"), + OAUTH2_SCOPES: strings.Split(getEnvRequired("OAUTH2_SCOPES"), ","), + OAUTH2_ENDPOINT_TOKEN_URL: getEnvRequired("OAUTH2_ENDPOINT_TOKEN_URL"), + OAUTH2_ENDPOINT_AUTH_URL: getEnvRequired("OAUTH2_ENDPOINT_AUTH_URL"), + OAUTH2_ENDPOINT_USER_INFO_URL: getEnvRequired("OAUTH2_ENDPOINT_USER_INFO_URL"), + } +} diff --git a/internal/database.go b/internal/database.go index 46aa877..8467f5d 100644 --- a/internal/database.go +++ b/internal/database.go @@ -1,16 +1,22 @@ package internal import ( + "code.tjo.space/mentos1386/zdravko/internal/models" + "code.tjo.space/mentos1386/zdravko/internal/models/query" "github.com/glebarez/sqlite" "gorm.io/gorm" ) //go:generate just _generate-gorm -func ConnectToDatabase() (*gorm.DB, error) { - db, err := gorm.Open(sqlite.Open("gorm.db"), &gorm.Config{}) +func ConnectToDatabase(path string) (*gorm.DB, *query.Query, error) { + db, err := gorm.Open(sqlite.Open(path), &gorm.Config{}) if err != nil { - return nil, err + return nil, nil, err } - return db, nil + db.AutoMigrate(&models.Healthcheck{}) + + q := query.Use(db) + + return db, q, nil } diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go new file mode 100644 index 0000000..55f2ac2 --- /dev/null +++ b/internal/handlers/handlers.go @@ -0,0 +1,17 @@ +package handlers + +import ( + "code.tjo.space/mentos1386/zdravko/internal" + "code.tjo.space/mentos1386/zdravko/internal/models/query" + "gorm.io/gorm" +) + +type BaseHandler struct { + db *gorm.DB + query *query.Query + config *internal.Config +} + +func NewBaseHandler(db *gorm.DB, q *query.Query, config *internal.Config) *BaseHandler { + return &BaseHandler{db, q, config} +} diff --git a/internal/pages/index.go b/internal/handlers/index.go similarity index 63% rename from internal/pages/index.go rename to internal/handlers/index.go index 7ed10e9..1501325 100644 --- a/internal/pages/index.go +++ b/internal/handlers/index.go @@ -1,14 +1,14 @@ -package pages +package handlers import ( "net/http" "text/template" - "code.tjo.space/mentos1386/zdravko/internal/ui" + "code.tjo.space/mentos1386/zdravko/web/templates" ) -func (p *PageHandler) Index(w http.ResponseWriter, r *http.Request) { - ts, err := template.ParseFS(ui.Templates, +func (h *BaseHandler) Index(w http.ResponseWriter, r *http.Request) { + ts, err := template.ParseFS(templates.Templates, "components/base.tmpl", "pages/index.tmpl", ) diff --git a/internal/handlers/oauth2.go b/internal/handlers/oauth2.go new file mode 100644 index 0000000..7f99dfe --- /dev/null +++ b/internal/handlers/oauth2.go @@ -0,0 +1,63 @@ +package handlers + +import ( + "context" + "io" + "net/http" + + "code.tjo.space/mentos1386/zdravko/internal" + "golang.org/x/oauth2" +) + +func newOAuth2(config *internal.Config) *oauth2.Config { + return &oauth2.Config{ + ClientID: config.OAUTH2_CLIENT_ID, + ClientSecret: config.OAUTH2_CLIENT_SECRET, + Scopes: config.OAUTH2_SCOPES, + RedirectURL: config.ROOT_URL + "/oauth2/callback", + Endpoint: oauth2.Endpoint{ + TokenURL: config.OAUTH2_ENDPOINT_TOKEN_URL, + AuthURL: config.OAUTH2_ENDPOINT_AUTH_URL, + }, + } +} + +func (h *BaseHandler) OAuth2LoginGET(w http.ResponseWriter, r *http.Request) { + conf := newOAuth2(h.config) + + url := conf.AuthCodeURL("state", oauth2.AccessTypeOffline) + + http.Redirect(w, r, url, http.StatusTemporaryRedirect) +} + +func (h *BaseHandler) OAuth2CallbackGET(w http.ResponseWriter, r *http.Request) { + ctx := context.Background() + conf := newOAuth2(h.config) + + // Exchange the code for a new token. + tok, err := conf.Exchange(r.Context(), r.URL.Query().Get("code")) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // Ge the user information. + client := oauth2.NewClient(ctx, oauth2.StaticTokenSource(tok)) + resp, err := client.Get(h.config.OAUTH2_ENDPOINT_USER_INFO_URL) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + + _, err = w.Write(body) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + +} diff --git a/internal/pages/settings.go b/internal/handlers/settings.go similarity index 63% rename from internal/pages/settings.go rename to internal/handlers/settings.go index f217b4b..c4e119b 100644 --- a/internal/pages/settings.go +++ b/internal/handlers/settings.go @@ -1,14 +1,14 @@ -package pages +package handlers import ( "net/http" "text/template" - "code.tjo.space/mentos1386/zdravko/internal/ui" + "code.tjo.space/mentos1386/zdravko/web/templates" ) -func (p *PageHandler) Settings(w http.ResponseWriter, r *http.Request) { - ts, err := template.ParseFS(ui.Templates, +func (h *BaseHandler) Settings(w http.ResponseWriter, r *http.Request) { + ts, err := template.ParseFS(templates.Templates, "components/base.tmpl", "pages/settings.tmpl", ) diff --git a/internal/pages/pages.go b/internal/pages/pages.go deleted file mode 100644 index 65beed1..0000000 --- a/internal/pages/pages.go +++ /dev/null @@ -1,11 +0,0 @@ -package pages - -import "gorm.io/gorm" - -type PageHandler struct { - db *gorm.DB -} - -func NewPageHandler(db *gorm.DB) *PageHandler { - return &PageHandler{db} -} diff --git a/justfile b/justfile index 863a4f7..e8d2b88 100644 --- a/justfile +++ b/justfile @@ -1,5 +1,7 @@ # Always use devbox environment to run commands. set shell := ["devbox", "run"] +# Load dotenv +set dotenv-load STATIC_DIR := "./internal/static" @@ -45,4 +47,4 @@ _feather-icons-download: curl -sLo {{STATIC_DIR}}/icons/feather-sprite.svg https://unpkg.com/feather-icons/dist/feather-sprite.svg _generate-gorm: - go run cmd/generate/main.go + go run tools/generate/main.go diff --git a/cmd/generate/main.go b/tools/generate/main.go similarity index 100% rename from cmd/generate/main.go rename to tools/generate/main.go diff --git a/internal/static/css/main.css b/web/static/css/main.css similarity index 100% rename from internal/static/css/main.css rename to web/static/css/main.css diff --git a/internal/static/css/tailwind.css b/web/static/css/tailwind.css similarity index 100% rename from internal/static/css/tailwind.css rename to web/static/css/tailwind.css diff --git a/internal/static/icons/feather-sprite.svg b/web/static/icons/feather-sprite.svg similarity index 100% rename from internal/static/icons/feather-sprite.svg rename to web/static/icons/feather-sprite.svg diff --git a/internal/static/js/htmx.min.js b/web/static/js/htmx.min.js similarity index 100% rename from internal/static/js/htmx.min.js rename to web/static/js/htmx.min.js diff --git a/internal/static/static.go b/web/static/static.go similarity index 100% rename from internal/static/static.go rename to web/static/static.go diff --git a/internal/ui/components/base.tmpl b/web/templates/components/base.tmpl similarity index 100% rename from internal/ui/components/base.tmpl rename to web/templates/components/base.tmpl diff --git a/internal/ui/pages/index.tmpl b/web/templates/pages/index.tmpl similarity index 100% rename from internal/ui/pages/index.tmpl rename to web/templates/pages/index.tmpl diff --git a/internal/ui/pages/settings.tmpl b/web/templates/pages/settings.tmpl similarity index 100% rename from internal/ui/pages/settings.tmpl rename to web/templates/pages/settings.tmpl diff --git a/internal/ui/ui.go b/web/templates/tempaltes.go similarity index 76% rename from internal/ui/ui.go rename to web/templates/tempaltes.go index 4b3c37c..83a8320 100644 --- a/internal/ui/ui.go +++ b/web/templates/tempaltes.go @@ -1,4 +1,4 @@ -package ui +package templates import ( "embed"