Skip to content

Commit

Permalink
Continue with !1 by doing login flow and getting list of servers
Browse files Browse the repository at this point in the history
  • Loading branch information
Scott Bragg committed Dec 27, 2024
1 parent 56fb376 commit 21849f9
Show file tree
Hide file tree
Showing 17 changed files with 442 additions and 27 deletions.
2 changes: 1 addition & 1 deletion .air-bot.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ tmp_dir = "tmp"
include_ext = ["go", "tpl", "tmpl", "html"]
include_file = []
kill_delay = "0s"
log = "build-errors.log"
log = "bot-build-errors.log"
poll = false
poll_interval = 0
post_cmd = []
Expand Down
2 changes: 1 addition & 1 deletion .air.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ tmp_dir = "tmp"
include_ext = ["go", "tpl", "tmpl", "html"]
include_file = []
kill_delay = "0s"
log = "build-errors.log"
log = "web-build-errors.log"
poll = false
poll_interval = 0
post_cmd = []
Expand Down
13 changes: 13 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ RUN apk add --no-cache ca-certificates

# Copy the binary from the builder
COPY --from=builder /app/bin/bot /app/bin/bot
COPY --from=builder /app/bin/web /app/bin/web

# Set environment variables for database connection
ENV GOPATH=/golang
Expand All @@ -52,9 +53,21 @@ CMD ["/app/bin/bot"]
# Development image we actually use go so we can use hot reloading with air
FROM builder AS dev

# Set environment variables for database connection
ENV GOPATH=/golang
ENV PATH=$GOPATH/bin:$PATH

ENV DB_HOST=postgres
ENV DB_PORT=5432
ENV DB_USER=napandgo
ENV DB_PASSWORD=napandgo
ENV DB_NAME=napandgo
ENV DB_SSLMODE=disable

# Install air for Go for hot reloading
RUN apk add --no-cache git
RUN go install github.com/air-verse/air@latest
RUN git config --global --add safe.directory /app

# Expose any required ports (optional)
EXPOSE 8080
Expand Down
120 changes: 114 additions & 6 deletions cmd/web/auth.go
Original file line number Diff line number Diff line change
@@ -1,19 +1,127 @@
// Package web runs Gin based webserver for the application.
// auth.go has endpoints to handle authentication
// auth.go has endpoints to handle authentication and session management
package main

import (
"context"
"encoding/json"
"log"
"net/http"

"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"golang.org/x/oauth2"

"github.com/faulteh/nap-and-go/config"
)

// homeHandler handles the home page rendering index.html
// AuthRequired is middleware to check if the user is authenticated
func AuthRequired() gin.HandlerFunc {
return func(c *gin.Context) {
session := sessions.Default(c)
user := session.Get("user")
if user == nil {
// User not logged in, redirect to login
c.Redirect(http.StatusFound, "/login")
c.Abort()
return
}
// User is logged in; proceed to the next handler
c.Next()
}
}

// homeHandler redirects to login or servers page based on the session
func homeHandler(c *gin.Context) {
// Render the home page
c.HTML(http.StatusOK, "index.html", nil)
session := sessions.Default(c)
user := session.Get("user")
if user == nil {
// Redirect to login page
c.Redirect(http.StatusFound, "/login")
return
}
// Redirect to servers page
c.Redirect(http.StatusFound, "/servers/")
}

