diff --git a/.github/workflows/release_staging.yml b/.github/workflows/release_staging.yml new file mode 100644 index 00000000..7f00a0c1 --- /dev/null +++ b/.github/workflows/release_staging.yml @@ -0,0 +1,33 @@ +name: Deploy to Azure Staging + +on: + workflow_dispatch: + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + with: + fetch-depth: 0 + + - name: Download artifact + uses: dawidd6/action-download-artifact@v3 + with: + workflow: build.yml + name: build-artifact + path: ./deploy + + - name: Set up Node.js + uses: actions/setup-node@v2 + with: + node-version: "16" + + - name: Deploy to Azure App Service + uses: azure/webapps-deploy@v2 + with: + app-name: auroria-test-faucet # Replace with your Azure App Service name + slot-name: staging + publish-profile: ${{ secrets.AZURE_PUBLISH_PROFILE_STAGING }} # Azure publish profile secret + package: ./deploy # Path to the downloaded artifact diff --git a/cmd/server.go b/cmd/server.go index a0b07197..25e503eb 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -26,9 +26,9 @@ var ( versionFlag = flag.Bool("version", false, "Print version number") //payoutFlag = flag.Int("faucet_amount", 10000, "Number of Ethers to transfer per user request") - intervalFlag = flag.Int("faucet_minutes", 1440, "Number of minutes to wait between funding rounds") - netnameFlag = flag.String("faucet_name", os.Getenv("FAUCET_NAME"), "Network name to display on the frontend") - symbolFlag = flag.String("faucet_symbol", os.Getenv("FAUCET_SYMBOL"), "Token symbol to display on the frontend") + //intervalFlag = flag.Int("faucet_minutes", os.Getenv("FAUCET_MINUTES"), "Number of minutes to wait between funding rounds") + netnameFlag = flag.String("faucet_name", os.Getenv("FAUCET_NAME"), "Network name to display on the frontend") + symbolFlag = flag.String("faucet_symbol", os.Getenv("FAUCET_SYMBOL"), "Token symbol to display on the frontend") keyJSONFlag = flag.String("wallet_keyjson", os.Getenv("KEYSTORE"), "Keystore file to fund user requests with") keyPassFlag = flag.String("wallet_keypass", "password.txt", "Passphrase text file to decrypt keystore") @@ -37,6 +37,10 @@ var ( hcaptchaSiteKeyFlag = flag.String("hcaptcha_sitekey", os.Getenv("HCAPTCHA_SITEKEY"), "hCaptcha sitekey") hcaptchaSecretFlag = flag.String("hcaptcha_secret", os.Getenv("HCAPTCHA_SECRET"), "hCaptcha secret") + + discordClientId = flag.String("discord_client_id", os.Getenv("DISCORD_CLIENTID"), "Discord client id for oauth2") + discordClientSecret = flag.String("discord_client_secret", os.Getenv("DISCORD_CLIENTSECRET"), "Discord client secret for oauth2") + discordRedirectUrl = flag.String("discord_redirect_url", os.Getenv("DISCORD_REDIRECTURL"), "Discord redirect url for oauth2") ) func init() { @@ -68,13 +72,19 @@ func Execute() { } httpPortFlag := flag.Int("httpport", port, "Listener port to serve HTTP connection") + interval, err := strconv.Atoi(os.Getenv("FAUCET_MINUTES")) + if err != nil { + interval = 1440 + } + intervalFlag := flag.Int("faucet_minutes", interval, "Number of minutes to wait between funding rounds") + faucetAmount, err := strconv.Atoi(os.Getenv("FAUCET_AMOUNT")) if err != nil { faucetAmount = 10000 } payoutFlag := flag.Int("faucet.amount", faucetAmount, "Number of Ethers to transfer per user request") - config := server.NewConfig(*netnameFlag, *symbolFlag, *httpPortFlag, *intervalFlag, *payoutFlag, *proxyCntFlag, *hcaptchaSiteKeyFlag, *hcaptchaSecretFlag) + config := server.NewConfig(*netnameFlag, *symbolFlag, *httpPortFlag, *intervalFlag, *payoutFlag, *proxyCntFlag, *hcaptchaSiteKeyFlag, *hcaptchaSecretFlag, *discordClientId, *discordClientSecret, *discordRedirectUrl) go server.NewServer(txBuilder, config).Run() c := make(chan os.Signal, 1) diff --git a/internal/server/config.go b/internal/server/config.go index 514bcfd3..4d72031b 100644 --- a/internal/server/config.go +++ b/internal/server/config.go @@ -1,25 +1,31 @@ package server type Config struct { - network string - symbol string - httpPort int - interval int - payout int - proxyCount int - hcaptchaSiteKey string - hcaptchaSecret string + network string + symbol string + httpPort int + interval int + payout int + proxyCount int + hcaptchaSiteKey string + hcaptchaSecret string + discordClientId string + discordClientSecret string + discordRedirectUrl string } -func NewConfig(network, symbol string, httpPort, interval, payout, proxyCount int, hcaptchaSiteKey, hcaptchaSecret string) *Config { +func NewConfig(network, symbol string, httpPort, interval, payout, proxyCount int, hcaptchaSiteKey, hcaptchaSecret, discordClientId, discordClientSecret, discordRedirectUrl string) *Config { return &Config{ - network: network, - symbol: symbol, - httpPort: httpPort, - interval: interval, - payout: payout, - proxyCount: proxyCount, - hcaptchaSiteKey: hcaptchaSiteKey, - hcaptchaSecret: hcaptchaSecret, + network: network, + symbol: symbol, + httpPort: httpPort, + interval: interval, + payout: payout, + proxyCount: proxyCount, + hcaptchaSiteKey: hcaptchaSiteKey, + hcaptchaSecret: hcaptchaSecret, + discordClientId: discordClientId, + discordClientSecret: discordClientSecret, + discordRedirectUrl: discordRedirectUrl, } } diff --git a/internal/server/dto.go b/internal/server/dto.go index ac75ff55..a96baa01 100644 --- a/internal/server/dto.go +++ b/internal/server/dto.go @@ -16,16 +16,38 @@ type claimRequest struct { Address string `json:"address"` } +type loginRequest struct { + Code string `json:"code"` +} + type claimResponse struct { Message string `json:"msg"` } +type loginResponse struct { + Message string `json:"msg"` +} + +type discordTokenResponse struct { + AccessToken string `json:"access_token"` + Error string `json:"error"` + ErrorDesc string `json:"error_description"` +} + type infoResponse struct { Account string `json:"account"` Network string `json:"network"` Payout string `json:"payout"` Symbol string `json:"symbol"` HcaptchaSiteKey string `json:"hcaptcha_sitekey,omitempty"` + RemoteAddr string `json:"remote_addr,omitempty"` + Forward string `json:"forward,omitempty"` + RealIP string `json:"real_ip,omitempty"` + DiscorClientId string `json:"discord_client_id"` +} + +type authResponse struct { + Token string `json:"token"` } type malformedRequest struct { @@ -92,6 +114,24 @@ func readAddress(r *http.Request) (string, error) { return claimReq.Address, nil } +func readCode(r *http.Request) (string, error) { + var loginReq loginRequest + if err := decodeJSONBody(r, &loginReq); err != nil { + return "", err + } + + return loginReq.Code, nil +} + +func readToken(r *http.Request) (string, error) { + var discordRes discordTokenResponse + if err := decodeJSONBody(r, &discordRes); err != nil { + return "", err + } + + return discordRes.AccessToken, nil +} + func renderJSON(w http.ResponseWriter, v interface{}, code int) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(code) diff --git a/internal/server/middleware.go b/internal/server/middleware.go index 414c9577..dbd7a26e 100644 --- a/internal/server/middleware.go +++ b/internal/server/middleware.go @@ -1,6 +1,7 @@ package server import ( + "bytes" "errors" "fmt" "net" @@ -50,7 +51,11 @@ func (l *Limiter) ServeHTTP(w http.ResponseWriter, r *http.Request, next http.Ha } clintIP := getClientIPFromRequest(l.proxyCount, r) + + fmt.Println("Client Values", address, clintIP) + l.mutex.Lock() + // if l.limitByKey(w, address) { if l.limitByKey(w, address) || l.limitByKey(w, clintIP) { l.mutex.Unlock() return @@ -65,6 +70,7 @@ func (l *Limiter) ServeHTTP(w http.ResponseWriter, r *http.Request, next http.Ha l.cache.Remove(clintIP) return } + log.WithFields(log.Fields{ "address": address, "clientIP": clintIP, @@ -73,7 +79,8 @@ func (l *Limiter) ServeHTTP(w http.ResponseWriter, r *http.Request, next http.Ha func (l *Limiter) limitByKey(w http.ResponseWriter, key string) bool { if _, ttl, err := l.cache.GetWithTTL(key); err == nil { - errMsg := fmt.Sprintf("You have exceeded the rate limit. Please wait %s before you try again", ttl.Round(time.Second)) + errMsg := fmt.Sprintf("You have exceeded the rate limit for %s. Please wait %s before you try again", key, ttl.Round(time.Second)) + fmt.Println(errMsg) renderJSON(w, claimResponse{Message: errMsg}, http.StatusTooManyRequests) return true } @@ -94,18 +101,105 @@ func getClientIPFromRequest(proxyCount int, r *http.Request) string { } } - remoteIP, _, err := net.SplitHostPort(r.RemoteAddr) + remoteIP, _, err := net.SplitHostPort(getIPAdress(r)) if err != nil { - remoteIP = r.RemoteAddr + remoteIP = getIPAdress(r) + if remoteIP == "" { + remoteIP = r.RemoteAddr + } } return remoteIP } +// ipRange - a structure that holds the start and end of a range of ip addresses +type ipRange struct { + start net.IP + end net.IP +} + +// inRange - check to see if a given ip address is within a range given +func inRange(r ipRange, ipAddress net.IP) bool { + // strcmp type byte comparison + if bytes.Compare(ipAddress, r.start) >= 0 && bytes.Compare(ipAddress, r.end) < 0 { + return true + } + return false +} + +var privateRanges = []ipRange{ + ipRange{ + start: net.ParseIP("10.0.0.0"), + end: net.ParseIP("10.255.255.255"), + }, + ipRange{ + start: net.ParseIP("100.64.0.0"), + end: net.ParseIP("100.127.255.255"), + }, + ipRange{ + start: net.ParseIP("172.16.0.0"), + end: net.ParseIP("172.31.255.255"), + }, + ipRange{ + start: net.ParseIP("192.0.0.0"), + end: net.ParseIP("192.0.0.255"), + }, + ipRange{ + start: net.ParseIP("192.168.0.0"), + end: net.ParseIP("192.168.255.255"), + }, + ipRange{ + start: net.ParseIP("198.18.0.0"), + end: net.ParseIP("198.19.255.255"), + }, +} + +// isPrivateSubnet - check to see if this ip is in a private subnet +func isPrivateSubnet(ipAddress net.IP) bool { + // my use case is only concerned with ipv4 atm + if ipCheck := ipAddress.To4(); ipCheck != nil { + // iterate over all our ranges + for _, r := range privateRanges { + // check if this ip is in a private range + if inRange(r, ipAddress) { + return true + } + } + } + return false +} + +func getIPAdress(r *http.Request) string { + for _, h := range []string{"X-Forwarded-For", "X-Real-Ip"} { + addresses := strings.Split(r.Header.Get(h), ",") + // march from right to left until we get a public address + // that will be the address right before our proxy. + for i := len(addresses) - 1; i >= 0; i-- { + ip := strings.TrimSpace(addresses[i]) + realIP, _, err := net.SplitHostPort(ip) + if err != nil { + realIP = ip + } + + parsedId := net.ParseIP(realIP) + if !parsedId.IsGlobalUnicast() || isPrivateSubnet(parsedId) { + // bad address, go to next + continue + } + return realIP + } + } + return "" +} + type Captcha struct { client *hcaptcha.Client secret string } +type Auth struct { + code string +} + func NewCaptcha(hcaptchaSiteKey, hcaptchaSecret string) *Captcha { client := hcaptcha.New(hcaptchaSecret) client.SiteKey = hcaptchaSiteKey @@ -115,6 +209,12 @@ func NewCaptcha(hcaptchaSiteKey, hcaptchaSecret string) *Captcha { } } +func NewAuth(code string) *Auth { + return &Auth{ + code: code, + } +} + func (c *Captcha) ServeHTTP(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) { if c.secret == "" { next.ServeHTTP(w, r) @@ -129,3 +229,50 @@ func (c *Captcha) ServeHTTP(w http.ResponseWriter, r *http.Request, next http.Ha next.ServeHTTP(w, r) } + +func (c *Auth) ServeHTTP(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) { + + cookie, err := r.Cookie("token") + if err != nil { + // If the cookie is not set, return an unauthorized status + if err == http.ErrNoCookie { + renderJSON(w, loginResponse{Message: "Invalid login. Please authenticate with discord first"}, http.StatusUnauthorized) + return + } + // For any other error, return a bad request status + renderJSON(w, loginResponse{Message: "Invalid login. Please authenticate with discord first"}, http.StatusBadRequest) + return + } + + token := cookie.Value + + isValid := validateToken(token) + if !isValid { + renderJSON(w, loginResponse{Message: "Invalid login. Please authenticate with discord first"}, http.StatusUnauthorized) + return + } + + next.ServeHTTP(w, r) +} + +func validateToken(token string) bool { + req, err := http.NewRequest("GET", "https://discord.com/api/users/@me", nil) + if err != nil { + return false + } + req.Header.Set("Authorization", "Bearer "+token) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return false + } + defer resp.Body.Close() + + // Check if the response status code is 200 OK + if resp.StatusCode == http.StatusOK { + return true + } + + return false +} diff --git a/internal/server/server.go b/internal/server/server.go index 82701c7d..3ad91c6c 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -2,8 +2,10 @@ package server import ( "context" + "encoding/json" "fmt" "net/http" + "net/url" "os" "strconv" "strings" @@ -34,7 +36,10 @@ func (s *Server) setupRouter() *http.ServeMux { router.Handle("/", http.FileServer(web.Dist())) limiter := NewLimiter(s.cfg.proxyCount, time.Duration(s.cfg.interval)*time.Minute) hcaptcha := NewCaptcha(s.cfg.hcaptchaSiteKey, s.cfg.hcaptchaSecret) - router.Handle("/api/claim", negroni.New(limiter, hcaptcha, negroni.Wrap(s.handleClaim()))) + auth := NewAuth("") + router.Handle("/api/login", s.handleLogin()) + router.Handle("/api/check", negroni.New(auth, negroni.Wrap(s.handleLoginCheck()))) + router.Handle("/api/claim", negroni.New(limiter, hcaptcha, auth, negroni.Wrap(s.handleClaim()))) router.Handle("/api/info", s.handleInfo()) return router @@ -109,6 +114,106 @@ func (s *Server) handleInfo() http.HandlerFunc { Symbol: s.cfg.symbol, Payout: strconv.Itoa(s.cfg.payout), HcaptchaSiteKey: s.cfg.hcaptchaSiteKey, + RemoteAddr: r.RemoteAddr, + Forward: r.Header.Get("X-Forwarded-For"), + RealIP: r.Header.Get("X-Real-IP"), + DiscorClientId: s.cfg.discordClientId, }, http.StatusOK) } } + +func (s *Server) handleLogin() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + + if r.Method != "POST" { + http.NotFound(w, r) + return + } + + // The error always be nil since it has already been handled in limiter + code, _ := readCode(r) + + token, err := exchangeCodeForToken(code, s.cfg.discordClientId, s.cfg.discordClientSecret, s.cfg.discordRedirectUrl) + if err != nil { + http.Error(w, err.Error(), http.StatusUnauthorized) + return + } + + expirationTime := time.Now().Add(24 * time.Hour) // Expires in 24 hours + http.SetCookie(w, &http.Cookie{ + Name: "token", + Value: token.AccessToken, // Replace with the actual token value + Expires: expirationTime, + HttpOnly: true, // This makes the cookie inaccessible to JavaScript + }) + + // You can send back a simple response + //w.Write([]byte("User logged in")) + + renderJSON(w, authResponse{ + Token: token.AccessToken, + }, http.StatusOK) + } +} + +func (s *Server) handleLoginCheck() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie("token") + if err != nil { + // Handle the case where the cookie is not present or invalid + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + // Additional checks can be performed here, such as validating the token + log.Info(cookie.Value) + + // If everything is okay + w.WriteHeader(http.StatusOK) + } +} + +func exchangeCodeForToken(code, discordClientId, discordClientSecret, discordRedirectUrl string) (*discordTokenResponse, error) { + log.Info(discordClientId, discordClientSecret, discordRedirectUrl, code) + // Prepare the request data + data := url.Values{} + data.Set("client_id", discordClientId) + data.Set("client_secret", discordClientSecret) + data.Set("grant_type", "authorization_code") + data.Set("code", code) + data.Set("redirect_uri", discordRedirectUrl) + data.Set("scope", "identify") + + // Make the request + req, err := http.NewRequest("POST", "https://discord.com/api/oauth2/token", strings.NewReader(data.Encode())) + if err != nil { + log.Error(err) + return nil, err + } + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + log.Error(err) + return nil, err + } + defer resp.Body.Close() + + log.Info(resp.Body) + + // Decode the response + var tokenResp discordTokenResponse + if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil { + log.Error(err) + return nil, err + } + + log.WithFields(log.Fields{ + "token": tokenResp.AccessToken, + "erorr": tokenResp.Error, + "error_description": tokenResp.ErrorDesc, + }).Info("Response") + + return &tokenResp, nil +} diff --git a/web/src/Faucet.svelte b/web/src/Faucet.svelte index 70089cb0..a1e9d5c9 100644 --- a/web/src/Faucet.svelte +++ b/web/src/Faucet.svelte @@ -4,7 +4,6 @@ import { CloudflareProvider } from '@ethersproject/providers'; import { setDefaults as setToast, toast } from 'bulma-toast'; import SocialIcons from '@rodneylab/svelte-social-icons'; - import { web3 } from 'svelte-web3' let input = null; let faucetInfo = { @@ -13,23 +12,72 @@ payout: 1, symbol: 'ETH', hcaptcha_sitekey: '', + discord_client_id: '', }; + let loggedIn = false; let mounted = false; let hcaptchaLoaded = false; + let loginUrl = ''; onMount(async () => { + + const params = new URLSearchParams(window.location.search); + const code = params.get('code'); + + const res = await fetch('/api/info'); + faucetInfo = await res.json(); + loginUrl = `https://discord.com/api/oauth2/authorize?client_id=${faucetInfo.discord_client_id}&redirect_uri=${window.location.href}&response_type=code&scope=identify%20email`; + + await checkAuthentication(); + + if (code) { + exchangeCodeForToken(code); + } + await checkNetwork(); if (window.ethereum) { window.ethereum.on('chainChanged', (_chainId) => { - checkNetwork(); + checkNetwork(); }); } - const res = await fetch('/api/info'); - faucetInfo = await res.json(); + mounted = true; }); + async function checkAuthentication() { + try { + const response = await fetch('/api/check', { + credentials: 'include' // Ensures cookies are included in the request + }); + + if (response.ok) { + loggedIn = true; + } else { + loggedIn = false; + } + } catch (error) { + console.error('Error checking authentication:', error); + } + } + + async function exchangeCodeForToken(code) { + // Make an API request to your backend with the code + const response = await fetch('/api/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code }), + }); + + if (response.ok) { + loggedIn = true; + const newUrl = window.location.pathname; // This retains the current path without the query parameters + window.history.replaceState({}, '', newUrl); + } else { + // Handle errors + } + } + window.hcaptchaOnLoad = () => { hcaptchaLoaded = true; }; @@ -45,16 +93,40 @@ }); } - let networkLabel = 'Checking Network...'; + let accounts = []; + async function connectMetaMask() { + if (window.ethereum) { + try { + // Request account access + accounts = await window.ethereum.request({ method: 'eth_requestAccounts' }); + console.log('Connected account:', accounts[0]); + input = accounts[0]; + // Proceed with the connected account + } catch (error) { + if (error.code === 4001) { + // User denied account access + console.log('User denied account access'); + } else { + console.error('Error connecting to MetaMask:', error); + } + } + } else { + console.log('MetaMask is not installed'); + } + } + + let networkLabel = 'Checking Network...'; + async function checkNetwork() { - setTimeout(() => { + setTimeout(async () => { if (window.ethereum) { try { const currentChainId = window.ethereum.chainId; if (currentChainId === '0x32195') { - networkLabel = 'Auroria'; // Correct network + networkLabel = 'Stratis Auroria Testnet'; // Correct network + await connectMetaMask(); } else { networkLabel = 'Switch Network'; // Incorrect network } @@ -66,30 +138,30 @@ console.log('Ethereum wallet not detected'); networkLabel = 'No Wallet Detected'; } - }, 3000); + }, 2000); } async function addCustomNetwork() { if (window.ethereum) { - try { - // Request to add a custom network - await window.ethereum.request({ - method: 'wallet_addEthereumChain', - params: [{ - chainId: '0x32195', // The chainId of the network in hexadecimal, 205205 in decimal - chainName: 'Auroria', - nativeCurrency: { - name: 'STRAX', - symbol: 'STRAX', // Up to 5 characters - decimals: 18 - }, - rpcUrls: ['https://auroria.rpc.stratisevm.com/'], - blockExplorerUrls: ['https://auroria.explorer.stratisevm.com/'] - }], - }); - } catch (addError) { - alert('Error adding Auroria network:'); - } + try { + // Request to add a custom network + await window.ethereum.request({ + method: 'wallet_addEthereumChain', + params: [{ + chainId: '0x32195', // The chainId of the network in hexadecimal, 205205 in decimal + chainName: 'Stratis Auroria Testnet', + nativeCurrency: { + name: 'STRAX', + symbol: 'STRAX', // Up to 5 characters + decimals: 18 + }, + rpcUrls: ['https://auroria.rpc.stratisevm.com/'], + blockExplorerUrls: ['https://auroria.explorer.stratisevm.com/'] + }], + }); + } catch (addError) { + alert('Error adding Auroria network:'); + } } else { alert('Metamask wallet is not installed'); } @@ -145,6 +217,7 @@ const res = await fetch('/api/claim', { method: 'POST', + credentials: 'include', headers, body: JSON.stringify({ address, @@ -335,60 +408,6 @@ window.requestAnimationFrame(this.animate); } } - - // Box highlighter - // class Highlighter { - // constructor(containerElement) { - // this.container = containerElement; - // this.boxes = Array.from(this.container.children); - // this.mouse = { - // x: 0, - // y: 0, - // }; - // this.containerSize = { - // w: 0, - // h: 0, - // }; - // this.initContainer = this.initContainer.bind(this); - // this.onMouseMove = this.onMouseMove.bind(this); - // this.init(); - // } - - // initContainer() { - // this.containerSize.w = this.container.offsetWidth; - // this.containerSize.h = this.container.offsetHeight; - // } - - // onMouseMove(event) { - // const { clientX, clientY } = event; - // const rect = this.container.getBoundingClientRect(); - // const { w, h } = this.containerSize; - // const x = clientX - rect.left; - // const y = clientY - rect.top; - // const inside = x < w && x > 0 && y < h && y > 0; - // if (inside) { - // this.mouse.x = x; - // this.mouse.y = y; - // this.boxes.forEach((box) => { - // const boxX = -(box.getBoundingClientRect().left - rect.left) + this.mouse.x; - // const boxY = -(box.getBoundingClientRect().top - rect.top) + this.mouse.y; - // box.style.setProperty('--mouse-x', `${boxX}px`); - // box.style.setProperty('--mouse-y', `${boxY}px`); - // }); - // } - // } - - // init() { - // this.initContainer(); - // window.addEventListener('resize', this.initContainer); - // window.addEventListener('mousemove', this.onMouseMove); - // } - // } - - // const highlighters = document.querySelectorAll('[data-highlighter]'); - // highlighters.forEach((highlighter) => { - // new Highlighter(highlighter); - // }); @@ -475,11 +494,19 @@
- + {#if loggedIn} + + {:else} + + Login with Discord -> + + {/if}