Merge pull request 'Full packaged app' (#1) from dev/app-done into main

Reviewed-on: #1
This commit is contained in:
riomoo 2025-10-20 11:53:01 -04:00 committed by moobot
commit fecfb72765
Signed by: moobot
GPG key ID: 1F58B1369E1C199C
7 changed files with 852 additions and 0 deletions

36
Dockerfile Normal file
View file

@ -0,0 +1,36 @@
# Build stage
FROM golang:bookworm AS builder
# Install UPX
RUN apt-get update && apt-get install -y wget xz-utils && rm -rf /var/lib/apt/lists/*
# Download the latest UPX binary directly from GitHub
RUN wget https://github.com/upx/upx/releases/download/v5.0.2/upx-5.0.2-amd64_linux.tar.xz
RUN tar -xf upx-5.0.2-amd64_linux.tar.xz && mv upx-5.0.2-amd64_linux/upx /usr/local/bin/upx && rm -r upx-5.0.2-amd64_linux upx-5.0.2-amd64_linux.tar.xz
# Create a simple Go web server
WORKDIR /app
# Copy go mod files first for better layer caching
COPY go.mod ./
RUN go mod download
# Copy source code
COPY . .
# Create necessary directories, build, and compress with UPX
RUN mkdir -p /var/sockets
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -ldflags="-s -w -extldflags '-static' -X main.GOMEMLIMIT=50MiB -X runtime.defaultGOGC=150" -trimpath -gcflags="-l=4" -asmflags=-trimpath -o bin/main app/gofudge/main.go
RUN upx --best --ultra-brute bin/main
RUN chmod +x bin/main
# Final stage with Chainguard static
FROM cgr.dev/chainguard/static:latest
WORKDIR /app
# Copy only the built binary and necessary directories
COPY --from=builder /app/bin/main ./bin/main
EXPOSE 8080
USER nonroot:nonroot
CMD ["./bin/main"]

17
LICENSE Normal file
View file

@ -0,0 +1,17 @@
Copyright (c) 2025 Riomoo [alister at kamikishi dot net]
This software is licensed under the Prism Information License (PIL).
- You are free to use, modify, and distribute this software.
- Any derivative work must include this license.
- You must also provide a concise explanation of how to operate this software,
as well as backends and frontends (if any) that work with this software.
- You must credit the original creator of this software.
- You may choose to license this software under additional licenses, provided that the terms of the original PIL are still adhered to.
This software is provided "as is", without any warranty of any kind,
express or implied, including but not limited to the warranties of merchantability,
fitness for a particular purpose, and non-infringement.
By using this software, you agree to the terms of the PIL.
For more information visit: https://pil.jester-designs.com/

58
README.md Normal file
View file

@ -0,0 +1,58 @@
# GoFudge
A Fudge Dice rolling room programmed in Go
## License
[![Custom badge](https://img.shields.io/endpoint?style=for-the-badge&url=https%3A%2F%2Fshare.jester-designs.com%2Fview%2Fpil.json)](LICENSE)
## Prerequisites
[![Go](https://img.shields.io/badge/go-%2300ADD8.svg?style=for-the-badge&logo=go&logoColor=white)](https://golang.org/dl/)
- The version of **Go** used to test the code in this repository is **1.25.3**.
## Get started
- run the following commands: `go mod tidy; go build -o bin/main ./app/gofudge/main.go` then `./bin/main` to start the server on port 8080.
- Visit http://localhost:8080 in your browser.
- Upon visiting the URL you will be created with a username entry and Create room button. After that you will be in the room.
- (If you are hosting this publicly) You can copy the room link in the top right hand corner and share it to anyone. They will be prompted to also pick a username.
- From there you may increase/decrease the modifier as needed for the skill you are rolling for.
### Podman/Docker
- If you want to use this with Docker, replace all `podman` commands in `run.sh` instances with `docker`
- Use `run.sh` script which will start the site on port `12007` and to make it public change the `8080` port in the NGINX config to `12007` or change the port in the `run.sh` script how you like.
- To use it locally, same as above but visit http://localhost:12007 instead.
## Config for NGINX to use as a website:
```
upstream gofudge {
server 127.0.0.1:8080;
server [::1]:8080;
}
server {
listen 80;
listen [::1]:80;
server_name fudge.example.com;
location / {
proxy_pass http://gofudge;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
<div align="center">
## Software Used but not included
![Arch](https://img.shields.io/badge/Arch%20Linux-1793D1?logo=arch-linux&logoColor=fff&style=for-the-badge)
![Vim](https://img.shields.io/badge/VIM-%2311AB00.svg?style=for-the-badge&logo=vim&logoColor=white)
![Git](https://img.shields.io/badge/git-%23F05033.svg?style=for-the-badge&logo=git&logoColor=white)
![Forgejo](https://img.shields.io/badge/forgejo-%23FB923C.svg?style=for-the-badge&logo=forgejo&logoColor=white)
</div>

693
app/gofudge/main.go Normal file
View file

@ -0,0 +1,693 @@
package main
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"html/template"
"log"
"math/big"
"net/http"
"sync"
"time"
"github.com/gorilla/websocket"
)
type Room struct {
ID string
Users map[*User]bool
History []RollResult
mu sync.RWMutex
}
type User struct {
Username string
Conn *websocket.Conn
Room *Room
}
type RollResult struct {
Username string `json:"username"`
Dice []int `json:"dice"`
Modifier int `json:"modifier"`
Total int `json:"total"`
Timestamp time.Time `json:"timestamp"`
}
type Message struct {
Type string `json:"type"`
Data interface{} `json:"data"`
Username string `json:"username,omitempty"`
}
var (
rooms = make(map[string]*Room)
roomsMu sync.RWMutex
upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
}
)
func generateRoomID() string {
b := make([]byte, 8)
rand.Read(b)
return hex.EncodeToString(b)
}
func rollFateDice() []int {
dice := make([]int, 4)
for i := 0; i < 4; i++ {
n, _ := rand.Int(rand.Reader, big.NewInt(3))
dice[i] = int(n.Int64()) - 1 // -1, 0, or 1
}
return dice
}
func getOrCreateRoom(roomID string) *Room {
roomsMu.Lock()
defer roomsMu.Unlock()
if room, exists := rooms[roomID]; exists {
return room
}
room := &Room{
ID: roomID,
Users: make(map[*User]bool),
History: []RollResult{},
}
rooms[roomID] = room
return room
}
func deleteRoomIfEmpty(roomID string) {
roomsMu.Lock()
defer roomsMu.Unlock()
if room, exists := rooms[roomID]; exists {
room.mu.RLock()
isEmpty := len(room.Users) == 0
room.mu.RUnlock()
if isEmpty {
delete(rooms, roomID)
log.Printf("Room %s deleted", roomID)
}
}
}
func (r *Room) broadcast(msg Message) {
r.mu.RLock()
defer r.mu.RUnlock()
data, _ := json.Marshal(msg)
for user := range r.Users {
user.Conn.WriteMessage(websocket.TextMessage, data)
}
}
func (r *Room) addUser(user *User) {
r.mu.Lock()
r.Users[user] = true
r.mu.Unlock()
// Send room history to new user
r.mu.RLock()
history := r.History
r.mu.RUnlock()
historyMsg := Message{Type: "history", Data: history}
data, _ := json.Marshal(historyMsg)
user.Conn.WriteMessage(websocket.TextMessage, data)
// Notify others
r.broadcast(Message{
Type: "user_joined",
Username: user.Username,
})
}
func (r *Room) removeUser(user *User) {
r.mu.Lock()
delete(r.Users, user)
r.mu.Unlock()
r.broadcast(Message{
Type: "user_left",
Username: user.Username,
})
}
func handleHome(w http.ResponseWriter, r *http.Request) {
tmpl := `<!DOCTYPE html>
<html>
<head>
<title>Go Fudge Dice Roller</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: #1a1a1a;
color: #e0e0e0;
min-height: 100vh;
display: flex;
flex-direction: column;
}
.container {
max-width: 800px;
margin: 0 auto;
padding: 20px;
flex: 1;
}
h1 {
color: #4a9eff;
margin-bottom: 30px;
text-align: center;
}
.section {
background: #2a2a2a;
border-radius: 8px;
padding: 30px;
margin-bottom: 20px;
box-shadow: 0 4px 6px rgba(0,0,0,0.3);
}
input {
width: 100%;
padding: 12px;
background: #1a1a1a;
border: 2px solid #3a3a3a;
border-radius: 4px;
color: #e0e0e0;
font-size: 16px;
margin-bottom: 15px;
}
input:focus {
outline: none;
border-color: #4a9eff;
}
button {
width: 100%;
padding: 12px 24px;
background: #4a9eff;
color: white;
border: none;
border-radius: 4px;
font-size: 16px;
cursor: pointer;
transition: background 0.3s;
}
button:hover {
background: #3a8eef;
}
.invite-info {
background: #1a1a1a;
padding: 15px;
border-radius: 4px;
margin-top: 15px;
word-break: break-all;
}
.label {
font-weight: bold;
color: #4a9eff;
margin-bottom: 8px;
}
.footer {
text-align: center;
padding: 20px;
color: #94a3b8;
font-size: 14px;
}
.footer a {
color: #4a9eff;
text-decoration: none;
}
.footer a:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<div class="container">
<h1>Go Fudge Dice Roller</h1>
<div class="section">
<h2 style="color: #4a9eff; margin-bottom: 20px;">Create New Room</h2>
<input type="text" id="username" placeholder="Enter your username">
<button onclick="createRoom()">Create Room</button>
</div>
</div>
<div class="footer">
Licensed under the <a href="https://pil.jester-designs.com/" target="_blank">Prism Information License</a>
</div>
<script>
function createRoom() {
const username = document.getElementById('username').value.trim();
if (!username) {
alert('Please enter a username');
return;
}
fetch('/api/create-room', { method: 'POST' })
.then(r => r.json())
.then(data => {
window.location.href = '/room/' + data.roomId + '?username=' + encodeURIComponent(username);
});
}
</script>
</body>
</html>`
w.Header().Set("Content-Type", "text/html")
w.Write([]byte(tmpl))
}
func handleRoom(w http.ResponseWriter, r *http.Request) {
roomID := r.URL.Path[len("/room/"):]
username := r.URL.Query().Get("username")
if username == "" {
// Show username prompt
tmpl := fmt.Sprintf(`<!DOCTYPE html>
<html>
<head>
<title>Join Room - FATE</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: #1a1a1a;
color: #e0e0e0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.section {
background: #2a2a2a;
border-radius: 8px;
padding: 40px;
box-shadow: 0 4px 6px rgba(0,0,0,0.3);
max-width: 400px;
width: 100%%;
}
h1 {
color: #4a9eff;
margin-bottom: 30px;
text-align: center;
}
input {
width: 100%%;
padding: 12px;
background: #1a1a1a;
border: 2px solid #3a3a3a;
border-radius: 4px;
color: #e0e0e0;
font-size: 16px;
margin-bottom: 15px;
}
button {
width: 100%%;
padding: 12px;
background: #4a9eff;
color: white;
border: none;
border-radius: 4px;
font-size: 16px;
cursor: pointer;
}
.footer {
text-align: center;
padding: 20px;
color: #94a3b8;
font-size: 14px;
}
.footer a {
color: #4a9eff;
text-decoration: none;
}
.footer a:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<div class="section">
<h1>Join Room</h1>
<input type="text" id="username" placeholder="Enter your username">
<button onclick="joinRoom()">Join</button>
</div>
<div class="footer">
Licensed under the <a href="https://pil.jester-designs.com/" target="_blank">Prism Information License</a>
</div>
<script>
function joinRoom() {
const username = document.getElementById('username').value.trim();
if (!username) {
alert('Please enter a username');
return;
}
window.location.href = '/room/%s?username=' + encodeURIComponent(username);
}
</script>
</body>
</html>`, roomID)
w.Header().Set("Content-Type", "text/html")
w.Write([]byte(tmpl))
return
}
tmpl := template.Must(template.New("room").Parse(`<!DOCTYPE html>
<html>
<head>
<title>FATE Room</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: #1a1a1a;
color: #e0e0e0;
min-height: 100vh;
}
.container {
max-width: 1000px;
margin: 0 auto;
padding: 20px;
}
.header {
background: #2a2a2a;
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
h1 { color: #4a9eff; }
.invite-link {
background: #1a1a1a;
padding: 10px 15px;
border-radius: 4px;
font-size: 14px;
display: flex;
gap: 10px;
align-items: center;
}
.copy-btn {
padding: 5px 15px;
background: #4a9eff;
border: none;
border-radius: 4px;
color: white;
cursor: pointer;
}
.roll-section {
background: #2a2a2a;
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
}
.roll-controls {
display: flex;
gap: 10px;
align-items: center;
}
.roll-controls input {
flex: 0 0 150px;
padding: 10px;
background: #1a1a1a;
border: 2px solid #3a3a3a;
border-radius: 4px;
color: #e0e0e0;
}
.roll-controls button {
flex: 1;
padding: 10px 20px;
background: #4a9eff;
color: white;
border: none;
border-radius: 4px;
font-size: 16px;
cursor: pointer;
}
.history {
background: #2a2a2a;
border-radius: 8px;
padding: 20px;
}
.roll-item {
background: #1a1a1a;
padding: 15px;
border-radius: 4px;
margin-bottom: 10px;
}
.roll-header {
display: flex;
justify-content: space-between;
margin-bottom: 10px;
color: #4a9eff;
}
.dice {
display: flex;
gap: 10px;
margin: 10px 0;
}
.die {
width: 40px;
height: 40px;
background: #2a2a2a;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 18px;
}
.die.plus { color: #4ade80; }
.die.minus { color: #f87171; }
.die.zero { color: #94a3b8; }
.total {
font-size: 24px;
font-weight: bold;
color: #4a9eff;
margin-top: 10px;
}
.notification {
background: #1a1a1a;
padding: 10px;
border-radius: 4px;
margin-bottom: 10px;
color: #94a3b8;
font-style: italic;
}
.footer {
text-align: center;
padding: 20px;
color: #94a3b8;
font-size: 14px;
margin-top: 20px;
}
.footer a {
color: #4a9eff;
text-decoration: none;
}
.footer a:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Go Fudge Dice Roller</h1>
<div class="invite-link">
<span id="inviteLink"></span>
<button class="copy-btn" onclick="copyInvite()">Copy</button>
</div>
</div>
<div class="roll-section">
<h2 style="margin-bottom: 15px;">Roll Dice</h2>
<div class="roll-controls">
<input type="number" id="modifier" placeholder="Modifier (±)" value="0">
<button onclick="rollDice()">Roll Fuge Dice</button>
</div>
</div>
<div class="history">
<h2 style="margin-bottom: 15px;">Roll History</h2>
<div id="rollHistory"></div>
</div>
</div>
<div class="footer">
Licensed under the <a href="https://pil.jester-designs.com/" target="_blank">Prism Information License</a>
</div>
<script>
const roomId = '{{.RoomID}}';
const username = '{{.Username}}';
const ws = new WebSocket('ws://' + window.location.host + '/ws?room=' + roomId + '&username=' + encodeURIComponent(username));
document.getElementById('inviteLink').textContent = window.location.origin + '/room/' + roomId;
function copyInvite() {
navigator.clipboard.writeText(window.location.origin + '/room/' + roomId);
alert('Invite link copied!');
}
ws.onmessage = function(event) {
const msg = JSON.parse(event.data);
if (msg.type === 'history') {
displayHistory(msg.data);
} else if (msg.type === 'roll') {
addRoll(msg.data);
} else if (msg.type === 'user_joined') {
addNotification(msg.username + ' joined the room');
} else if (msg.type === 'user_left') {
addNotification(msg.username + ' left the room');
}
};
function rollDice() {
const modifier = parseInt(document.getElementById('modifier').value) || 0;
ws.send(JSON.stringify({ type: 'roll', data: { modifier: modifier } }));
}
function displayHistory(rolls) {
const container = document.getElementById('rollHistory');
container.innerHTML = '';
rolls.forEach(roll => addRoll(roll));
}
function addRoll(roll) {
const container = document.getElementById('rollHistory');
const item = document.createElement('div');
item.className = 'roll-item';
const diceSum = roll.dice.reduce((a, b) => a + b, 0);
const diceHTML = roll.dice.map(d =>
'<div class="die ' + (d > 0 ? 'plus' : d < 0 ? 'minus' : 'zero') + '">' +
(d > 0 ? '+' : d < 0 ? '' : '0') + '</div>'
).join('');
const modifierText = roll.modifier !== 0 ?
' ' + (roll.modifier > 0 ? '+' : '') + roll.modifier : '';
item.innerHTML =
'<div class="roll-header">' +
'<strong>' + roll.username + '</strong>' +
'<span>' + new Date(roll.timestamp).toLocaleTimeString() + '</span>' +
'</div>' +
'<div class="dice">' + diceHTML + '</div>' +
'<div>Result: ' + diceSum + modifierText + '</div>' +
'<div class="total">Total: ' + roll.total + '</div>';
container.insertBefore(item, container.firstChild);
}
function addNotification(text) {
const container = document.getElementById('rollHistory');
const item = document.createElement('div');
item.className = 'notification';
item.textContent = text;
container.insertBefore(item, container.firstChild);
}
</script>
</body>
</html>`))
data := struct {
RoomID string
Username string
}{
RoomID: roomID,
Username: username,
}
tmpl.Execute(w, data)
}
func handleCreateRoom(w http.ResponseWriter, r *http.Request) {
roomID := generateRoomID()
json.NewEncoder(w).Encode(map[string]string{"roomId": roomID})
}
func handleWebSocket(w http.ResponseWriter, r *http.Request) {
roomID := r.URL.Query().Get("room")
username := r.URL.Query().Get("username")
if roomID == "" || username == "" {
http.Error(w, "Missing parameters", http.StatusBadRequest)
return
}
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Println(err)
return
}
room := getOrCreateRoom(roomID)
user := &User{
Username: username,
Conn: conn,
Room: room,
}
room.addUser(user)
defer func() {
room.removeUser(user)
conn.Close()
deleteRoomIfEmpty(roomID)
}()
for {
var msg Message
err := conn.ReadJSON(&msg)
if err != nil {
break
}
if msg.Type == "roll" {
modifier := 0
if m, ok := msg.Data.(map[string]interface{}); ok {
if mod, ok := m["modifier"].(float64); ok {
modifier = int(mod)
}
}
dice := rollFateDice()
sum := 0
for _, d := range dice {
sum += d
}
result := RollResult{
Username: username,
Dice: dice,
Modifier: modifier,
Total: sum + modifier,
Timestamp: time.Now(),
}
room.mu.Lock()
room.History = append(room.History, result)
room.mu.Unlock()
room.broadcast(Message{
Type: "roll",
Data: result,
})
}
}
}
func main() {
http.HandleFunc("/", handleHome)
http.HandleFunc("/room/", handleRoom)
http.HandleFunc("/api/create-room", handleCreateRoom)
http.HandleFunc("/ws", handleWebSocket)
log.Println("Server starting on :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}

5
go.mod Normal file
View file

@ -0,0 +1,5 @@
module go-fate
go 1.25.3
require github.com/gorilla/websocket v1.5.3

2
go.sum Normal file
View file

@ -0,0 +1,2 @@
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=

41
run.sh Executable file
View file

@ -0,0 +1,41 @@
#!/bin/bash
# Define variables for your image and container name.
IMAGE_NAME="localhost/gofudge:latest"
CONTAINER_NAME="gofudge"
# Step 1: Build the new container image.
echo "Building new image: $IMAGE_NAME..."
podman build --force-rm -t "$IMAGE_NAME" .
# Check if the build was successful before proceeding.
if [ $? -ne 0 ]; then
echo "Image build failed. Exiting script."
exit 1
fi
# Step 2: Check if the container is already running.
if podman container exists "$CONTAINER_NAME"; then
echo "Container '$CONTAINER_NAME' already exists. Stopping and removing it..."
# Step 3: Stop the existing container.
podman stop "$CONTAINER_NAME"
# Step 4: Remove the stopped container.
podman rm "$CONTAINER_NAME"
fi
# Step 5: Run a new container from the newly built image.
echo "Starting new container from image: $IMAGE_NAME..."
podman run -d --name "$CONTAINER_NAME" --memory=75m --restart unless-stopped -p 12007:8080 "$IMAGE_NAME"
# Check if the new container started successfully.
if [ $? -ne 0 ]; then
echo "Failed to start new container. Exiting script."
exit 1
fi
# Step 6: Clean up old, unused images.
# This command removes any images that are not being used by a container.
echo "Cleaning up old images..."
podman image prune --force
echo "Update and cleanup complete!"