func testHandler(c *gin.Context) {
// loginPageHandler handles the home page rendering login page
func loginPageHandler(c *gin.Context) {
// Render the home page
c.String(http.StatusOK, "Hello World")
c.HTML(http.StatusOK, "login.html", nil)
}

// loginRedirectHandler redirects the user to the Discord OAuth2 login page
func loginRedirectHandler(c *gin.Context) {
oauth2Config := config.LoadDiscordConfig().OAuth2Config()
url := oauth2Config.AuthCodeURL("state-token", oauth2.AccessTypeOffline)
c.Redirect(http.StatusFound, url)
}

// discordCallbackHandler handles the Discord OAuth2 callback
func discordCallbackHandler(c *gin.Context) {
state := c.Query("state")
if state != "state-token" {
// Redirect back to login page such that the login page
c.Redirect(http.StatusFound, "/login")
return
}

code := c.Query("code")
if code == "" {
// Redirect back to login page such that the login page
c.Redirect(http.StatusFound, "/login")
return
}

// Exchange code for token
oauth2Config := config.LoadDiscordConfig().OAuth2Config()
token, err := oauth2Config.Exchange(context.Background(), code)
if err != nil {
log.Printf("Token exchange failed: %v\n", err)
// Redirect back to login page such that the login page
c.Redirect(http.StatusFound, "/login")
return
}

// Fetch user info
client := oauth2Config.Client(context.Background(), token)
resp, err := client.Get("https://discord.com/api/users/@me")
if err != nil {
log.Printf("Failed to get user info: %v\n", err)
// Redirect back to login page such that the login page
c.Redirect(http.StatusFound, "/login")
return
}
defer resp.Body.Close()

Check failure on line 95 in cmd/web/auth.go

View workflow job for this annotation

GitHub Actions / Run Go Lint

Error return value of `resp.Body.Close` is not checked (errcheck)

var user map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
log.Printf("Failed to parse user info: %v\n", err)
// Redirect back to login page such that the login page
c.Redirect(http.StatusFound, "/login")
return
}

// Store user data in session
session := sessions.Default(c)
session.Set("user", user)
session.Set("token", token)
if err := session.Save(); err != nil {
log.Printf("Failed to save session: %v\n", err)
// Redirect back to login page such that the login page
c.Redirect(http.StatusFound, "/login")
return
}

// Redirect to the servers page
c.Redirect(http.StatusFound, "/servers/")
}

// logoutHandler handles the logout
func logoutHandler(c *gin.Context) {
session := sessions.Default(c)
session.Clear()
session.Save()

Check failure on line 124 in cmd/web/auth.go

View workflow job for this annotation

GitHub Actions / Run Go Lint

Error return value of `session.Save` is not checked (errcheck)
// Redirect back to login page
c.Redirect(http.StatusFound, "/login")
}
15 changes: 15 additions & 0 deletions cmd/web/dashboard.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// Package main provides the web UI
// dashboard.go has the handlers for the dashboard view and configuration of the bot
package main

import (
"net/http"

"github.com/gin-gonic/gin"
)

// dashboardHandler handles the dashboard view
func dashboardHandler(c *gin.Context) {
// Render the dashboard view
c.HTML(http.StatusOK, "dashboard.html", nil)
}
76 changes: 76 additions & 0 deletions cmd/web/servers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// Package main provides the web UI
// servers.go has the handlers for the server view
package main

import (
"context"
"encoding/json"
"log"
"net/http"

"github.com/gin-gonic/gin"
"github.com/gin-contrib/sessions"
"golang.org/x/oauth2"

"github.com/faulteh/nap-and-go/config"
)

type Guild struct {

Check failure on line 18 in cmd/web/servers.go

View workflow job for this annotation

GitHub Actions / Run Go Lint

exported: exported type Guild should have comment or be unexported (revive)
ID string `json:"id"`
Name string `json:"name"`
Icon string `json:"icon"`
Owner bool `json:"owner"`
Permissions int64 `json:"permissions"`
}

// serversHandler handles the server view
func serversHandler(c *gin.Context) {
// Get the user session
session := sessions.Default(c)
token := session.Get("token").(*oauth2.Token)

// Retrieve list of servers for user from discord
oauth2Config := config.LoadDiscordConfig().OAuth2Config()
client := oauth2Config.Client(context.Background(), token)
resp, err := client.Get("https://discord.com/api/users/@me/guilds")
if err != nil {
log.Printf("Failed to get user info: %v\n", err)
// For now just return an error
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to get user info",
"msg": err.Error(),
})
return
}
defer resp.Body.Close()

Check failure on line 45 in cmd/web/servers.go

View workflow job for this annotation

GitHub Actions / Run Go Lint

Error return value of `resp.Body.Close` is not checked (errcheck)

if resp.StatusCode != http.StatusOK {
log.Printf("Discord API responded with status %d\n", resp.StatusCode)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch servers"})
return
}

var guilds []Guild
if err := json.NewDecoder(resp.Body).Decode(&guilds); err != nil {
log.Printf("Error decoding guilds response: %v\n", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to decode servers"})
return
}

// Filter guilds where the user has admin permissions
var adminGuilds []Guild
const ADMINISTRATOR = 0x00000008
for _, guild := range guilds {
if guild.Permissions&ADMINISTRATOR != 0 {
adminGuilds = append(adminGuilds, guild)
}
}

log.Println("Admin guilds:", adminGuilds)

// Render the servers view
c.HTML(http.StatusOK, "servers.html", gin.H{
"Title": "Servers",
"Servers": adminGuilds,
})
}
38 changes: 33 additions & 5 deletions cmd/web/web.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,19 @@ package main

