feat(oauth): add twitter oauth support
This commit is contained in:
2
.example.env
Normal file
2
.example.env
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
TWITTER_CLIENT_ID=**********************************
|
||||||
|
TWITTER_CLIENT_SECRET=**************************************************
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -4,6 +4,7 @@ Session.vim
|
|||||||
.vercel
|
.vercel
|
||||||
*_templ.go
|
*_templ.go
|
||||||
*_templ.txt
|
*_templ.txt
|
||||||
|
.env
|
||||||
dist
|
dist
|
||||||
tmp
|
tmp
|
||||||
bin
|
bin
|
||||||
|
|||||||
23
components/logins.templ
Normal file
23
components/logins.templ
Normal file
@@ -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() {
|
||||||
|
<a href={ templ.SafeURL(loginUrl) } rel="">Login on Twitter</a>
|
||||||
|
}
|
||||||
154
flake.lock
generated
154
flake.lock
generated
@@ -1,5 +1,81 @@
|
|||||||
{
|
{
|
||||||
"nodes": {
|
"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": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1719075281,
|
"lastModified": 1719075281,
|
||||||
@@ -16,9 +92,85 @@
|
|||||||
"type": "github"
|
"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": {
|
"root": {
|
||||||
"inputs": {
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
description = "learning.rs";
|
description = "learning.rs";
|
||||||
inputs = {
|
inputs = {
|
||||||
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
|
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
|
||||||
|
templ.url = "github:a-h/templ?ref=v0.2.707";
|
||||||
};
|
};
|
||||||
outputs = { nixpkgs, ... }:
|
outputs = { nixpkgs, ... } @ inputs:
|
||||||
let
|
let
|
||||||
systems = [
|
systems = [
|
||||||
"x86_64-linux"
|
"x86_64-linux"
|
||||||
@@ -16,6 +17,7 @@
|
|||||||
pkgs = import nixpkgs { inherit system; };
|
pkgs = import nixpkgs { inherit system; };
|
||||||
in
|
in
|
||||||
f system pkgs);
|
f system pkgs);
|
||||||
|
templ = system: inputs.templ.packages.${system}.templ;
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
devShells = forAllSystems (system: pkgs: {
|
devShells = forAllSystems (system: pkgs: {
|
||||||
@@ -23,6 +25,7 @@
|
|||||||
buildInputs = with pkgs; [
|
buildInputs = with pkgs; [
|
||||||
go
|
go
|
||||||
golangci-lint
|
golangci-lint
|
||||||
|
(templ system)
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,32 @@
|
|||||||
package internals
|
package internals
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"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 {
|
func HttpErrorHelper(w http.ResponseWriter) func(msg string, err error, status int) bool {
|
||||||
return func(msg string, err error, status int) bool {
|
return func(msg string, err error, status int) bool {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
34
main.go
34
main.go
@@ -5,11 +5,9 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"slices"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"extrovert/config"
|
|
||||||
"extrovert/internals"
|
"extrovert/internals"
|
||||||
|
"extrovert/pages"
|
||||||
)
|
)
|
||||||
|
|
||||||
var logger = log.Default()
|
var logger = log.Default()
|
||||||
@@ -28,41 +26,13 @@ func main() {
|
|||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
config.APIROUTES(mux)
|
pages.RegisterAllRoutes(pages.ROUTES, 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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.URL.Path != "/" {
|
if r.URL.Path != "/" {
|
||||||
logger.Printf("Handling file server request. path=%s", r.URL.Path)
|
logger.Printf("Handling file server request. path=%s", r.URL.Path)
|
||||||
http.FileServer(http.Dir(*staticDir)).ServeHTTP(w, r)
|
http.FileServer(http.Dir(*staticDir)).ServeHTTP(w, r)
|
||||||
return
|
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)
|
logger.Printf("Running server at port: %v", *port)
|
||||||
|
|||||||
4
makefile
4
makefile
@@ -34,7 +34,8 @@ dev/sync_assets:
|
|||||||
--build.include_ext "js,css"
|
--build.include_ext "js,css"
|
||||||
|
|
||||||
dev:
|
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
|
run: build
|
||||||
./bin/www
|
./bin/www
|
||||||
@@ -45,7 +46,6 @@ clean:
|
|||||||
if [[ -d "dist" ]]; then rm -r ./dist; fi
|
if [[ -d "dist" ]]; then rm -r ./dist; fi
|
||||||
if [[ -d "tmp" ]]; then rm -r ./tmp; fi
|
if [[ -d "tmp" ]]; then rm -r ./tmp; fi
|
||||||
if [[ -d "bin" ]]; then rm -r ./bin; fi
|
if [[ -d "bin" ]]; then rm -r ./bin; fi
|
||||||
rm ./static/uno.css
|
|
||||||
rm $(TEMPL_FILES)
|
rm $(TEMPL_FILES)
|
||||||
|
|
||||||
# For some reason "templ generate" does not detect the files in CI,
|
# For some reason "templ generate" does not detect the files in CI,
|
||||||
|
|||||||
@@ -13,6 +13,13 @@ var ROUTES = []Route{
|
|||||||
Page: IndexPage(),
|
Page: IndexPage(),
|
||||||
Handler: IndexHandler,
|
Handler: IndexHandler,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Pattern: "/api/oauth/twitter",
|
||||||
|
Static: false,
|
||||||
|
Page: TwitterLogin(),
|
||||||
|
Handler: TwitterLoginHandler,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
type RouteHandler = func(http.ResponseWriter, *http.Request)
|
type RouteHandler = func(http.ResponseWriter, *http.Request)
|
||||||
|
|
||||||
|
|||||||
95
pages/twitter_login.templ
Normal file
95
pages/twitter_login.templ
Normal file
@@ -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") {
|
||||||
|
<dialog open>
|
||||||
|
<article>
|
||||||
|
<header>
|
||||||
|
<p>Logged into Twitter!</p>
|
||||||
|
</header>
|
||||||
|
<p>
|
||||||
|
Your account was succefully connected with project-extrovert!
|
||||||
|
Click "Ok" to return to the index page.
|
||||||
|
</p>
|
||||||
|
<footer>
|
||||||
|
<a href="/index.html">
|
||||||
|
<button>Ok</button>
|
||||||
|
</a>
|
||||||
|
</footer>
|
||||||
|
</article>
|
||||||
|
</dialog>
|
||||||
|
@IndexPage()
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user