Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

1 add a web framework #2

Merged
merged 2 commits into from
Dec 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions .air-bot.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
root = "."
testdata_dir = "testdata"
tmp_dir = "tmp"

[build]
args_bin = []
bin = "./tmp/bot"
cmd = "go build -o ./tmp/bot ./cmd/bot"
delay = 1000
exclude_dir = ["assets", "tmp", "vendor", "testdata"]
exclude_file = []
exclude_regex = ["_test.go"]
exclude_unchanged = false
follow_symlink = false
full_bin = ""
include_dir = []
include_ext = ["go", "tpl", "tmpl", "html"]
include_file = []
kill_delay = "0s"
log = "bot-build-errors.log"
poll = false
poll_interval = 0
post_cmd = []
pre_cmd = []
rerun = false
rerun_delay = 500
send_interrupt = false
stop_on_error = false

[color]
app = ""
build = "yellow"
main = "magenta"
runner = "green"
watcher = "cyan"

[log]
main_only = false
silent = false
time = false

[misc]
clean_on_exit = false

[proxy]
app_port = 0
enabled = false
proxy_port = 0

[screen]
clear_on_rebuild = false
keep_scroll = true
52 changes: 52 additions & 0 deletions .air.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
root = "."
testdata_dir = "testdata"
tmp_dir = "tmp"

[build]
args_bin = []
bin = "./tmp/web"
cmd = "go build -o ./tmp/web ./cmd/web"
delay = 1000
exclude_dir = ["assets", "tmp", "vendor", "testdata"]
exclude_file = []
exclude_regex = ["_test.go"]
exclude_unchanged = false
follow_symlink = false
full_bin = ""
include_dir = []
include_ext = ["go", "tpl", "tmpl", "html"]
include_file = []
kill_delay = "0s"
log = "web-build-errors.log"
poll = false
poll_interval = 0
post_cmd = []
pre_cmd = []
rerun = false
rerun_delay = 500
send_interrupt = false
stop_on_error = false

[color]
app = ""
build = "yellow"
main = "magenta"
runner = "green"
watcher = "cyan"

[log]
main_only = false
silent = false
time = false

[misc]
clean_on_exit = false

[proxy]
app_port = 0
enabled = false
proxy_port = 0

[screen]
clear_on_rebuild = false
keep_scroll = true
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,5 @@ go.work.sum
# env file
.env

bin/
bin/
tmp/
41 changes: 40 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ FROM golang:1.23-alpine AS builder
# Install dependencies required to run the Makefile
RUN apk add --no-cache make

# Set up Go environment variables
ENV GOPATH=/golang
ENV PATH=$GOPATH/bin:$PATH

# Set the working directory
WORKDIR /app

Expand All @@ -14,7 +18,7 @@ COPY . .
RUN make build

# Use a minimal base image for the final container
FROM alpine:latest
FROM alpine:latest AS deploy

# Set the working directory
WORKDIR /app
Expand All @@ -24,8 +28,12 @@ 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
ENV PATH=$GOPATH/bin:$PATH

ENV DB_HOST=postgres
ENV DB_PORT=5432
ENV DB_USER=napandgo
Expand All @@ -36,5 +44,36 @@ ENV DB_SSLMODE=disable
# Expose any required ports (optional)
EXPOSE 8080

# Run the container as a non-root user
USER nobody

# Run the bot binary
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

# Run the container as a non-root user
USER root

# Run the bot binary
CMD ["air", "-c", ".air-bot.toml"]
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ bot:

# Build the web interface binary
web:
$(GO_BUILD_CMD) -o bin/web ./cmd/web
CGO_ENABLED=$(CGO_ENABLED) $(GO_BUILD_CMD) -o bin/web ./cmd/web

# Run both bot and web
run: build
Expand Down
44 changes: 44 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,47 @@ My way to learn Go by creating a Discord bot with a bunch of features
- Replies to a message when it is sent a message in a channel

Features will be added as they get completed

## Usage

The application is designed to be run in a collection of containers with Postgres.

```
docker compose up -d
docker compose logs -f
```

## Create a Discord application and Bot

- Go to the [Discord Developer portal](https://discord.com/developers/applications)
- Click the *New Application* button at the top right
![Discord application page](docs/images/screen-new-app.png)
- Give the application a name
![Discord create app modal](docs/images/screen-create-app.png)
- Click the OAuth menu item and configure the a redirect URI. Don't forget to save the page!
- For development http://localhost:3000/api/oauth
- For production https://YOURSERVER/api/oauth
![Discord OAuth page](docs/images/screen-bot-settings.png)
- Click the *Reset Secret* button and copy the new secret that you are given. This will become DISCORD_CLIENT_SECRET in your environment file
- Copy the Client ID - this will become DISCORD_CLIENT_ID in your environment file.
- Click the Bot menu item, and configure the bot settings (you will need to enable several Intents as noted below)
![Discord Bot page](docs/images/screen-bot-settings2.png)
- Click the *Reset Token* button and copy the new token that you receive. This will become your DISCORD_BOT_TOKEN environment variable.

## Installation

- Create an .env file using the .env-prod-example as a template
- Enter your values for DISCORD_CLIENT_ID, DISCORD_CLIENT_SECRET and DISCORD_BOT_TOKEN from the above procedure.

## Development Notes

### Project layout

- cmd/bot -- stuff for the bot
- cmd/web -- stuff for the web UI
- config -- handles configuration (mainly loading by environment variables)
- db -- stuff for the database
- docs -- additional documentation
- models -- database models
- static -- web UI static files
- templates -- web UI templates
127 changes: 127 additions & 0 deletions cmd/web/auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
// Package web runs Gin based webserver for the application.
// 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"
)

// 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) {
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/")
}

// loginPageHandler handles the home page rendering login page
func loginPageHandler(c *gin.Context) {
// Render the home page
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() //nolint: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() //nolint: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)
}
Loading
Loading