import (
"log"

"encoding/gob"

"github.com/faulteh/nap-and-go/config"
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/cookie"
"github.com/gin-gonic/gin"
"golang.org/x/oauth2"
)

func main() {
// Initialize the web server
router := gin.Default()

// Load the templates
router.LoadHTMLGlob("templates/*")

// Register the routes
registerRoutes(router)

Expand All @@ -28,7 +30,33 @@ func main() {

// registerRoutes registers the routes for the web server.
func registerRoutes(router *gin.Engine) {
// Register map[string]interface{} with gob as we use it for user session data
gob.Register(map[string]interface{}{})
// Register oauth2.Token with gob as we use it for user session data
gob.Register(&oauth2.Token{})

// Set up session store to store login sessions
store := cookie.NewStore([]byte(config.LoadSessionStoreSecret()))
router.Use(sessions.Sessions("nap-and-go", store))

// Load the templates
router.LoadHTMLGlob("templates/*.html")

// Static files
router.Static("/static", "./static")

// Define the routes
router.GET("/", homeHandler)
router.GET("/test", testHandler)

// Auth routes
router.GET("/login", loginPageHandler)
router.GET("/auth/login", loginRedirectHandler)
router.GET("/auth/callback", discordCallbackHandler)
router.GET("/logout", logoutHandler)

authenticated := router.Group("/servers")
authenticated.Use(AuthRequired())

authenticated.GET("/", serversHandler)
authenticated.GET("/:id", dashboardHandler)
}
42 changes: 42 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ package config
import (
"fmt"
"os"

"golang.org/x/oauth2"
)

// DBConfig holds the database configuration.
Expand All @@ -16,6 +18,14 @@ type DBConfig struct {
SSLMode string
}

// DiscordConfig holds the Discord configuration.
type DiscordConfig struct {
clientID string
clientSecret string
botToken string
redirectURL string
}

// LoadDBConfig loads the database configuration from environment variables.
func LoadDBConfig() *DBConfig {
return &DBConfig{
Expand All @@ -42,3 +52,35 @@ func (c *DBConfig) DSN() string {
return fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=%s",
c.Host, c.Port, c.User, c.Password, c.DBName, c.SSLMode)
}

// Define Discord's OAuth2 endpoint
var discordEndpoint = oauth2.Endpoint{
AuthURL: "https://discord.com/api/oauth2/authorize",
TokenURL: "https://discord.com/api/oauth2/token",
}

// LoadDiscordConfig loads the Discord configuration from environment variables.
func LoadDiscordConfig() *DiscordConfig {
return &DiscordConfig{
clientID: getEnv("DISCORD_CLIENT_ID", ""),
clientSecret: getEnv("DISCORD_CLIENT_SECRET", ""),
botToken: getEnv("DISCORD_BOT_TOKEN", ""),
redirectURL: getEnv("DISCORD_REDIRECT_URL", ""),
}
}

// Extend the DiscordConfig struct with a method OAuth2Config that returns an oauth2.Config.

Check failure on line 72 in config/config.go

View workflow job for this annotation

GitHub Actions / Run Go Lint

exported: comment on exported method DiscordConfig.OAuth2Config should be of the form "OAuth2Config ..." (revive)
func (c *DiscordConfig) OAuth2Config() *oauth2.Config {
return &oauth2.Config{
ClientID: c.clientID,
ClientSecret: c.clientSecret,
Endpoint: discordEndpoint,
RedirectURL: c.redirectURL,
Scopes: []string{"identify", "email", "guilds"},
}
}

// Load the session store secret from an environment variable.

Check failure on line 83 in config/config.go

View workflow job for this annotation

GitHub Actions / Run Go Lint

exported: comment on exported function LoadSessionStoreSecret should be of the form "LoadSessionStoreSecret ..." (revive)
func LoadSessionStoreSecret() string {
return getEnv("SESSION_STORE_SECRET", "")
}
Loading

0 comments on commit 21849f9

Please sign in to comment.