From 37fd8625904bfa4bcddcdb4898430283d533a8f3 Mon Sep 17 00:00:00 2001 From: "Gustavo \"Guz\" L. de Mello" Date: Thu, 27 Jun 2024 19:53:09 -0300 Subject: [PATCH] feat(oauth): add twitter oauth support --- .example.env | 2 + .gitignore | 1 + components/logins.templ | 23 ++++++ flake.lock | 154 +++++++++++++++++++++++++++++++++++++- flake.nix | 5 +- internals/helpers.go | 23 ++++++ main.go | 34 +-------- makefile | 4 +- pages/router.go | 7 ++ pages/twitter_login.templ | 95 +++++++++++++++++++++++ 10 files changed, 312 insertions(+), 36 deletions(-) create mode 100644 .example.env create mode 100644 components/logins.templ create mode 100644 pages/twitter_login.templ diff --git a/.example.env b/.example.env new file mode 100644 index 0000000..8255aa7 --- /dev/null +++ b/.example.env @@ -0,0 +1,2 @@ +TWITTER_CLIENT_ID=********************************** +TWITTER_CLIENT_SECRET=************************************************** diff --git a/.gitignore b/.gitignore index 785dc37..900ff7b 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ Session.vim .vercel *_templ.go *_templ.txt +.env dist tmp bin diff --git a/components/logins.templ b/components/logins.templ new file mode 100644 index 0000000..a9cfd0f --- /dev/null +++ b/components/logins.templ @@ -0,0 +1,23 @@ +package components + +import ( + "fmt" + "os" + "net/url" +) + +var loginUrl = fmt.Sprintf("https://x.com/i/oauth2/authorize"+ + "?response_type=code"+ + "&client_id=%s"+ + "&redirect_uri=%s"+ + "&scope=tweet.write tweet.read users.read"+ + "&state=state"+ + "&code_challenge=challenge"+ + "&code_challenge_method=plain", + os.Getenv("TWITTER_CLIENT_ID"), + url.PathEscape("http://localhost:7331/api/oauth/twitter"), +) + +templ LoginTwitter() { + Login on Twitter +} diff --git a/flake.lock b/flake.lock index 2fac8de..0b72591 100644 --- a/flake.lock +++ b/flake.lock @@ -1,5 +1,81 @@ { "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1694529238, + "narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "ff7b65b44d01cf9ba6a71320833626af21126384", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "flake-utils_2": { + "locked": { + "lastModified": 1667395993, + "narHash": "sha256-nuEHfE/LcWyuSWnS8t12N1wc105Qtau+/OdUAjtQ0rA=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "5aed5285a952e0b949eb3ba02c12fa4fcfef535f", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "gitignore": { + "inputs": { + "nixpkgs": [ + "templ", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1709087332, + "narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=", + "owner": "hercules-ci", + "repo": "gitignore.nix", + "rev": "637db329424fd7e46cf4185293b9cc8c88c95394", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "gitignore.nix", + "type": "github" + } + }, + "gomod2nix": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": [ + "templ", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1710154385, + "narHash": "sha256-4c3zQ2YY4BZOufaBJB4v9VBBeN2dH7iVdoJw8SDNCfI=", + "owner": "nix-community", + "repo": "gomod2nix", + "rev": "872b63ddd28f318489c929d25f1f0a3c6039c971", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "gomod2nix", + "type": "github" + } + }, "nixpkgs": { "locked": { "lastModified": 1719075281, @@ -16,9 +92,85 @@ "type": "github" } }, + "nixpkgs_2": { + "locked": { + "lastModified": 1710565619, + "narHash": "sha256-xu/EnZCNdIj7m/QjCNIG5vrCA4TYg5uwFReb9XDxET0=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "8ac30a39abc5ea67037dfbf090d6e89f187c6e50", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-23.11", + "repo": "nixpkgs", + "type": "github" + } + }, "root": { "inputs": { - "nixpkgs": "nixpkgs" + "nixpkgs": "nixpkgs", + "templ": "templ" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "templ": { + "inputs": { + "gitignore": "gitignore", + "gomod2nix": "gomod2nix", + "nixpkgs": "nixpkgs_2", + "xc": "xc" + }, + "locked": { + "lastModified": 1716625173, + "narHash": "sha256-4TkK8zeoWWGmcBg8YwALo2EyKfOyq5ut/3TjG81a+8M=", + "owner": "a-h", + "repo": "templ", + "rev": "0d42d67413c2a0fa357018d1b1c0301231f3b359", + "type": "github" + }, + "original": { + "owner": "a-h", + "ref": "v0.2.707", + "repo": "templ", + "type": "github" + } + }, + "xc": { + "inputs": { + "flake-utils": "flake-utils_2", + "nixpkgs": [ + "templ", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1709043057, + "narHash": "sha256-F/u9u1vevCUqgCz0WjcCOPcN+h9kLEP73nBqie86J2w=", + "owner": "joerdav", + "repo": "xc", + "rev": "52cfa7e7f2d5a0d97b45a6f69ff1403a901e78c6", + "type": "github" + }, + "original": { + "owner": "joerdav", + "repo": "xc", + "type": "github" } } }, diff --git a/flake.nix b/flake.nix index d3aead0..93d3f51 100644 --- a/flake.nix +++ b/flake.nix @@ -2,8 +2,9 @@ description = "learning.rs"; inputs = { nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; + templ.url = "github:a-h/templ?ref=v0.2.707"; }; - outputs = { nixpkgs, ... }: + outputs = { nixpkgs, ... } @ inputs: let systems = [ "x86_64-linux" @@ -16,6 +17,7 @@ pkgs = import nixpkgs { inherit system; }; in f system pkgs); + templ = system: inputs.templ.packages.${system}.templ; in { devShells = forAllSystems (system: pkgs: { @@ -23,6 +25,7 @@ buildInputs = with pkgs; [ go golangci-lint + (templ system) ]; }; }); diff --git a/internals/helpers.go b/internals/helpers.go index bf944d9..4188c14 100644 --- a/internals/helpers.go +++ b/internals/helpers.go @@ -1,9 +1,32 @@ package internals import ( + "fmt" "net/http" + "slices" ) +func GetCookie(name string, w http.ResponseWriter, r *http.Request) *http.Cookie { + name = fmt.Sprintf("__Host-%s-%s-%s", APP_NAME, APP_VERSION, name) + + c := r.Cookies() + i := slices.IndexFunc(c, func(c *http.Cookie) bool { + return c.Name == name + }) + var cookie *http.Cookie + if i == -1 { + cookie = &http.Cookie{ + Name: name, + SameSite: http.SameSiteStrictMode, + Path: "/", + Secure: true, + } + } else { + cookie = c[i] + } + return cookie +} + func HttpErrorHelper(w http.ResponseWriter) func(msg string, err error, status int) bool { return func(msg string, err error, status int) bool { if err != nil { diff --git a/main.go b/main.go index c34154a..5bdcf33 100644 --- a/main.go +++ b/main.go @@ -5,11 +5,9 @@ import ( "fmt" "log" "net/http" - "slices" - "strings" - "extrovert/config" "extrovert/internals" + "extrovert/pages" ) var logger = log.Default() @@ -28,41 +26,13 @@ func main() { mux := http.NewServeMux() - config.APIROUTES(mux) - for _, route := range config.ROUTES { - path := "/" + strings.TrimSuffix(route.Path, ".html") - if path == "/index" { - continue - } - logger.Printf("Registering page route. page=%s route=%s", route.Path, path) - - mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { - w.Header().Add("Content-Type", "text/html") - - err := route.Component.Render(r.Context(), w) - if err != nil { - logger.Fatalf("Unable to render route %s due to %s", route.Path, err) - } - }) - } + pages.RegisterAllRoutes(pages.ROUTES, mux) mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { logger.Printf("Handling file server request. path=%s", r.URL.Path) http.FileServer(http.Dir(*staticDir)).ServeHTTP(w, r) return } - - w.Header().Add("Content-Type", "text/html") - - index := slices.IndexFunc(config.ROUTES, func(route internals.Page) bool { - return route.Path == "index.html" - }) - indexPage := config.ROUTES[index] - - err := indexPage.Component.Render(r.Context(), w) - if err != nil { - log.Fatalf("Unable to render index page due to %s", err) - } }) logger.Printf("Running server at port: %v", *port) diff --git a/makefile b/makefile index dafcbe6..4a2be9a 100644 --- a/makefile +++ b/makefile @@ -34,7 +34,8 @@ dev/sync_assets: --build.include_ext "js,css" dev: - make -j3 dev/templ dev/server dev/sync_assets + go run github.com/joho/godotenv/cmd/godotenv@v1.5.1 \ + make -j3 dev/templ dev/server dev/sync_assets run: build ./bin/www @@ -45,7 +46,6 @@ clean: if [[ -d "dist" ]]; then rm -r ./dist; fi if [[ -d "tmp" ]]; then rm -r ./tmp; fi if [[ -d "bin" ]]; then rm -r ./bin; fi - rm ./static/uno.css rm $(TEMPL_FILES) # For some reason "templ generate" does not detect the files in CI, diff --git a/pages/router.go b/pages/router.go index b405679..6dfbcf0 100644 --- a/pages/router.go +++ b/pages/router.go @@ -13,6 +13,13 @@ var ROUTES = []Route{ Page: IndexPage(), Handler: IndexHandler, }, + { + Pattern: "/api/oauth/twitter", + Static: false, + Page: TwitterLogin(), + Handler: TwitterLoginHandler, + }, +} type RouteHandler = func(http.ResponseWriter, *http.Request) diff --git a/pages/twitter_login.templ b/pages/twitter_login.templ new file mode 100644 index 0000000..9bf2d7c --- /dev/null +++ b/pages/twitter_login.templ @@ -0,0 +1,95 @@ +package pages + +import ( + "net/http" + "fmt" + "encoding/json" + "errors" + "os" + + "extrovert/layouts" + "extrovert/internals" +) + +type TwitterTokenResponse struct { + Type string `json:"token_type"` + Token string `json:"access_token"` + Expires int `json:"expires_in"` + Scope string `json:"scope"` +} + +func TwitterLoginHandler(w http.ResponseWriter, r *http.Request) { + error := internals.HttpErrorHelper(w) + + code := r.URL.Query().Get("code") + if code == "" { + error( + "Bad request", + errors.New("Missing \"code\" parameter"), + http.StatusBadRequest, + ) + return + } + + tReq := fmt.Sprintf("https://api.twitter.com/2/oauth2/token"+ + "?grant_type=authorization_code"+ + "&client_id=%s"+ + "&code_verifier=challenge"+ + "&code=%s"+ + "&challenge_method=plain"+ + "&redirect_uri=http://localhost:7331/api/oauth/twitter", + os.Getenv("TWITTER_CLIENT_ID"), + code, + ) + + t, err := http.Post(tReq, "application/x-www-form-urlencoded", bytes.NewReader([]byte(""))) + if error("Error trying to request token from twitter", err, http.StatusInternalServerError) { + return + } + + b, err := io.ReadAll(t.Body) + if error("Error trying to read response body from twitter", err, http.StatusInternalServerError) { + return + } else if t.StatusCode < 200 || t.StatusCode > 299 { + error( + "Error trying to request token from twitter, returned non-200 code", + errors.New(fmt.Sprintf("Code: %v, Return value: %s", t.StatusCode, string(b))), + http.StatusInternalServerError, + ) + return + } + + var res TwitterTokenResponse + err = json.Unmarshal(b, &res) + if error("Error trying to parse response body from twitter", err, http.StatusInternalServerError) { + return + } + + c := internals.GetCookie("twitter-data", w, r) + c.Value = res.Token + http.SetCookie(w, c) + + TwitterLogin().Render(context.Background(), w) +} + +templ TwitterLogin() { + @layouts.Page("Project Extrovert") { + +
+
+

Logged into Twitter!

+
+

+ Your account was succefully connected with project-extrovert! + Click "Ok" to return to the index page. +

+ +
+
+ @IndexPage() + } +}