Compare commits

...

No commits in common. "5f43cde2d63c88416ec6c50be36ef900147f5860" and "97ae53d7f5bea967b5995975964bc7f6084440a9" have entirely different histories.

45 changed files with 5556 additions and 2379 deletions

0
.gitignore vendored Normal file
View File

8
.idea/.gitignore generated vendored Normal file
View File

@ -0,0 +1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

9
.idea/dictionaries/project.xml generated Normal file
View File

@ -0,0 +1,9 @@
<component name="ProjectDictionaryState">
<dictionary name="project">
<words>
<w>abcdefghijklmnopqrstuvwxyz</w>
<w>bufsize</w>
<w>omitempty</w>
</words>
</dictionary>
</component>

View File

@ -0,0 +1,16 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="CssUnknownProperty" enabled="true" level="WARNING" enabled_by_default="true">
<option name="myCustomPropertiesEnabled" value="true" />
<option name="myIgnoreVendorSpecificProperties" value="false" />
<option name="myCustomPropertiesList">
<value>
<list size="1">
<item index="0" class="java.lang.String" itemvalue="glow" />
</list>
</value>
</option>
</inspection_tool>
</profile>
</component>

6
.idea/jsLibraryMappings.xml generated Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="JavaScriptLibraryMappings">
<file url="PROJECT" libraries="{embed, platform, widgets}" />
</component>
</project>

8
.idea/modules.xml generated Normal file
View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/radchat.iml" filepath="$PROJECT_DIR$/.idea/radchat.iml" />
</modules>
</component>
</project>

15
.idea/radchat.iml generated Normal file
View File

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="Go" enabled="true" />
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
<orderEntry type="library" name="widgets" level="application" />
<orderEntry type="library" name="platform" level="application" />
<orderEntry type="library" name="embed" level="application" />
<orderEntry type="library" name="widgets" level="application" />
<orderEntry type="library" name="embed" level="application" />
<orderEntry type="library" name="platform" level="application" />
</component>
</module>

6
.idea/vcs.xml generated Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

101
README.md Normal file
View File

@ -0,0 +1,101 @@
# RadChat
A lightweight, self-hostable voice and text chat application using WebRTC for media and WebSockets for signaling. RadChat runs as a single binary with a static frontend.
- Backend: Go (Gorilla WebSocket), simple HTTP server with middleware
- Frontend: Single-page app (vanilla JS, HTML, CSS)
- Signaling: JSON messages over WebSocket
- Media: WebRTC peer connections with configurable STUN/TURN
## Features
- Join a room and chat with others via audio and text
- Username validation and collision checks
- Emoji picker and message reactions (planned)
- File upload/download (ephemeral)
- Optional gzip and no-cache middlewares
- Configurable origin policy for WebSocket upgrades
## Architecture Overview
- server/ package: Hub and Client primitives, websocket upgrader, HTTP middleware
- package main: wires routes, creates a Hub, starts the run loop, and serves static assets
- static/: SPA frontend (index.html, app.js, styles.css, SVG and sounds) handling UI, WebRTC, and signaling
Data flow (high level):
1. Browser loads SPA from / (static/)
2. Client connects to /ws for signaling
3. Hub tracks Clients and broadcasts system/user messages
4. WebRTC offers/answers/ICE are relayed via WS signaling
5. Optional file uploads via /files and served via /files/{id}
## Directory Layout
- main.go: entrypoint; flags and route wiring
- hub.go: HTTP handlers and hub run loop integration
- client.go: read/write pumps for websocket client
- message.go: message envelope used for signaling and system messages
- utils.go: file ops, ids, username validation, URL scheme helpers
- server/: Hub/Client structs, upgrader, middleware
- static/: frontend resources
## Getting Started
Prerequisites: Go 1.20+ and a modern browser.
Build and run:
```bash
go build -o radchat
./radchat -ip 0.0.0.0 -port 8080 -gzip-enable
```
Open http://localhost:8080 in your browser.
Common flags:
- -ip: bind address (default localhost)
- -port: port (default 8080)
- -bufsize: channel buffer size for hub broadcast/register/unregister
- -gzip-enable: enable gzip middleware
- -cache-disable: disable caching headers
- -files: directory for uploads (default ./files)
- -files-timeout: seconds until an upload expires (default 3600)
- -origin: allowed Origin host for WS upgrades; leave empty to allow any
## Configuration (STUN/TURN)
Edit static/app.js CONFIG.CONN.RTC_CONFIGURATION to set iceServers. By default, public Google STUN is enabled. For restrictive networks, configure your TURN server (e.g., Coturn) and optionally set iceTransportPolicy to "relay".
## Development
- Backend hot reload: run using `go run .`
- Frontend: static files are served as-is; just refresh the browser
- Lint/docs: prefer GoDoc-style comments for exported symbols (see server/doc.go)
## Security Notes
- Set -origin in production to your domain to enforce Origin checks for WS upgrades
- Consider terminating TLS in front (reverse proxy) so GetScheme returns https
- Uploaded files are sanitized by name and stored under ./files; validate trust boundaries
## Roadmap / Tasks
##### FIX/ADJUSTMENT NEEDED
* STYLE: MOBILE: Users list needs a rework
* APP: Emojis should have category picker buttons
* APP: File picker: Add an uploading spinner icon before the message is posted, currently it has a chat alert
* APP: File picker: Drag and drop
##### FEATURE ADDITIONS
* APP/STYLE: User settings should include a microphone selection, audio selection, and a test functionality for both
* APP/STYLE: Emoji message reactions
* APP/STYLE: Add webcam capability
* APP/STYLE: Add screen sharing
##### IN PROGRESS FEATURE ADDITIONS
* APP: Self-hosted STUN/TURN
##### BUG TRACKER [ Latest: #38 ]
* [#38] APP: STUN/TURN issues with browser ICE rejection of offer. Need TURN to function properly?
+ Manifests in an inability to receive/send audio from a user.
+ Need a way of reporting which STUN/TURN was agreed on.
+ Set up my own STUN/TURN with Coturn.
##### FUTURE
* APP: Add pagination and message history
* APP: See who is in the room before you join
* META: Turn the app into a docker container
## License
MIT (or your chosen license).

210
client.go Normal file
View File

@ -0,0 +1,210 @@
package main
// WebSocket client read/write pumps.
//
// ReadPump pulls messages from the client's WebSocket connection and routes
// them into the Hub. WritePump pushes outbound messages from the Hub to the
// client's connection. Both functions are expected to run as goroutines per
// client.
import (
"encoding/json"
"fmt"
"radchat/server"
"time"
"github.com/gorilla/websocket"
)
// ReadPump reads from the websocket connection and handles client pings/pongs.
func ReadPump(c *server.Client) {
defer func() {
c.Hub.Unregister <- c
err := c.Conn.Close()
if err != nil {
return
}
}()
for {
var msg Message
err := c.Conn.ReadJSON(&msg)
if err != nil {
break
}
switch msg.Type {
case "system_message":
messageContent := msg.Data.(string)
messageQualifier := msg.DataExt.(string)
messageUserList := make(map[string]bool)
for _, v := range msg.DataList {
if v == nil {
continue
}
messageUserList[v.(string)] = true
}
messageType := msg.DataType
messageTimeout := msg.DataTime
var messageJson []byte
if messageTimeout == 0 {
messageJson, _ = json.Marshal(Message{
Type: "system_message",
Data: messageContent,
DataType: messageType,
})
} else {
messageJson, _ = json.Marshal(Message{
Type: "system_message",
Data: messageContent,
DataType: messageType,
DataTime: messageTimeout,
})
}
switch messageQualifier {
case "all":
for cli := range c.Hub.Clients {
SendToClient(c.Hub, cli.Id, messageJson)
}
case "include":
for cli := range c.Hub.Clients {
if _, ok := messageUserList[cli.Id]; ok {
SendToClient(c.Hub, cli.Id, messageJson)
}
}
case "except":
for cli := range c.Hub.Clients {
if _, ok := messageUserList[cli.Id]; !ok {
SendToClient(c.Hub, cli.Id, messageJson)
}
}
default:
fmt.Println("No handler exists for system_message qualifier:", messageQualifier)
}
case "set_username":
if username, ok := msg.Data.(string); ok {
//log.Printf("Username request for client %s: %s", c.id, username)
if IsUsernameTaken(c.Hub, username) {
errorMsg := Message{
Type: "username_error",
Error: "Username is already taken. Please choose a different username.",
}
data, _ := json.Marshal(errorMsg)
c.Send <- data
continue
}
if !IsUsernameValid(username) {
errorMsg := Message{
Type: "username_error",
Error: "Invalid username. Please use 3-24 characters, letters, numbers, underscores, or hyphens.",
}
data, _ := json.Marshal(errorMsg)
c.Send <- data
continue
}
c.Username = username
SendUsersList(c.Hub)
} else {
//log.Printf("Invalid username data type: %T", msg.Data)
}
case "webrtc_offer":
// Forward WebRTC offer to target client
data, _ := json.Marshal(Message{
Type: "webrtc_offer",
UserID: c.Id,
Username: c.Username,
Offer: msg.Offer,
})
SendToClient(c.Hub, msg.Target, data)
case "webrtc_answer":
// Forward WebRTC answer to target client
data, _ := json.Marshal(Message{
Type: "webrtc_answer",
UserID: c.Id,
Answer: msg.Answer,
})
SendToClient(c.Hub, msg.Target, data)
case "webrtc_ice":
// Forward ICE candidate to target client
data, _ := json.Marshal(Message{
Type: "webrtc_ice",
UserID: c.Id,
ICE: msg.ICE,
})
SendToClient(c.Hub, msg.Target, data)
case "speaking":
// Broadcast speaking status
broadcastMsg := Message{
Type: "user_speaking",
UserID: c.Id,
Username: c.Username,
Data: msg.Data,
}
data, _ := json.Marshal(broadcastMsg)
c.Hub.Broadcast <- data
case "chat_message":
// Broadcast chat message to all users
if msg.Username != "" {
// Extract message content from Data field
var chatMsg string
if msgData, ok := msg.Data.(map[string]any); ok {
if message, exists := msgData["message"]; exists {
chatMsg, _ = message.(string)
}
}
// Use timestamp from message or current time
timestamp := msg.Timestamp
if timestamp == 0 {
timestamp = time.Now().UnixMilli()
}
if chatMsg != "" {
//log.Printf("Chat message from %s (%s): %s", msg.Username, c.id, chatMsg)
broadcastMsg := Message{
Type: "chat_message",
Username: msg.Username,
Data: map[string]any{
"message": chatMsg,
"timestamp": timestamp,
},
}
data, _ := json.Marshal(broadcastMsg)
c.Hub.Broadcast <- data
} else {
//log.Printf("Empty chat message from client %s", c.id)
}
} else {
//log.Printf("Invalid chat message format from client %s", c.id)
}
}
}
}
// WritePump writes queued messages to the websocket connection and sends periodic pings.
func WritePump(c *server.Client) {
defer func(conn *websocket.Conn) {
_ = conn.Close()
}(c.Conn)
for message := range c.Send {
err := c.Conn.WriteMessage(websocket.TextMessage, message)
if err != nil {
return
}
}
}

View File

@ -1,16 +0,0 @@
{
"server": {
"ipAddress": "192.168.1.222",
"port": 8080
},
"paths": {
"databasePath": "/home/radon/Documents/chattest.db",
"indexJsPath": "./public/index.js",
"indexCssPath": "./public/style.css",
"indexHtmlPath": "./public/index.html"
},
"options": {
"messageMaxAge": 259200,
"nameMaxLength": 32
}
}

326
db/db.go
View File

@ -1,326 +0,0 @@
package db
import (
"database/sql"
"fmt"
_ "github.com/mattn/go-sqlite3"
"strconv"
"time"
)
type User struct {
Id string
Username string
IpAddress string
Timezone string
}
type Message struct {
Id string
SenderIp string
SenderUsername string
Content string
Timestamp string
Edited bool
}
type Database struct {
db *sql.DB
}
func OpenDatabase(filepath string) *Database {
if db, err := sql.Open("sqlite3", filepath); err != nil {
return nil
} else {
return &Database{
db: db,
}
}
}
func (db *Database) Close() {
db.db.Close()
}
func (db *Database) DbCreateTableMessages() {
stmt := `CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ip_address TEXT NOT NULL,
content TEXT NOT NULL,
edited INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)`
db.db.Exec(stmt)
}
func (db *Database) DbCreateTableUsers() {
stmt := `CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ip_address TEXT NOT NULL,
username TEXT NOT NULL UNIQUE,
timezone TEXT DEFAULT 'America/New_York',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)`
db.db.Exec(stmt)
}
func (db *Database) UserTimezoneSet(ip_address, timezone string) {
_, err := db.db.Exec("UPDATE users SET timezone = ? WHERE ip_address = ?", timezone, ip_address)
if err != nil {
fmt.Println(err)
}
}
func (db *Database) UserAdd(ip_address, username string) {
_, err := db.db.Exec("INSERT INTO users (username, ip_address) VALUES (?, ?)", username, ip_address)
if err != nil {
fmt.Println(err)
}
}
func (db *Database) MessageAdd(ip_address string, content string) {
_, err := db.db.Exec("INSERT INTO messages (ip_address, content) VALUES (?, ?)", ip_address, content)
if err != nil {
fmt.Println(err)
}
}
func (db *Database) UserNameGet(ip_address string) string {
rows, err := db.db.Query("SELECT username FROM users WHERE ip_address = ?", ip_address)
if err != nil {
fmt.Println(err)
}
defer rows.Close()
var username string
rows.Next()
rows.Scan(&username)
return username
}
func (db *Database) UserIpGet(username string) string {
rows, err := db.db.Query("SELECT ip_address FROM users WHERE username = ?", username)
if err != nil {
fmt.Println(err)
}
defer rows.Close()
var ip_address string
rows.Next()
rows.Scan(&ip_address)
return ip_address
}
func (db *Database) UserGetTimezone(ip_address string) string {
rows, err := db.db.Query("SELECT timezone FROM users WHERE ip_address = ?", ip_address)
if err != nil {
fmt.Println(err)
}
defer rows.Close()
var timezone string
rows.Next()
rows.Scan(&timezone)
return timezone
}
func (db *Database) UsersGet() []User {
rows, err := db.db.Query("SELECT * FROM users")
if err != nil {
fmt.Println(err)
}
defer rows.Close()
var users []User
for rows.Next() {
var id string
var ip_address string
var username string
var created_at string
var timezone string
rows.Scan(&id, &ip_address, &username, &created_at, &timezone)
user := User{
Id: id,
Username: username,
IpAddress: ip_address,
Timezone: timezone,
}
users = append(users, user)
}
return users
}
func (db *Database) MessagesGet() []Message {
rows, err := db.db.Query(`
SELECT messages.id, messages.ip_address, messages.content,
strftime('%Y-%m-%d %H:%M:%S', messages.created_at) as created_at,
users.username, messages.edited
FROM messages
LEFT JOIN users ON messages.ip_address = users.ip_address;
`)
if err != nil {
fmt.Println(err)
}
defer rows.Close()
var messages []Message
for rows.Next() {
var id string
var content string
var ip_address string
var created_at string
var username string
var edited int
rows.Scan(&id, &ip_address, &content, &created_at, &username, &edited)
editedBool := false
if edited == 1 {
editedBool = true
}
message := Message{
Id: id,
Content: content,
SenderIp: ip_address,
SenderUsername: username,
Edited: editedBool,
Timestamp: created_at,
}
messages = append(messages, message)
}
return messages
}
func (db *Database) UserNameExists(username string) bool {
rows, err := db.db.Query("SELECT * FROM users WHERE username = ?", username)
if err != nil {
fmt.Println(err)
}
defer rows.Close()
return rows.Next()
}
func (db *Database) UserExists(ip string) bool {
rows, err := db.db.Query("SELECT * FROM users WHERE ip_address = ?", ip)
if err != nil {
fmt.Println(err)
}
defer rows.Close()
return rows.Next()
}
func (db *Database) UserNameChange(ip, newUsername string) {
_, err := db.db.Exec("UPDATE users SET username = ? WHERE ip_address = ?", newUsername, ip)
if err != nil {
fmt.Println(err)
}
}
func (db *Database) UserMessagesDelete(ip string) {
_, err := db.db.Exec("DELETE FROM messages WHERE ip_address = ?", ip)
if err != nil {
fmt.Println(err)
}
}
func (db *Database) UserMessagesGet(ip string) []Message {
rows, err := db.db.Query(`
SELECT messages.*, users.username
FROM messages
LEFT JOIN users ON messages.ip_address = users.ip_address
WHERE messages.ip_address = ?
ORDER BY messages.created_at DESC;
`, ip)
if err != nil {
fmt.Println(err)
}
defer rows.Close()
var messages []Message
for rows.Next() {
var id string
var content string
var ip_address string
var created_at string
var username string
var edited int
rows.Scan(&id, &ip_address, &content, &created_at, &username, &edited)
t, _ := time.Parse(created_at, created_at)
editedBool := false
if edited == 1 {
editedBool = true
}
message := Message{
Id: id,
Content: content,
SenderIp: ip_address,
SenderUsername: username,
Edited: editedBool,
Timestamp: t.Format(created_at),
}
messages = append(messages, message)
}
return messages
}
func (db *Database) MessageDeleteId(id string) {
_, err := db.db.Exec("DELETE FROM messages WHERE id = ?", id)
if err != nil {
fmt.Println(err)
}
}
func (db *Database) MessageDeleteIfOwner(id string, ip string) (int, error) {
res, err := db.db.Exec("DELETE FROM messages WHERE id = ? AND ip_address = ?", id, ip)
if err != nil {
return 0, err
}
affected, err := res.RowsAffected()
if err != nil {
return 0, err
}
return int(affected), nil
}
func (db *Database) MessageEditIfOwner(id string, content string, ip string) (int, error) {
res, err := db.db.Exec("UPDATE messages SET content = ?, edited = 1 WHERE id = ? AND ip_address = ?", content, id, ip)
if err != nil {
return 0, err
}
affected, err := res.RowsAffected()
if err != nil {
return 0, err
}
return int(affected), nil
}
func (db *Database) DeleteOldMessages(ageMinutes int) {
if ageMinutes <= 0 {
return
}
age := strconv.Itoa(ageMinutes)
_, err := db.db.Exec("DELETE FROM messages WHERE created_at < datetime('now', ? || ' minutes')", "-"+age)
if err != nil {
fmt.Println(err)
}
}
func (db *Database) UserDeleteIp(ip string) {
_, err := db.db.Exec("DELETE FROM users WHERE ip_address = ?", ip)
if err != nil {
fmt.Println(err)
}
}
func (db *Database) UsersDelete() {
_, err := db.db.Exec("DELETE FROM users")
if err != nil {
fmt.Println(err)
}
}
func (db *Database) MessagesDelete() {
_, err := db.db.Exec("DELETE FROM messages")
if err != nil {
fmt.Println(err)
}
}

33
deploy.sh Executable file
View File

@ -0,0 +1,33 @@
#!/usr/bin/env bash
BINARY_NAME="chatserver"
DEPLOY_USERNAME="root"
DEPLOY_ADDRESS="192.168.1.90"
DEPLOY_PATH="/root/projects/"
main() {
unset SSHPASS
read -sp "Enter password for $DEPLOY_USERNAME@$DEPLOY_ADDRESS: " SSHPASS
echo
export SSHPASS
echo "Building binary..."
go build -o "$BINARY_NAME"
echo "Killing process on remote server..."
sshpass -e ssh "$DEPLOY_USERNAME@$DEPLOY_ADDRESS" -t "pkill $BINARY_NAME"
echo "Deploying files..."
sshpass -e scp -r ./* "$DEPLOY_USERNAME@$DEPLOY_ADDRESS:$DEPLOY_PATH"
echo "Cleaning up local binary..."
rm -f "$BINARY_NAME"
echo "Rebooting remote server..."
sshpass -e ssh "$DEPLOY_USERNAME@$DEPLOY_ADDRESS" -t "reboot"
unset SSHPASS
}
main

18
doc.go Normal file
View File

@ -0,0 +1,18 @@
// Package main contains the RadChat application entrypoint and HTTP handlers.
//
// RadChat is a lightweight, self-hostable voice/text chat with WebRTC for
// peer-to-peer media and WebSockets for control signaling. The package wires up
// HTTP routes for:
// - /ws: WebSocket endpoint for signaling (join/leave, ICE, offers/answers, chat)
// - /user-count: Small health/info endpoint returning connected users
// - /check-username: Validate username rules and collision checks
// - /files: Upload endpoint for ephemeral file sharing
// - /files/{id}: Download endpoint for previously uploaded files
//
// Static assets are served from ./static and include the single-page app (SPA)
// implemented in static/app.js.
//
// The main stateful coordination happens in the server subpackage via the Hub
// and Client types. This package (
// package main) creates, runs, and exposes that hub over HTTP.
package main

6
go.mod
View File

@ -1,5 +1,5 @@
module chat
module radchat
go 1.23.4
go 1.24.6
require github.com/mattn/go-sqlite3 v1.14.24 // direct
require github.com/gorilla/websocket v1.5.3

4
go.sum
View File

@ -1,2 +1,2 @@
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=

386
hub.go Normal file
View File

@ -0,0 +1,386 @@
package main
// HTTP handlers and hub run loop integration for RadChat.
//
// This file contains:
// - WebSocket and HTTP endpoints (username check, user count, file upload/download)
// - Helpers to send messages to specific clients and broadcast user lists
// - The hub Run loop that manages registration, unregistration, and broadcasting
//
// The actual Hub/Client definitions live in the server package. Here we glue
// them to HTTP.
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"path/filepath"
"radchat/server"
"strings"
"time"
)
// IsUsernameTaken returns true if the given username already exists among connected clients (case-insensitive).
func IsUsernameTaken(h *server.Hub, username string) bool {
h.Mutex.RLock()
defer h.Mutex.RUnlock()
for client := range h.Clients {
if client.Username == username {
return true
}
}
return false
}
// HandleUserCountCheck responds with the current number of connected users.
func HandleUserCountCheck(hub *server.Hub, w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
response := map[string]any{
"userCount": int64(len(hub.Clients)),
}
err := json.NewEncoder(w).Encode(response)
if err != nil {
return
}
}
// HandleUsernameCheck validates a requested username and checks for collisions.
func HandleUsernameCheck(hub *server.Hub, w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var request struct {
Username string `json:"username"`
}
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
// Check if username is taken
errorString := ""
isTaken := IsUsernameTaken(hub, request.Username)
if isTaken {
errorString = "Username is already taken. Please choose a different username."
}
isValid := IsUsernameValid(request.Username)
if !isValid {
errorString = "Invalid username. Please use 3-24 characters, letters, numbers, underscores, or hyphens."
}
response := map[string]any{
"available": isValid && !isTaken,
"username": request.Username,
"error": errorString,
}
err := json.NewEncoder(w).Encode(response)
if err != nil {
return
}
}
func SendUsersList(h *server.Hub) {
h.Mutex.RLock()
users := make([]map[string]string, 0)
for client := range h.Clients {
if client.Username != "" {
users = append(users, map[string]string{
"id": client.Id,
"username": client.Username,
})
}
}
h.Mutex.RUnlock()
msg := Message{
Type: "users_list",
Data: users,
}
data, err := json.Marshal(msg)
if err != nil {
return
}
select {
case h.Broadcast <- data:
default:
}
}
func SendToClient(h *server.Hub, targetID string, message []byte) {
h.Mutex.RLock()
defer h.Mutex.RUnlock()
for client := range h.Clients {
if client.Id == targetID {
select {
case client.Send <- message:
default:
close(client.Send)
delete(h.Clients, client)
}
return
}
}
}
func HandleWebSocket(hub *server.Hub, w http.ResponseWriter, r *http.Request) {
conn, err := hub.Upgrader.Upgrade(w, r, nil)
if err != nil {
log.Println("WebSocket upgrade error:", err)
return
}
clientID := r.URL.Query().Get("client_id")
if clientID == "" {
clientID = GenerateId()
log.Println("Client joined. Generated new client ID:", clientID)
} else {
log.Println("Client joined. Used existing client ID:", clientID)
}
client := &server.Client{
Conn: conn,
Id: clientID,
Hub: hub,
Send: make(chan []byte, 256),
}
userIdMsg := map[string]string{
"type": "client_id",
"clientId": clientID,
}
if msgBytes, err := json.Marshal(userIdMsg); err == nil {
client.Send <- msgBytes
}
client.Hub.Register <- client
go WritePump(client)
go ReadPump(client)
}
func HandleFileUpload(h *server.Hub, filesDir string, fileTimeout time.Duration, w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
err := r.ParseMultipartForm(256 << 20)
if err != nil {
http.Error(w, "Error parsing form", http.StatusBadRequest)
return
}
// Get all files
files := r.MultipartForm.File
_ = r.ParseForm()
username := r.FormValue("username")
clientId := r.FormValue("client_id")
var uploadedFiles []string
for _, fileHeaders := range files {
for _, fileHeader := range fileHeaders {
file, err := fileHeader.Open()
if err != nil {
continue
}
fileBytes, err := io.ReadAll(file)
if err != nil {
_ = file.Close()
continue
}
filename := SanitizeFilename(fileHeader.Filename)
// get filename before the extension
fileExt := filepath.Ext(filename)
fileName := strings.TrimSuffix(filename, fileExt)
fileFullName := fileName + "_" + time.Now().Format("20060102150405") + fileExt
filePath := filepath.Join(filesDir, fileFullName)
err = os.WriteFile(filePath, fileBytes, 0644)
if err != nil {
_ = file.Close()
continue
}
uploadedFiles = append(uploadedFiles, fileFullName)
fileFullName = url.PathEscape(fileFullName)
fileLocation := filepath.Join(r.URL.Path, fileFullName)
scheme := GetScheme(r)
fileLink := scheme + "://" + filepath.Join(r.Host, fileLocation)
// find our client and send a message as them
clients := h.Clients
for client := range clients {
if client.Id == clientId {
broadcastMsg := Message{
Type: "chat_message",
Username: username,
Data: map[string]any{
"message": fileLink,
"timestamp": time.Now().UnixMilli(),
},
}
data, _ := json.Marshal(broadcastMsg)
h.Broadcast <- data
break
}
}
go func() {
time.Sleep(fileTimeout)
err := os.Remove(filename)
if err != nil {
return
}
}()
_ = file.Close()
}
}
// Respond with success
response := map[string]interface{}{
"success": true,
"uploaded_files": uploadedFiles,
"count": len(uploadedFiles),
}
w.Header().Set("Content-Type", "application/json")
err = json.NewEncoder(w).Encode(response)
if err != nil {
return
}
}
func HandleFileDownload(filesDir string, w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
path := strings.TrimPrefix(r.URL.Path, "/")
segments := strings.Split(path, "/")
if len(segments) < 2 {
http.Error(w, "Filename required", http.StatusBadRequest)
return
}
filename := segments[len(segments)-1]
filename = SanitizeFilename(filename)
if filename == "" {
http.Error(w, "Invalid filename", http.StatusBadRequest)
return
}
filePath := filepath.Join(filesDir, filename)
absUploadsDir, err := filepath.Abs(filesDir)
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
absFilePath, err := filepath.Abs(filePath)
if err != nil {
http.Error(w, "Invalid file path", http.StatusBadRequest)
return
}
if !strings.HasPrefix(absFilePath, absUploadsDir) {
http.Error(w, "Access denied", http.StatusForbidden)
return
}
if _, err := os.Stat(filePath); os.IsNotExist(err) {
http.Error(w, "File not found", http.StatusNotFound)
return
}
http.ServeFile(w, r, filePath)
}
func Run(h *server.Hub) {
log.Println("Hub started and running...")
for {
select {
case client := <-h.Register:
h.Mutex.Lock()
h.Clients[client] = true
h.Mutex.Unlock()
SendUsersList(h)
case client := <-h.Unregister:
var messageJson []byte
messageJson, _ = json.Marshal(Message{
Type: "system_message",
Data: fmt.Sprintf("%s left the voice chat", client.Username),
DataType: "system_message_leave",
// FIX: Please...
// Using CONFIG.APP.SYSTEM_MSG_DEFAULT_TIMEOUT from javascript config
// Hard coding it here for now
DataTime: 5000,
})
for cli := range client.Hub.Clients {
SendToClient(client.Hub, cli.Id, messageJson)
}
h.Mutex.Lock()
if _, ok := h.Clients[client]; ok {
delete(h.Clients, client)
close(client.Send)
}
h.Mutex.Unlock()
log.Println("Client left. Client ID:", client.Id)
SendUsersList(h)
case message := <-h.Broadcast:
h.Mutex.RLock()
successCount := 0
for client := range h.Clients {
select {
case client.Send <- message:
successCount++
default:
close(client.Send)
delete(h.Clients, client)
}
}
h.Mutex.RUnlock()
}
}
}

88
main.go
View File

@ -1,22 +1,82 @@
package main
// main is the entrypoint for RadChat. It parses flags, prepares storage,
// configures middleware and routes, and starts the HTTP server. See README for
// the flag reference and architecture overview.
import (
"chat/srv"
"flag"
"fmt"
"os"
"log"
"net/http"
"path/filepath"
"radchat/server"
"time"
)
var config srv.Config
func init() {
if len(os.Args) < 2 {
fmt.Printf("Usage: %s <config.json>\n", os.Args[0])
os.Exit(1)
}
config = srv.LoadConfig(os.Args[1])
fmt.Println("Config loaded")
}
func main() {
srv.NewServer(config).Run()
var ip = flag.String("ip", "localhost", "Server address (e.g., 192.168.1.1)")
var port = flag.Int("port", 8080, "Server port")
var bufSize = flag.Int("bufsize", 256, "Channel buffer size")
var gzipEnabled = flag.Bool("gzip-enable", false, "Enable gzip compression")
var cachingDisabled = flag.Bool("cache-disable", false, "Disable caching")
var filesDirectory = flag.String("files", "./files", "Directory to store upload files")
var filesTimeout = flag.Int("files-timeout", 3600, "File timeout in seconds")
var origin = flag.String("origin", "", "Origin to allow (e.g. example.com), leave blank to allow all")
var help = flag.Bool("help", false, "Show help")
flag.Parse()
if *help {
flag.PrintDefaults()
return
}
filesDir := filepath.Clean(*filesDirectory)
_ = DeleteDirContents(filesDir)
err := MakeDir(filesDir)
if err != nil {
log.Fatal(err)
}
address := fmt.Sprintf("%s:%d", *ip, *port)
hub := server.NewHub(*bufSize, server.Upgrader(*origin, *origin == ""))
go Run(hub)
mux := http.NewServeMux()
mux.HandleFunc("/files", func(w http.ResponseWriter, r *http.Request) {
HandleFileUpload(hub, filesDir, time.Duration(*filesTimeout)*time.Second, w, r)
})
mux.HandleFunc("/files/", func(w http.ResponseWriter, r *http.Request) {
HandleFileDownload(filesDir, w, r)
})
mux.HandleFunc("/user-count", func(w http.ResponseWriter, r *http.Request) {
HandleUserCountCheck(hub, w, r)
})
mux.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
HandleWebSocket(hub, w, r)
})
mux.HandleFunc("/check-username", func(w http.ResponseWriter, r *http.Request) {
HandleUsernameCheck(hub, w, r)
})
var middleware []func(http.Handler) http.Handler
if *cachingDisabled {
middleware = append(middleware, server.CacheDisableMiddleware)
}
if *gzipEnabled {
middleware = append(middleware, server.GzipMiddleware)
}
fileServer := http.FileServer(http.Dir("./static/"))
mux.Handle("/", server.MiddlewareChain(middleware...)(fileServer))
log.Printf("Voice chat server starting on %s...\n", address)
log.Fatal(http.ListenAndServe(address, mux))
}

125
message.go Normal file
View File

@ -0,0 +1,125 @@
package main
// Message is a generic envelope exchanged over the WebSocket signaling channel.
//
// Type is the message kind (e.g., "join", "leave", "chat", "offer",
// "answer", "ice", etc.). Other fields are used depending on Type; unused
// fields are omitted in JSON.
//
// The flexibility helps keep the client and server loosely coupled; consider
// introducing stricter typed payloads if this grows.
//
// Note: This struct is intentionally broad to carry both chat and WebRTC
// signaling payloads.
//
// Timestamp is set by the sender (usually server) for ordering.
// Target is a peer client ID for directed messages.
// Data/DataExt/DataList support heterogeneous payloads where necessary.
// Offer/Answer/ICE carry WebRTC SDP/ICE candidate info.
// DataType can be used by clients to dispatch UI behavior.
// DataTime is optional timing metadata.
//
// Username/UserID are the sender identity.
// Error holds error information if Type indicates a failure.
//
// All fields are optional in JSON except Type.
// Keep in sync with frontend parsing in static/app.js
// to avoid breaking changes.
//
// Consider versioning if wire format evolves.
//
//nolint:lll // wide field tags
//
//
// Message struct definition:
//
// - Type: message category
// - Username, UserID: identity
// - Data/DataExt/DataList: generic payloads
// - Offer/Answer/ICE: WebRTC signaling
// - Target: directed recipient id
// - Error: error description if any
// - Timestamp: unix ms or ns
//
// The JSON tags omit empty fields to minimize bandwidth.
//
// End of doc.
//
//
//
//
//
//
//
//
//
//
//
//
//
//
// Actual type follows.
//
//
//
//
//
//
//
//
//
//
//
//
//
//
//
//
//
//
//
//
//
//
//
//
//
//
//
//
//
//
//
//
//
//
//
//
//
//
//
//
//
//
//
//
//
//
//
type Message struct {
Type string `json:"type"`
Username string `json:"username,omitempty"`
UserID string `json:"userId,omitempty"`
Data any `json:"data,omitempty"`
DataExt any `json:"dataExt,omitempty"`
DataType string `json:"dataType,omitempty"`
DataTime int64 `json:"dataTime,omitempty"`
DataList []any `json:"dataList,omitempty"`
Offer any `json:"offer,omitempty"`
Answer any `json:"answer,omitempty"`
ICE any `json:"ice,omitempty"`
Target string `json:"target,omitempty"`
Error string `json:"error,omitempty"`
Timestamp int64 `json:"timestamp,omitempty"`
}

View File

@ -1,86 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<meta http-equiv="Cache-Control" content="max-age=86400, must-revalidate">
<meta http-equiv="Pragma" content="cache">
<meta http-equiv="Expires" content="86400">
<title>RadChat</title>
<script src="root.js"></script>
<link rel="stylesheet" href="root.css">
</head>
<body>
<div class="container">
<div class="radchat">
RadChat
</div>
<div class="header-controls">
<button class="users-button" onclick="toggleUsers()">
<svg class="users-icon" viewBox="0 0 24 24">
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/>
</svg>
</button>
<button class="settings-button" onclick="toggleSettings()">
<svg class="settings-icon" viewBox="0 0 24 24">
<path d="M12 15.5A3.5 3.5 0 0 1 8.5 12 3.5 3.5 0 0 1 12 8.5a3.5 3.5 0 0 1 3.5 3.5 3.5 3.5 0 0 1-3.5 3.5m7.43-2.53c.04-.32.07-.65.07-.97 0-.32-.03-.66-.07-.98l2.11-1.65c.19-.15.24-.42.12-.64l-2-3.46c-.12-.22-.39-.3-.61-.22l-2.49 1c-.52-.4-1.08-.73-1.69-.98l-.38-2.65C14.46 2.18 14.25 2 14 2h-4c-.25 0-.46.18-.49.42l-.38 2.65c-.61.25-1.17.59-1.69.98l-2.49-1c-.23-.09-.49 0-.61.22l-2 3.46c-.13.22-.07.49.12.64l2.11 1.65c-.04.32-.07.65-.07.98 0 .33.03.66.07.98l-2.11 1.65c-.19.15-.24.42-.12.64l2 3.46c.12.22.39.3.61.22l2.49-1c.52.4 1.08.73 1.69.98l.38 2.65c.03.24.24.42.49.42h4c.25 0 .46-.18.49-.42l.38-2.65c.61-.25 1.17-.59 1.69-.98l2.49 1c.23.09.49 0 .61-.22l2-3.46c.12-.22.07-.49-.12-.64l-2.11-1.65z"/>
</svg>
</button>
<button class="theme-button" onclick="toggleTheme()">
<svg class="theme-icon" viewBox="0 0 24 24">
<path class="moon" d="M12 3c-4.97 0-9 4.03-9 9s4.03 9 9 9 9-4.03 9-9c0-.46-.04-.92-.1-1.36-.98 1.37-2.58 2.26-4.4 2.26-3.03 0-5.5-2.47-5.5-5.5 0-1.82.89-3.42 2.26-4.4-.44-.06-.9-.1-1.36-.1z"/>
<path class="sun" d="M12 7c-2.76 0-5 2.24-5 5s2.24 5 5 5 5-2.24 5-5-2.24-5-5-5zM2 13h2c.55 0 1-.45 1-1s-.45-1-1-1H2c-.55 0-1 .45-1 1s.45 1 1 1zm18 0h2c.55 0 1-.45 1-1s-.45-1-1-1h-2c-.55 0-1 .45-1 1s.45 1 1 1zM11 2v2c0 .55.45 1 1 1s1-.45 1-1V2c0-.55-.45-1-1-1s-1 .45-1 1zm0 18v2c0 .55.45 1 1 1s1-.45 1-1v-2c0-.55-.45-1-1-1s-1 .45-1 1zM5.99 4.58c-.39-.39-1.03-.39-1.41 0-.39.39-.39 1.03 0 1.41l1.06 1.06c.39.39 1.03.39 1.41 0s.39-1.03 0-1.41L5.99 4.58zm12.37 12.37c-.39-.39-1.03-.39-1.41 0-.39.39-.39 1.03 0 1.41l1.06 1.06c.39.39 1.03.39 1.41 0 .39-.39.39-1.03 0-1.41l-1.06-1.06zm1.06-10.96c.39-.39.39-1.03 0-1.41-.39-.39-1.03-.39-1.41 0l-1.06 1.06c-.39.39-.39 1.03 0 1.41s1.03.39 1.41 0l1.06-1.06zM7.05 18.36c.39-.39.39-1.03 0-1.41-.39-.39-1.03-.39-1.41 0l-1.06 1.06c-.39.39-.39 1.03 0 1.41s1.03.39 1.41 0l1.06-1.06z"/>
</svg>
</button>
<div class="search-trigger">
<div class="search-container" id="searchContainer">
<button class="search-button" id="searchButton">
<svg class="search-icon" viewBox="0 0 24 24">
<path d="M15.5 14h-.79l-.28-.27a6.5 6.5 0 1 0-.7.7l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0A4.5 4.5 0 1 1 14 9.5 4.5 4.5 0 0 1 9.5 14z"/>
</svg>
</button>
<input
type="text"
class="search-input"
id="searchInput"
placeholder="Type to search page..."
>
<span class="search-count" id="searchCount"></span>
</div>
</div>
</div>
<div id="current-user" class="current-user"></div>
<div class="users-section" id="users-panel">
<h3>Online Users</h3>
<div id="users-list">
<!-- Users will be loaded here -->
</div>
</div>
<div class="username-section" id="settings-panel">
<h3>Username</h3>
<div style="display: flex; gap: 10px;">
<input type="text" id="username" placeholder="Enter username">
<button onclick="setUsername()">Set</button>
</div>
<div id="username-status"></div>
</div>
<div class="messages-section" id="messages">
<!-- Messages will be loaded here -->
</div>
</div>
<div class="message-section">
<div class="message-container">
<textarea id="message" placeholder="Type a message (use shift+return for a new line)" rows="1"></textarea>
<button onclick="sendMessage()">Send</button>
<button id="scroll" class="scroll"><svg class="scroll-icon" id="scroll-icon" viewBox="0 0 24 24">
<path d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z"/>
</svg></button>
</div>
<div id="status"></div>
</div>
</body>
</html>

View File

@ -1,831 +0,0 @@
function toggleSettings() {
const panel = document.getElementById("settings-panel");
panel.style.display = panel.style.display === "block"
? "none"
: "block";
if (panel.style.display === "block") {
const username = document.getElementById("username");
username.focus();
username.selectionStart = username.selectionEnd = username.value
.length;
}
}
function toggleUsers() {
const panel = document.getElementById("users-panel");
panel.style.display = panel.style.display === "block"
? "none"
: "block";
}
function toggleTheme() {
const currentTheme = document.documentElement.getAttribute(
"data-theme",
);
const newTheme = currentTheme === "dark" ? "light" : "dark";
document.documentElement.setAttribute("data-theme", newTheme);
localStorage.setItem("theme", newTheme);
}
function replaceDiv(oldDiv, newDiv) {
oldDiv.parentNode.replaceChild(newDiv, oldDiv);
}
function cancelEditMessage(messageId) {
const editMessageDiv = document.getElementById(
".edit-message@" + messageId,
);
replaceDiv(editMessageDiv, editMessageOriginalDiv);
editMessageOriginalDiv = null;
editing = false;
editMessageNumber = null;
}
async function submitEditMessage(messageId) {
const editMessageDiv = document.getElementById(
".edit-message@" + messageId,
);
const content =
editMessageOriginalDiv.querySelector(".content").textContent;
const newContent =
editMessageDiv.querySelector(".edit-message-textarea").value;
if (content === null) {
cancelEditMessage(messageId);
return;
}
if (content === "") {
cancelEditMessage(messageId);
deleteMessage(messageId);
return;
}
if (newContent === content) {
cancelEditMessage(messageId);
return;
}
try {
const response = await fetch("/messages", {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
messageId: messageId,
messageContent: newContent,
}),
});
if (response.ok) {
editing = false;
editMessageNumber = null;
editMessageOriginalDiv = null;
updateMessagesInPlace();
} else {
console.error("Failed to edit message");
}
} catch (error) {
console.error("Error editing message:", error);
}
}
editMessageOriginalDiv = null;
editMessageNumber = null;
editing = false;
async function editMessage(messageId) {
if (editing) {
cancelEditMessage(editMessageNumber);
}
editing = true;
editMessageNumber = messageId;
const originalMessageDiv = document.getElementById(
".message@" + messageId,
);
const height = originalMessageDiv.scrollHeight;
const queryContent = originalMessageDiv.querySelector(".content");
// we need to replace all the html elements with their text content, this includes links like img, a, iframe
queryContent.querySelectorAll("a").forEach((link) => {
link.textContent = link.href;
});
queryContent.querySelectorAll("img").forEach((img) => {
img.textContent = img.src;
});
queryContent.querySelectorAll("iframe").forEach((iframe) => {
iframe.textContent = iframe.src;
});
const content =
originalMessageDiv.querySelector(".content").textContent;
editMessageOriginalDiv = originalMessageDiv.cloneNode(true);
const editMessageDiv = document.createElement("div");
const editTextArea = document.createElement("textarea");
editTextArea.className = "edit-message-textarea";
editTextArea.id = "edit-message-textarea";
editTextArea.value = content;
editTextArea.style.height = height + "px";
editMessageDiv.className = "edit-message";
editMessageDiv.id = ".edit-message@" + messageId;
editMessageDiv.innerHTML += `<div class="button-container">` +
`<button class="edit-message-button" onclick="submitEditMessage('${messageId}')">Submit</button>` +
`<button class="edit-message-button" onclick="cancelEditMessage('${messageId}')">Cancel</button>` +
`</div>`;
editMessageDiv.appendChild(editTextArea);
replaceDiv(originalMessageDiv, editMessageDiv);
}
async function deleteMessage(messageId) {
if (confirm("Delete this message?")) {
try {
const response = await fetch("/messages", {
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ messageId: messageId }),
});
if (response.ok) {
updateMessagesInPlace();
} else {
console.error("Failed to delete message");
}
} catch (error) {
console.error("Error deleting message:", error);
}
}
}
async function updateMessagesInPlace() {
lastMessageCount = await getMessageCount();
const currentScrollLocation = getScrollLocation();
loadMessages(true, currentScrollLocation);
}
function getScrollLocation() {
const messagesDiv = document.getElementById("messages");
return messagesDiv.scrollTop;
}
function setScrollLocation(height) {
const messagesDiv = document.getElementById("messages");
messagesDiv.scrollTop = height;
}
document.addEventListener("click", function(event) {
const settingsPanel = document.getElementById("settings-panel");
const settingsButton = document.querySelector(".settings-button");
const usersPanel = document.getElementById("users-panel");
const usersButton = document.querySelector(".users-button");
if (
!settingsPanel.contains(event.target) &&
!settingsButton.contains(event.target)
) {
settingsPanel.style.display = "none";
}
if (
!usersPanel.contains(event.target) &&
!usersButton.contains(event.target)
) {
usersPanel.style.display = "none";
}
});
document.addEventListener("keypress", function(event) {
if (event.key === "Enter" && !event.shiftKey) {
const settingsPanel = document.getElementById("settings-panel");
const inputPanel = document.getElementById("message");
if (settingsPanel.contains(event.target)) {
setUsername();
}
if (inputPanel.contains(event.target)) {
event.preventDefault();
sendMessage();
}
}
});
document.addEventListener("input", function(_) {
const msg = document.getElementById("message");
msg.style.height = "auto";
msg.style.height = (msg.scrollHeight) + "px";
});
document.addEventListener("blur", function(_) {
const msg = document.getElementById("message");
msg.style.height = "auto";
}, true);
async function loadUsers() {
try {
const response = await fetch("/users");
const data = await response.json();
const usersList = document.getElementById("users-list");
usersList.innerHTML = "";
data.users.sort().forEach((user) => {
const userDiv = document.createElement("div");
userDiv.className = "user-item";
userDiv.textContent = user;
usersList.appendChild(userDiv);
});
} catch (error) {
console.error("Error loading users:", error);
}
}
async function getMessageCount() {
try {
const response = await fetch("/messages/length");
const text = await response.json();
return text.length;
} catch (error) {
console.error("Error getting message count:", error);
}
}
let lastMessageCount = 0;
async function loadMessages(forceUpdate = false, scrollLocation) {
if (editing) {
return;
}
try {
const newMessageCount = await getMessageCount();
const update = newMessageCount != lastMessageCount ||
lastMessageCount === 0 || forceUpdate;
let messagesDiv = document.getElementById("messages");
while (messagesDiv === null) {
await new Promise((resolve) =>
setTimeout(resolve, 100)
);
messagesDiv = document.getElementById(
"messages",
);
}
if (messagesDiv.scrollTop != bottom) {
// show a button to scroll to the bottom
const scrollToBottomButton = document
.getElementById(
"scroll",
);
scrollToBottomButton.style.display = "block";
scrollToBottomButton.onclick = scrollToBottom;
} else {
// hide the button
const scrollToBottomButton = document
.getElementById(
"scroll",
);
scrollToBottomButton.style.display = "none";
}
if (update) {
const response = await fetch("/messages");
const text = await response.text();
const tempDiv = document.createElement("div");
tempDiv.innerHTML = text;
const messages = tempDiv.getElementsByTagName("p");
messagesDiv.innerHTML = "";
Array.from(messages).forEach((msg) => {
const messageDiv = document.createElement(
"div",
);
messageDiv.className = "message";
const [
messageId,
username,
timestamp,
content,
] = msg
.innerHTML.split(
"<br>",
);
messageDiv.id = ".message@" + messageId;
const usernameDiv = document.createElement(
"div",
);
usernameDiv.innerHTML = username;
compareUsername = usernameDiv.textContent;
const embeddedContent = contentEmbedding(
content,
);
let deleteHtml = "";
let editHtml = "";
const parser = new DOMParser();
const contentHtmlString = content;
const doc = parser.parseFromString(
contentHtmlString,
"text/html",
);
const contentString =
doc.querySelector("span").textContent;
const isMultiline = contentString.match(
"\\n",
) && true || false;
if (
compareUsername ===
document.getElementById(
"current-user",
).textContent
) {
deleteHtml =
`<button class="delete-button" title="Delete message" onclick="deleteMessage('${messageId}')" style="display: inline;">🗑️</button>`;
editHtml =
`<button class="edit-button" title="Edit message" onclick="editMessage('${messageId}')" style="display: inline;">📝</button>`;
}
messageDiv.innerHTML = `
<div class="message-header">
<div class="username">${username} ${timestamp} ${deleteHtml} ${editHtml}</div>
</div>
<div class="content">${embeddedContent}</div>`;
messagesDiv.appendChild(messageDiv);
});
if (scrollLocation !== undefined) {
setScrollLocation(scrollLocation);
} else {
scrollToBottom();
}
lastMessageCount = newMessageCount;
}
} catch (error) {
console.error("Error loading messages:", error);
}
}
function contentEmbedding(content) {
return content.replace(
/(?![^<]*>)(https?:\/\/[^\s<]+)/g,
function(url) {
const videoId = getYouTubeID(
url,
);
if (videoId) {
return `<div class="video-embed"><iframe
width="100%"
height="315"
src="https://www.youtube.com/embed/${videoId}"
frameborder="0"
onerror="console.log('Video failed to load:', this.src); this.value=this.src"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen></iframe></div>`;
} else if (isImageUrl(url)) {
return `<div class="image-embed"><img
src="${url}"
alt="${url}"
loading="lazy"
onerror="console.log('Image failed to load:', this.src); this.value=this.src"></div>`;
}
return `<a href="${url}" target="_blank" rel="noopener noreferrer">${url}</a>`;
},
);
}
function isImageUrl(url) {
return url.match(/\.(jpeg|jpg|gif|png|webp|bmp)($|\?)/i) != null;
}
function getYouTubeID(url) {
// First check if it's a Shorts URL
if (url.includes("/shorts/")) {
const shortsMatch = url.match(/\/shorts\/([^/?]+)/);
return shortsMatch ? shortsMatch[1] : false;
}
// Otherwise check regular YouTube URLs
const regExp =
/^.*((youtu.be\/)|(v\/)|(\/u\/\w\/)|(embed\/)|(watch\?))\??v?=?([^#&?]*).*/;
const match = url.match(regExp);
return (match && match[7].length == 11) ? match[7] : false;
}
function scrollToBottom() {
const messagesDiv = document.getElementById("messages");
messagesDiv.scrollTop = messagesDiv.scrollHeight;
bottom = messagesDiv.scrollTop;
}
async function checkUsername() {
try {
const response = await fetch("/username/status");
const data = await response.json();
if (!data.hasUsername) {
document.getElementById("settings-panel").style
.display = "block";
const username = document.getElementById("username");
username.focus();
username.selectionStart =
username.selectionEnd =
username.value.length;
}
} catch (error) {
console.error("Error checking username status:", error);
}
}
async function updateCurrentUser() {
try {
const response = await fetch("/username/status");
const data = await response.json();
const userDiv = document.getElementById("current-user");
if (data.hasUsername) {
userDiv.textContent = data.username;
} else {
userDiv.textContent = "";
}
} catch (error) {
console.error("Error getting username:", error);
}
}
async function setUsername() {
const username = document.getElementById("username").value;
if (!username) {
showUsernameStatus("Please enter a username", "red");
return;
}
try {
const response = await fetch("/username", {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ username: username }),
});
const data = await response.json();
if (response.ok) {
showUsernameStatus(
"Username set successfully!",
"green",
);
updateCurrentUser();
setTimeout(() => {
document.getElementById("settings-panel").style
.display = "none";
}, 750);
updateMessagesInPlace();
} else {
showUsernameStatus(
data.error || "Failed to set username",
"red",
);
}
} catch (error) {
showUsernameStatus("Error connecting to server", "red");
}
}
let lastMessage = "";
async function sendMessage() {
const messageInput = document.getElementById("message");
const message = messageInput.value;
if (!message) {
return;
}
try {
lastMessage = message;
const response = await fetch("/messages", {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ message: message }),
});
const data = await response.json();
if (response.ok) {
messageInput.value = "";
messageInput.style.height = "auto";
loadMessages(true);
} else {
showStatus(
data.error || "Failed to send message",
"red",
);
}
} catch (error) {
showStatus("Error connecting to server", "red");
}
}
function showStatus(message, color) {
const status = document.getElementById("status");
status.textContent = message;
status.style.color = color;
setTimeout(() => {
status.textContent = "";
}, 3000);
}
function showUsernameStatus(message, color) {
const status = document.getElementById("username-status");
status.textContent = message;
status.style.color = color;
setTimeout(() => {
status.textContent = "";
}, 3000);
}
async function pingCheck() {
try {
await fetch("/ping", { method: "POST" });
} catch (error) {
console.error("Ping failed:", error);
}
}
async function timeZoneCheck() {
try {
const timeZone =
Intl.DateTimeFormat().resolvedOptions().timeZone;
const response = await fetch("/timezone", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ timezone: timeZone }),
});
const data = await response.json();
if (!response.ok) {
console.error("Failed to set timezone:", data.error);
}
} catch (error) {
console.error("Error checking timezone:", error);
}
}
function initializeTheme() {
const savedTheme = localStorage.getItem("theme");
if (savedTheme) {
document.documentElement.setAttribute("data-theme", savedTheme);
} else {
// Check system preference
if (
window.matchMedia &&
window.matchMedia("(prefers-color-scheme: light)")
.matches
) {
document.documentElement.setAttribute(
"data-theme",
"light",
);
} else {
document.documentElement.setAttribute(
"data-theme",
"dark",
);
}
}
}
document.addEventListener("keyup", function(event) {
const inputPanel = document.getElementById("message");
if (inputPanel.contains(event.target) && event.key === "ArrowUp") {
if (inputPanel.value === "") {
inputPanel.value = lastMessage;
}
}
});
let bottom = 0;
async function initialize() {
await checkUsername();
await updateCurrentUser();
await timeZoneCheck();
await loadMessages(true);
initializePanels();
initializeTheme();
initializeSearchBox();
setInterval(loadMessages, 1000);
setInterval(loadUsers, 1000);
setInterval(pingCheck, 3000);
setTimeout(scrollToBottom, 100);
}
function initializePanels() {
usersPanel = document.getElementById("users-panel");
if (usersPanel) {
usersPanel.style.display = "none";
}
settingsPanel = document.getElementById("settings-panel");
if (settingsPanel) {
settingsPanel.style.display = "none";
}
}
function initializeSearchBox() {
const searchContainer = document.getElementById("searchContainer");
const searchButton = document.getElementById("searchButton");
const searchInput = document.getElementById("searchInput");
const searchCount = document.getElementById("searchCount");
let currentMatchIndex = -1;
let matches = [];
function escapeRegExp(string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function clearHighlights() {
// Remove all existing highlights
const highlights = document.querySelectorAll(
".search-highlight",
);
highlights.forEach((highlight) => {
const parent = highlight.parentNode;
parent.replaceChild(
document.createTextNode(highlight.textContent),
highlight,
);
parent.normalize();
});
matches = [];
currentMatchIndex = -1;
searchCount.textContent = "";
}
function findTextNodes(element, textNodes = []) {
// Skip certain elements
if (element.nodeType === Node.ELEMENT_NODE) { // Check if it's an element node first
if (
element.tagName === "SCRIPT" ||
element.tagName === "STYLE" ||
element.tagName === "NOSCRIPT" ||
(element.classList &&
element.classList.contains(
"search-container",
)) ||
(element.classList &&
element.classList.contains(
"search-highlight",
))
) {
return textNodes;
}
}
// Check if this node is a text node with non-whitespace content
if (
element.nodeType === Node.TEXT_NODE &&
element.textContent.trim()
) {
textNodes.push(element);
}
// Recursively check all child nodes
const children = element.childNodes;
for (let i = 0; i < children.length; i++) {
findTextNodes(children[i], textNodes);
}
return textNodes;
}
function findAndHighlight(searchText) {
if (!searchText) {
clearHighlights();
return;
}
clearHighlights();
const searchRegex = new RegExp(escapeRegExp(searchText), "gi");
const textNodes = findTextNodes(document.body);
textNodes.forEach((node) => {
const matches = [
...node.textContent.matchAll(searchRegex),
];
if (matches.length > 0) {
const span = document.createElement("span");
span.innerHTML = node.textContent.replace(
searchRegex,
(match) =>
`<span class="search-highlight">${match}</span>`,
);
node.parentNode.replaceChild(span, node);
}
});
// Collect all highlights
matches = Array.from(
document.querySelectorAll(".search-highlight"),
);
if (matches.length > 0) {
currentMatchIndex = 0;
matches[0].classList.add("current");
matches[0].scrollIntoView({
behavior: "smooth",
block: "center",
});
}
// Update count
searchCount.textContent = matches.length > 0
? `${currentMatchIndex + 1}/${matches.length}`
: "No matches";
}
function nextMatch() {
if (matches.length === 0) return;
matches[currentMatchIndex].classList.remove("current");
currentMatchIndex = (currentMatchIndex + 1) % matches.length;
matches[currentMatchIndex].classList.add("current");
matches[currentMatchIndex].scrollIntoView({
behavior: "smooth",
block: "center",
});
searchCount.textContent = `${currentMatchIndex + 1
}/${matches.length}`;
}
searchButton.addEventListener("click", () => {
if (!searchContainer.classList.contains("expanded")) {
searchContainer.classList.add("expanded");
searchInput.focus();
} else {
nextMatch();
}
});
searchInput.addEventListener("input", (e) => {
findAndHighlight(e.target.value.trim());
});
searchInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
nextMatch();
} else if (e.key === "Escape") {
searchContainer.classList.remove("expanded");
searchInput.value = "";
clearHighlights();
}
});
// Debug function to check search coverage (call from console: checkSearchCoverage())
window.checkSearchCoverage = function() {
const textNodes = findTextNodes(document.body);
console.log(
"Total searchable text nodes found:",
textNodes.length,
);
textNodes.forEach((node, i) => {
console.log(`Node ${i + 1}:`, {
text: node.textContent,
parent: node.parentElement.tagName,
path: getNodePath(node),
});
});
};
function getNodePath(node) {
const path = [];
while (node && node.parentElement) {
let index = Array.from(node.parentElement.childNodes)
.indexOf(node);
path.unshift(`${node.parentElement.tagName}[${index}]`);
node = node.parentElement;
}
return path.join(" > ");
}
// Close search when clicking outside
document.addEventListener("click", (e) => {
if (!searchContainer.contains(e.target)) {
searchContainer.classList.remove("expanded");
searchInput.value = "";
clearHighlights();
}
});
}
initialize();

View File

@ -1,589 +0,0 @@
[data-theme="dark"] {
--radchat-color: #40a02b;
--main-bg-color: #11111b;
--pum-button-inactive-fg: #cdd6f4;
--pum-button-inactive-bg: #11111b;
--pum-button-active-fg: #89b4fa;
--pum-button-active-bg: #1e1e2e;
--pum-title-color: #cdd6f4;
--pum-bg-color: #1e1e2e;
--user-color: #89b4fa;
--timestamp-color: #313244;
--separator-color: #181825;
--message-color: #cdd6f4;
--message-bg-color: #1e1e2e;
--input-bg-color: #181825;
--input-text-color: #cdd6f4;
--input-button-inactive-bg: #b4befe;
--input-button-inactive-fg: #11111b;
--input-button-active-bg: #89b4fa;
--input-button-active-fg: #11111b;
}
[data-theme="light"] {
--radchat-color: #40a02b;
--main-bg-color: #dce0e8;
--pum-button-inactive-fg: #4c4f69;
--pum-button-inactive-bg: #dce0e8;
--pum-button-active-fg: #1e66f5;
--pum-button-active-bg: #eff1f5;
--pum-title-color: #4c4f69;
--pum-bg-color: #eff1f5;
--user-color: #1e66f5;
--timestamp-color: #8c8fa1;
--separator-color: #e6e9ef;
--message-color: #4c4f69;
--message-bg-color: #eff1f5;
--input-bg-color: #e6e9ef;
--input-text-color: #4c4f69;
--input-button-inactive-bg: #7287fd;
--input-button-inactive-fg: #dce0e8;
--input-button-active-bg: #1e66f5;
--input-button-active-fg: #dce0e8;
}
.edit-message {
display: flex;
flex-direction: column;
margin-top: 10px;
}
.edit-message textarea {
order: 1; /* Make textarea come first */
padding: 10px;
border: none;
border-radius: 4px;
background-color: var(--input-bg-color);
color: var(--input-text-color);
resize: none;
line-height: 1.4;
margin-bottom: 10px;
}
.edit-message .button-container {
order: 2; /* Make buttons container come second */
display: flex;
gap: 10px;
}
.edit-message button {
padding: 10px 20px;
border: none;
border-radius: 4px;
background-color: var(--input-button-inactive-bg);
color: var(--input-button-inactive-fg);
cursor: pointer;
}
.search-trigger {
position: fixed;
bottom: 20px;
right: 20px;
z-index: 1000;
}
.search-container {
position: absolute;
display: inline;
align-items: center;
background: var(--pum-button-inactive-bg);
border-radius: 24px;
transition: width 0.2s ease;
width: 24px;
height: 24px;
}
.search-container.expanded {
width: 300px;
}
.search-input {
display: none;
flex: 2;
border: none;
padding: 8px;
margin-left: 4px;
outline: none;
font-size: 14px;
}
.search-container.expanded .search-input {
display: block;
}
.search-highlight {
background-color: yellow;
color: gray;
}
.search-highlight.current {
background-color: darkorange;
color: black;
}
.search-count {
display: none;
margin-left: 8px;
font-size: 12px;
color: var(--pum-title-color);
}
.search-container.expanded .search-count {
display: block;
}
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 0; /* Remove padding from body */
background-color: var(--main-bg-color);
color: var(--pum-title-color);
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden; /* Prevent body scroll */
}
.container {
max-width: 800px;
margin: 0 auto;
width: 100%;
position: relative;
height: 100vh;
padding: 20px; /* Move padding here */
padding-bottom: 80px; /* Space for message input */
display: flex;
flex-direction: column;
}
.header-controls {
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-65%);
display: flex;
gap: 20px; /* Space between buttons */
align-items: center;
justify-content: center;
}
.header-controls .search-trigger {
position: relative;
right: 0%;
}
/* Show/hide appropriate icon based on theme */
[data-theme="light"] .theme-icon .sun { display: none; }
[data-theme="dark"] .theme-icon .moon { display: none; }
.settings-button, .users-button, .theme-button, .search-button {
top: 20px;
background: none;
border: none;
cursor: pointer;
padding: 5px;
border-radius: 50%;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--pum-button-inactive-bg);
}
.message-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-right: 8px;
}
.delete-button {
background: none;
border: none;
cursor: pointer;
color: var(--timestamp-color);
padding: 2px 6px;
font-size: 12px;
opacity: 0;
transition: opacity 0.3s;
}
.edit-button {
background: none;
border: none;
cursor: pointer;
color: var(--timestamp-color);
padding: 2px 6px;
font-size: 12px;
opacity: 0;
transition: opacity 0.3s;
}
.message:hover .edit-button {
opacity: 1;
}
.message:hover .delete-button {
opacity: 1;
}
.delete-button:hover {
background: var(--timestamp-color);
}
.edit-button:hover {
background: var(--timestamp-color);
}
.settings-icon, .users-icon, .theme-icon, .search-icon {
width: 24px;
height: 24px;
fill: var(--pum-button-inactive-fg);
}
.settings-button:hover, .users-button:hover, .theme-button:hover, .search-button:hover {
background-color: var(--pum-button-active-bg);
}
.settings-button:hover .settings-icon,
.users-button:hover .users-icon,
.theme-button:hover .theme-icon,
.search-button:hover .search-icon {
fill: var(--pum-button-active-fg);
}
#users-list {
margin-top: 0px;
}
.current-user {
position: absolute;
right: 0;
top: 28px;
color: var(--user-color);
font-weight: bold;
}
.username-section, .users-section {
position: fixed;
transform: translateX(-50%);
top: 70px;
padding: 15px;
background-color: var(--pum-bg-color);
border-radius: 5px;
box-shadow: 0 2px 10px rgba(0,0,0,0.3);
z-index: 100;
display: none;
min-width: 250px;
}
.username-section {
right: 50%;
transform: translateX(10%);
}
.username-section h3 {
margin-top: 0;
margin-bottom: 20px;
}
.users-section {
right: 50%;
transform: translateX(-10%);
}
.users-section h3 {
margin-top: 0;
margin-bottom: 10px;
}
.user-item {
padding: 8px 0;
color: var(--user-color);
border-bottom: 1px solid var(--separator-color);
}
.user-item:last-child {
border-bottom: none;
}
.messages-section {
flex-grow: 1;
margin-top: 60px; /* Space for gear icon */
margin-bottom: 60px;
overflow-y: auto;
height: 0; /* Allow flex-grow to work */
}
.message {
padding: 10px 0;
}
.message .username {
color: var(--user-color);
font-weight: bold;
}
.message .timestamp {
color: var(--timestamp-color);
font-weight: thin;
font-size: 0.8em;
}
.message .content {
margin-top: 5px;
color: var(--message-color);
word-wrap: break-word; /* Handle long words */
word-break: break-word; /* Break words at arbitrary points if needed */
white-space: pre-wrap; /* Preserve whitespace and wraps */
max-width: 100%; /* Ensure it doesn't exceed container width */
}
.message-section {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 20px;
background-color: var(--message-bg-color);
z-index: 100; /* Ensure it stays on top */
}
.message-container {
max-width: 800px;
margin: 0 auto;
display: flex;
gap: 10px;
}
.video-embed {
position: inline;
padding-top: 10px;
width: 100%;
max-width: 560px; /* Standard YouTube width */
}
.video-embed iframe {
border-radius: 4px;
}
.image-embed {
padding-top: 10px;
max-width: 560px; /* Match YouTube width */
}
.image-embed img {
max-width: 100%;
height: auto;
border-radius: 4px;
display: block;
}
input {
padding: 10px;
border: none;
border-radius: 4px;
background-color: var(--input-bg-color);
color: var(--input-text-color);
flex-grow: 1;
}
textarea {
padding: 10px;
border: none;
border-radius: 4px;
background-color: var(--input-bg-color);
color: var(--input-text-color);
flex-grow: 1;
resize: none; /* Prevents manual resizing */
min-height: 38px; /* Match your previous input height */
line-height: 1.4;
font-family: Arial, sans-serif; /* Match your body font */
}
button {
padding: 10px 20px;
border: none;
border-radius: 4px;
background-color: var(--input-button-inactive-bg);
color: var(--input-button-inactive-fg);
cursor: pointer;
}
button:hover {
background-color: var(--input-button-active-bg);
color: var(--input-button-active-fg)
}
button.scroll {
padding: 10px 20px;
border-radius: 4px;
border: none;
background-color: var(--input-button-inactive-bg);
fill: var(--input-button-inactive-fg);
padding: 10px;
cursor: pointer;
}
button.scroll:hover {
background-color: var(--input-button-active-bg);
}
.scroll-icon {
width: 14px;
height: 14px;
fill: var(--var-input-button-inactive-fg);
color: var(--var-input-button-inactive-fg);
}
.scroll-icon:hover {
fill: var(--var-input-button-active-fg);
color: var(--var-input-button-active-fg);
}
.radchat {
position: absolute;
left: 0;
font-family: Arial, sans-serif;
font-size: 2em;
font-weight: bold;
color: var(--radchat-color);
}
#status, #username-status {
margin-top: 10px;
}
/* Normal mobile devices */
@media screen and (max-width: 768px) {
.container {
padding: 10px;
padding-bottom: 70px;
height: 100vh;
width: 100%;
max-width: 100%;
}
.search-container {
display: none;
}
.radchat {
display: none;
}
.current-user {
display: none;
}
.header-controls {
top: 10px;
gap: 15px;
width: 100%;
justify-content: center;
}
.settings-button, .users-button, .theme-button {
width: 36px;
height: 36px;
}
.settings-icon, .users-icon, .theme-icon {
width: 20px;
height: 20px;
}
.username-section, .users-section {
top: 60px;
width: 90%;
max-width: 300px;
left: 50%;
right: auto;
transform: translateX(-50%);
}
.messages-section {
margin-top: 50px;
margin-bottom: 70px;
}
.message {
padding: 8px 0;
}
.message .content {
font-size: 0.95em;
}
.message-section {
padding: 10px;
}
.message-container {
gap: 8px;
}
textarea {
font-size: 16px;
padding: 8px;
}
button {
padding: 8px 15px;
}
.video-embed, .image-embed {
max-width: 100%;
}
.radchat {
font-size: 1.5em;
position: relative;
text-align: center;
margin-bottom: 10px;
}
/* Improve touch targets */
.delete-button {
padding: 8px 12px;
opacity: 1; /* Always visible on mobile */
}
.edit-button {
padding: 8px 12px;
opacity: 1; /* Always visible on mobile */
}
@media (orientation: landscape) {
.messages-section {
margin-top: 45px;
margin-bottom: 60px;
}
.message-section {
padding: 8px;
}
}
}
/* Small mobile devices */
@media screen and (max-width: 380px) {
.search-container {
display: none;
}
.radchat {
display: none;
}
.current-user {
display: none;
}
.header-controls {
gap: 10px;
}
.settings-button, .users-button, .theme-button {
width: 32px;
height: 32px;
}
.message .content {
font-size: 0.9em;
}
}

View File

@ -1,20 +0,0 @@
# Changes To Make
## Frontend
### High Priority
- Lazy load with pagination (frontend and backend)
- Clicking edit message should reposition you at the message with it centered or where the textarea edit box is at the top of the screen at least
- Fix mobile views instead of hiding elements that you don't want to position properly (search, radchat title, username)
- Add live voice chat? (This will be fun, maybe a separate app)
### Mid Priority
- Nothing yet
### Low Priority
- Other embeds (Twitter posts, spotify tracks, soundcloud, github repos, instagram posts, other video platforms)
## Backend
### High Priority
- Lazy load with pagination (frontend and backend)
- Add actual logging instead of ip based usernames, have messages tied to the logged in user not an ip (db changes)
- Add live voice chat? (This will be fun, maybe a separate app)
### Mid Priority
- Nothing yet
### Low Priority
- Nothing yet

21
server/client.go Normal file
View File

@ -0,0 +1,21 @@
package server
// Client represents a single WebSocket connection and associated user state.
// It includes a send channel for outbound messages and LastPong for liveness.
import (
"sync"
"time"
"github.com/gorilla/websocket"
)
// Client holds the websocket connection and linkage back to the Hub.
type Client struct {
Conn *websocket.Conn
Username string
Id string
Hub *Hub
Mu sync.RWMutex
LastPong time.Time
Send chan []byte
}

12
server/doc.go Normal file
View File

@ -0,0 +1,12 @@
// Package server contains the networking primitives for RadChat.
//
// It exposes:
// - Hub: central registry/bus of connected Clients with broadcast/register/unregister channels
// - Client: a single WebSocket connection/user state and its outbound send queue
// - Upgrader: an Origin-checking Gorilla WebSocket upgrader factory
// - Middleware: simple HTTP middleware for gzip and cache control
//
// The package is intentionally small: it focuses on safe concurrent access to
// client maps and channels, and leaves higher-level HTTP routing and handlers in
// the main package.
package server

32
server/hub.go Normal file
View File

@ -0,0 +1,32 @@
package server
// Hub is the central registry/bus for all connected clients.
//
// It exposes channels for broadcast, register, and unregister events
// and protects the Clients map with a RWMutex for safe concurrent access.
import (
"sync"
"github.com/gorilla/websocket"
)
// Hub groups clients and channels used by the server runtime.
type Hub struct {
Clients map[*Client]bool
Broadcast chan []byte
Register chan *Client
Unregister chan *Client
Mutex sync.RWMutex
Upgrader websocket.Upgrader
}
// NewHub constructs a Hub with buffered channels sized by bufferSize.
func NewHub(bufferSize int, upgrader websocket.Upgrader) *Hub {
return &Hub{
Clients: make(map[*Client]bool),
Broadcast: make(chan []byte, bufferSize),
Register: make(chan *Client, bufferSize),
Unregister: make(chan *Client, bufferSize),
Upgrader: upgrader,
}
}

55
server/middleware.go Normal file
View File

@ -0,0 +1,55 @@
package server
// HTTP middleware for gzip compression and cache control.
import (
"compress/gzip"
"io"
"net/http"
"strings"
)
// MiddlewareChain composes multiple middleware into a single http.Handler wrapper.
func MiddlewareChain(middlewares ...func(http.Handler) http.Handler) func(http.Handler) http.Handler {
return func(final http.Handler) http.Handler {
for _, middleware := range middlewares {
final = middleware(final)
}
return final
}
}
// gzipResponseWriter wraps http.ResponseWriter to write gzipped data.
type gzipResponseWriter struct {
http.ResponseWriter
io.Writer
}
func (g *gzipResponseWriter) Write(data []byte) (int, error) {
return g.Writer.Write(data)
}
func CacheDisableMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
w.Header().Set("Pragma", "no-cache")
w.Header().Set("Expires", "0")
next.ServeHTTP(w, r)
})
}
func GzipMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
next.ServeHTTP(w, r)
return
}
gz := gzip.NewWriter(w)
defer func(gz *gzip.Writer) {
_ = gz.Close()
}(gz)
w.Header().Set("Content-Encoding", "gzip")
next.ServeHTTP(&gzipResponseWriter{ResponseWriter: w, Writer: gz}, r)
})
}

34
server/utils.go Normal file
View File

@ -0,0 +1,34 @@
package server
// Utilities for server package: WebSocket upgrader with Origin checks.
import (
"net/http"
"strings"
"github.com/gorilla/websocket"
)
// Upgrader creates a Gorilla websocket.Upgrader that enforces Origin checks.
// If bypass is true, any Origin is accepted. Otherwise, the Origin must match
// the provided originAddress (host[:port]).
func Upgrader(originAddress string, bypass bool) websocket.Upgrader {
if strings.HasPrefix(originAddress, "http://") || strings.HasPrefix(originAddress, "https://") {
originAddress = strings.TrimPrefix(originAddress, "http://")
originAddress = strings.TrimPrefix(originAddress, "https://")
}
return websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
if bypass {
return true
}
origin := r.Header.Get("Origin")
if origin == "" {
return false
}
if strings.HasPrefix(origin, "http://"+originAddress) || strings.HasPrefix(origin, "https://"+originAddress) {
return true
}
return false
},
}
}

View File

@ -1,285 +0,0 @@
package srv
import (
tu "chat/tu"
"encoding/json"
"fmt"
"net/http"
)
func (s *Server) handlePing(w http.ResponseWriter, r *http.Request) {
clientIP := getClientIP(r)
s.updateActivity(clientIP)
s.cleanupActivity()
w.WriteHeader(http.StatusOK)
}
func (s *Server) handleUsername(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
http.Error(w, `{"error": "Method not allowed"}`, http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "application/json")
clientIP := getClientIP(r)
var req struct {
Username string `json:"username"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error": "Invalid JSON"}`, http.StatusBadRequest)
return
}
s.mu.Lock()
if len(req.Username) > s.Config.Options.NameMaxLength {
http.Error(w, fmt.Sprintf(`{"error": "Username too long (%v out of %v characters maximum)"}`, len(req.Username), s.Config.Options.NameMaxLength), http.StatusRequestEntityTooLarge)
s.mu.Unlock()
return
}
if !validUsername(req.Username) {
http.Error(w, fmt.Sprintf(`{"error": "Username must only contain alphanumeric characters and/or underscores"}`), http.StatusBadRequest)
s.mu.Unlock()
return
}
if s.Database.UserNameExists(req.Username) {
http.Error(w, fmt.Sprintf(`{"error": "Username already exists"}`), http.StatusConflict)
s.mu.Unlock()
return
}
if s.Database.UserExists(clientIP) {
s.Database.UserNameChange(clientIP, req.Username)
} else {
s.Database.UserAdd(clientIP, req.Username)
}
s.mu.Unlock()
json.NewEncoder(w).Encode(map[string]string{"status": "Username registered"})
}
func (s *Server) handleMessages(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodPatch:
w.Header().Set("Content-Type", "application/json")
var req struct {
MessageId string `json:"messageId"`
MessageContent string `json:"messageContent"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error": "Invalid JSON"}`, http.StatusBadRequest)
return
}
clientIP := getClientIP(r)
if affected, err := s.Database.MessageEditIfOwner(req.MessageId, req.MessageContent, clientIP); err != nil {
http.Error(w, `{"error": "Unauthorized"}`, http.StatusNotFound)
return
} else if affected == 0 {
http.Error(w, `{"error": "Message not found"}`, http.StatusNotFound)
return
}
json.NewEncoder(w).Encode(map[string]string{
"status": "Message edited successfully",
})
case http.MethodGet:
w.Header().Set("Content-Type", "text/html")
var body string
messages := s.Database.MessagesGet()
for _, msg := range messages {
clientIP := getClientIP(r)
timeZone := s.Database.UserGetTimezone(clientIP)
timeLocal := tu.TimeStringToTimeInLocation(msg.Timestamp, timeZone)
edited := ""
if msg.Edited {
edited = "(edited)"
}
body += fmt.Sprintf(`<p>%s<br><span class="username">%s</span><br><span class="timestamp">%s %s</span><br><span class="message">%s</span><br></p>`,
msg.Id, msg.SenderUsername, timeLocal, edited, msg.Content)
}
w.Write([]byte(getMessageTemplate(body)))
case http.MethodPut:
w.Header().Set("Content-Type", "application/json")
// Get client's IP
clientIP := getClientIP(r)
s.mu.Lock()
exists := s.Database.UserExists(clientIP)
username := s.Database.UserNameGet(clientIP)
s.mu.Unlock()
if !exists {
errorFmt := fmt.Sprintf(`{"error": "IP %s not registered with username"}`, clientIP)
http.Error(w, errorFmt, http.StatusUnauthorized)
return
}
var msg struct {
Message string `json:"message"`
}
if err := json.NewDecoder(r.Body).Decode(&msg); err != nil {
http.Error(w, `{"error": "Invalid JSON"}`, http.StatusBadRequest)
return
}
s.Database.MessageAdd(clientIP, msg.Message)
json.NewEncoder(w).Encode(map[string]string{
"status": "Message received",
"from": username,
})
case http.MethodDelete:
w.Header().Set("Content-Type", "application/json")
var req struct {
MessageId string `json:"messageId"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error": "Invalid JSON"}`, http.StatusBadRequest)
return
}
clientIP := getClientIP(r)
if affected, err := s.Database.MessageDeleteIfOwner(req.MessageId, clientIP); err != nil {
http.Error(w, `{"error": "Unauthorized"}`, http.StatusNotFound)
return
} else if affected == 0 {
http.Error(w, `{"error": "Message not found"}`, http.StatusNotFound)
return
}
json.NewEncoder(w).Encode(map[string]string{
"status": "Message deleted",
})
default:
http.Error(w, `{"error": "Method not allowed"}`, http.StatusMethodNotAllowed)
return
}
}
func (s *Server) handleUsernameStatus(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, `{"error": "Method not allowed"}`, http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "application/json")
clientIP := getClientIP(r)
s.mu.Lock()
exists := s.Database.UserExists(clientIP)
username := s.Database.UserNameGet(clientIP)
s.mu.Unlock()
json.NewEncoder(w).Encode(map[string]interface{}{
"hasUsername": exists,
"username": username,
})
}
func (s *Server) handleUsers(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, `{"error": "Method not allowed"}`, http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "application/json")
clientIP := getClientIP(r)
s.updateActivity(clientIP)
s.cleanupActivity()
s.mu.Lock()
var users []string
for ip := range s.Connected {
// for all connected, get their usernames
users = append(users, s.Database.UserNameGet(ip))
}
s.mu.Unlock()
json.NewEncoder(w).Encode(map[string]interface{}{
"users": users,
})
}
func (s *Server) handleTimezone(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, `{"error": "Method not allowed"}`, http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "application/json")
clientIP := getClientIP(r)
var req struct {
Timezone string `json:"timezone"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error": "Invalid JSON"}`, http.StatusBadRequest)
return
}
s.mu.Lock()
if !s.Database.UserExists(clientIP) {
http.Error(w, `{"error": "User not registered"}`, http.StatusUnauthorized)
s.mu.Unlock()
return
}
s.Database.UserTimezoneSet(clientIP, req.Timezone)
s.mu.Unlock()
json.NewEncoder(w).Encode(map[string]string{
"status": "success",
})
}
func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/html")
file := readFile(s.Config.Paths.IndexHtmlPath)
w.Write(file)
}
func (s *Server) handleJs(w http.ResponseWriter, r *http.Request) {
_ = r
w.Header().Set("Content-Type", "application/javascript")
file := readFile(s.Config.Paths.IndexJsPath)
w.Write(file)
}
func (s *Server) handleCss(w http.ResponseWriter, r *http.Request) {
_ = r
w.Header().Set("Content-Type", "text/css")
file := readFile(s.Config.Paths.IndexCssPath)
w.Write(file)
}
func (s *Server) handleMessagesLength(w http.ResponseWriter, r *http.Request) {
// should return the number of messages in the database
if r.Method != http.MethodGet {
http.Error(w, `{"error": "Method not allowed"}`, http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "application/json")
messages := s.Database.MessagesGet()
json.NewEncoder(w).Encode(map[string]int{
"length": len(messages),
})
}

View File

@ -1,181 +0,0 @@
package srv
import (
db "chat/db"
"compress/gzip"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
type Config struct {
Server struct {
IpAddress string `json:"ipAddress"`
Port int `json:"port"`
} `json:"server"`
Paths struct {
DatabasePath string `json:"databasePath"`
IndexJsPath string `json:"indexJsPath"`
IndexCssPath string `json:"indexCssPath"`
IndexHtmlPath string `json:"indexHtmlPath"`
MessagesHtmlPath string `json:"messagesHtmlPath"`
} `json:"paths"`
Options struct {
MessageMaxAge int `json:"messageMaxAge"`
NameMaxLength int `json:"nameMaxLength"`
} `json:"options"`
}
func LoadConfig(filepath string) Config {
contents, _ := os.ReadFile(filepath)
var config Config
err := json.Unmarshal(contents, &config)
config.Paths.IndexHtmlPath = pathMaker(config.Paths.IndexHtmlPath)
config.Paths.IndexJsPath = pathMaker(config.Paths.IndexJsPath)
config.Paths.IndexCssPath = pathMaker(config.Paths.IndexCssPath)
config.Paths.MessagesHtmlPath = pathMaker(config.Paths.MessagesHtmlPath)
config.Paths.DatabasePath = pathMaker(config.Paths.DatabasePath)
if err != nil {
fmt.Println("Error parsing config file: ", err)
os.Exit(1)
}
return config
}
func pathMaker(path string) string {
absPath, _ := filepath.Abs(path)
absPath = filepath.Clean(absPath)
fmt.Println(absPath)
return absPath
}
type gzipResponseWriter struct {
http.ResponseWriter
io.Writer
}
func (g *gzipResponseWriter) Write(data []byte) (int, error) {
return g.Writer.Write(data)
}
func GzipMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
next.ServeHTTP(w, r)
return
}
gz := gzip.NewWriter(w)
defer gz.Close()
w.Header().Set("Content-Encoding", "gzip")
next.ServeHTTP(&gzipResponseWriter{ResponseWriter: w, Writer: gz}, r)
})
}
func getMessageTemplate(body string) string {
template := `<html>
<body>
<div class="container">
{{body}}
</div>
</body>
</html>`
return strings.Replace(template, "{{body}}", body, 1)
}
func getClientIP(r *http.Request) string {
if fwdIP := r.Header.Get("X-Forwarded-For"); fwdIP != "" {
return strings.Split(fwdIP, ",")[0]
}
clientIP := r.RemoteAddr
if host, _, err := net.SplitHostPort(clientIP); err == nil {
return host
}
return clientIP
}
func readFile(filepath string) []byte {
contents, _ := os.ReadFile(filepath)
return contents
}
func validUsername(username string) bool {
for _, c := range username {
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_') {
return false
}
}
return true
}
type Server struct {
Connected map[string]time.Time // Map IP -> Last activity time
Database *db.Database
Config Config
mu sync.Mutex // For thread safety
}
func NewServer(config Config) *Server {
return &Server{
Connected: make(map[string]time.Time),
Database: db.OpenDatabase(config.Paths.DatabasePath),
Config: config,
mu: sync.Mutex{},
}
}
func (s *Server) AddMessage(userip string, contents string) {
s.Database.MessageAdd(userip, contents)
}
func (s *Server) updateActivity(ip string) {
s.mu.Lock()
defer s.mu.Unlock()
s.Connected[ip] = time.Now()
}
func (s *Server) cleanupActivity() {
s.mu.Lock()
defer s.mu.Unlock()
for ip, lastActivity := range s.Connected {
if time.Since(lastActivity) > 10*time.Second {
delete(s.Connected, ip)
}
}
}
func (s *Server) Run() {
s.Database.DbCreateTableMessages()
s.Database.DbCreateTableUsers()
s.Database.DeleteOldMessages(s.Config.Options.MessageMaxAge)
handler := http.NewServeMux()
handler.HandleFunc("/", s.handleRoot)
handler.HandleFunc("/root.js", s.handleJs)
handler.HandleFunc("/root.css", s.handleCss)
handler.HandleFunc("/ping", s.handlePing)
handler.HandleFunc("/timezone", s.handleTimezone)
handler.HandleFunc("/username/status", s.handleUsernameStatus)
handler.HandleFunc("/messages/length", s.handleMessagesLength)
handler.HandleFunc("/users", s.handleUsers)
handler.HandleFunc("/username", s.handleUsername)
handler.HandleFunc("/messages", s.handleMessages)
fmt.Printf("Server starting on %s:%d\n", s.Config.Server.IpAddress, s.Config.Server.Port)
defer s.Stop()
if err := http.ListenAndServe(fmt.Sprintf("%s:%d", s.Config.Server.IpAddress, s.Config.Server.Port), GzipMiddleware(handler)); err != nil {
fmt.Printf("Server error: %v\n", err)
}
}
func (s *Server) Stop() {
s.Database.Close()
}

2295
static/app.js Normal file

File diff suppressed because it is too large Load Diff

BIN
static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

156
static/index.html Normal file
View File

@ -0,0 +1,156 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RadChat</title>
<link rel="stylesheet" href="/styles.css">
<link rel="icon" href="/favicon.ico" type="image/x-icon">
<script src="/app.js" defer></script>
</head>
<body>
<div class="app-container">
<!-- Left Sidebar for Users and Voice Controls -->
<div class="sidebar" id="sidebar">
<!-- Users List -->
<div class="sidebar-section users-section">
<div id="users-list" class="users-list">
<div class="no-users"></div>
</div>
</div>
<!-- Voice Controls in Sidebar -->
<div class="sidebar-section voice-controls-sidebar" id="voice-controls-sidebar" style="display: none;">
<div class="voice-controls-content">
<div class="control-buttons">
<button
id="toggle-mute-btn"
onclick="toggleMute()"
class="voice-btn unmuted"
>
</button>
<button
id="toggle-deafen-btn"
onclick="toggleDeafen()"
class="voice-btn"
>
</button>
<button
id="voice-settings-btn"
onclick="openSelfModal()"
class="voice-btn"
>
</button>
<button
id="leave-voice-btn"
onclick="leaveVoiceChat()"
class="voice-btn danger"
>
</button>
</div>
</div>
</div>
</div>
<!-- Main Content Area -->
<div class="main-content">
<main>
<div class="join-section" id="join-section">
<div class="username-section" id="username-section">
<div class="section-header">
<h2>RadChat</h2>
</div>
<div class="input-group">
<input
type="text"
id="username-input"
placeholder="Enter your username"
maxlength="20"
>
<label for="username-input"></label>
<button
id="join-chat-btn"
onclick="joinVoiceChat()"
disabled
>
Join Voice Chat
</button>
</div>
</div>
<!-- Microphone Permission -->
<div class="mic-section" id="mic-section">
<div class="section-header">
</div>
<button
id="request-mic-btn"
onclick="requestMicrophonePermission()"
class="mic-button"
>
Request Microphone Access
</button>
<div id="mic-status" class="mic-status"></div>
</div>
</div>
<!-- Chat Section -->
<div class="chat-section" id="chat-section" style="display: none;">
<div class="chat-messages" id="chat-messages">
</div>
<div class="chat-input-container">
<textarea
id="chat-input"
placeholder="Type a message..."
rows="1"
></textarea>
<label for="chat-input"></label>
<div class="chat-input-buttons">
<button id="scroll-btn" onclick="scrollChatToBottom()" disabled></button>
<input type="file" id="file-input" multiple style="display: none;"/>
<button id="file-btn" onclick='openFilePicker()'>📤</button>
<button id="emoji-btn" onclick="toggleEmojiPicker()">😀</button>
<button id="send-btn" onclick="sendChatMessage()" disabled>Send</button>
</div>
</div>
</div>
<!-- Audio Elements Container -->
<div id="audio-container" style="display: none;"></div>
<!-- User Control Modal -->
<div id="user-control-modal" class="modal" style="display: none;">
<div class="modal-content">
<div class="modal-header">
<h3 id="modal-username">User Controls</h3>
<button class="modal-close" onclick="closeUserModal()">&times;</button>
</div>
<div class="modal-body">
<div class="control-section">
<h1>Volume Control</h1>
<div class="volume-control">
<span>🔇</span>
<input type="range" id="user-volume-slider" min="0" max="100" value="100" oninput="updateUserVolume()">
<label for="user-volume-slider"></label>
<span>🔊</span>
<span id="volume-percentage">100%</span>
</div>
</div>
<div class="control-section">
<h1>Audio Controls</h1>
<div class="audio-controls">
<button id="modal-mute-btn" onclick="toggleUserMuteFromModal()" class="control-btn">
🔇 Mute User
</button>
<button onclick="resetUserVolume()" class="control-btn secondary">
🔄 Reset Volume
</button>
</div>
</div>
</div>
</div>
</div>
</main>
</div>
</div>
</body>
</html>

BIN
static/sounds/join.wav Normal file

Binary file not shown.

BIN
static/sounds/leave.wav Normal file

Binary file not shown.

1785
static/styles.css Normal file

File diff suppressed because it is too large Load Diff

3
static/svg/connect.svg Normal file
View File

@ -0,0 +1,3 @@
<svg class="connect-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path class="phone" d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/>
</svg>

After

Width:  |  Height:  |  Size: 526 B

View File

@ -0,0 +1,4 @@
<svg class="disconnect-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path class="phone" d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/>
<line class="slash" x1="0" y1="22" x2="22" y2="0"/>
</svg>

After

Width:  |  Height:  |  Size: 585 B

View File

@ -0,0 +1,6 @@
<svg class="headphones-muted-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path class="headphones" d="M3 14h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-5z"/>
<path class="headphones" d="M21 14h-3a2 2 0 0 0-2 2v3a2 2 0 0 0 2 2h1a2 2 0 0 0 2-2v-5z"/>
<path class="headband" d="M3 14v-2a9 9 0 0 1 18 0v2"/>
<line class="slash" x1="2" y1="2" x2="22" y2="22"/>
</svg>

After

Width:  |  Height:  |  Size: 522 B

View File

@ -0,0 +1,5 @@
<svg class="headphones-unmuted-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path class="headphones" d="M3 14h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-5z"/>
<path class="headphones" d="M21 14h-3a2 2 0 0 0-2 2v3a2 2 0 0 0 2 2h1a2 2 0 0 0 2-2v-5z"/>
<path class="headband" d="M3 14v-2a9 9 0 0 1 18 0v2"/>
</svg>

After

Width:  |  Height:  |  Size: 467 B

7
static/svg/mic-muted.svg Normal file
View File

@ -0,0 +1,7 @@
<svg class="mic-muted-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path class="mic" d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/>
<path class="stand" d="M19 10v2a7 7 0 0 1-14 0v-2"/>
<line class="stand" x1="12" y1="19" x2="12" y2="23"/>
<line class="stand" x1="8" y1="23" x2="16" y2="23"/>
<line class="slash" x1="2" y1="2" x2="22" y2="22"/>
</svg>

After

Width:  |  Height:  |  Size: 521 B

View File

@ -0,0 +1,6 @@
<svg class="mic-unmuted-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path class="mic" d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/>
<path class="stand" d="M19 10v2a7 7 0 0 1-14 0v-2"/>
<line class="stand" x1="12" y1="19" x2="12" y2="23"/>
<line class="stand" x1="8" y1="23" x2="16" y2="23"/>
</svg>

After

Width:  |  Height:  |  Size: 467 B

3
static/svg/settings.svg Normal file
View File

@ -0,0 +1,3 @@
<svg class="settings-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path class="gear" fill-rule="evenodd" d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z M12 9a3 3 0 1 0 0 6 3 3 0 0 0 0-6z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -1,26 +0,0 @@
package tu
import (
"time"
)
func TimezoneToLocation(timezone string) *time.Location {
defaultLocation := time.FixedZone("UTC", 0)
location, err := time.LoadLocation(timezone)
if err != nil {
return defaultLocation
} else {
return location
}
}
func TimeStringToTime(timeString string) time.Time {
t, _ := time.Parse("2006-01-02 15:04:05", timeString)
return t
}
func TimeStringToTimeInLocation(timeString string, timezone string) string {
t := TimeStringToTime(timeString)
location := TimezoneToLocation(timezone)
return t.In(location).Format("2006-01-02 15:04:05")
}

112
utils.go Normal file
View File

@ -0,0 +1,112 @@
package main
// Utilities for filesystem, IDs, HTTP scheme detection, and input sanitization.
// These helpers are used by the HTTP handlers and startup code.
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"time"
)
// MakeDir creates the directory if it does not already exist.
func MakeDir(dirname string) error {
if _, err := os.Stat(dirname); os.IsNotExist(err) {
return os.Mkdir(dirname, 0666)
}
return nil
}
// DeleteDirContents removes all files and directories inside dirname but not the directory itself.
func DeleteDirContents(dirname string) error {
entries, err := os.ReadDir(dirname)
if err != nil {
return err
}
for _, entry := range entries {
if entry.IsDir() {
err := os.RemoveAll(filepath.Join(dirname, entry.Name()))
if err != nil {
return err
}
} else {
err := os.Remove(filepath.Join(dirname, entry.Name()))
if err != nil {
return err
}
}
}
return nil
}
// IsUsernameValid validates length, allowed characters, and rejects common placeholder names.
func IsUsernameValid(username string) bool {
minLength := 3
maxLength := 24
allowedChars := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-"
disallowedUsernames := []string{
"admin",
"user",
"guest",
"test",
"root",
"system",
"anonymous",
"default",
}
if len(username) < minLength || len(username) > maxLength {
return false
}
for _, char := range username {
if !strings.ContainsRune(allowedChars, char) {
return false
}
}
for _, disallowed := range disallowedUsernames {
if strings.EqualFold(username, disallowed) {
return false
}
}
return true
}
// GenerateId creates a short pseudo-unique ID derived from time and a hash.
func GenerateId() string {
timestamp := time.Now().UnixNano()
randomComponent := time.Now().UnixNano() % 1000000 // Add some randomness
data := fmt.Sprintf("%d-%d", timestamp, randomComponent)
hash := sha256.Sum256([]byte(data))
return hex.EncodeToString(hash[:])[:16]
}
// GetScheme determines the request scheme, respecting TLS and X-Forwarded-Proto.
func GetScheme(r *http.Request) string {
if r.URL.Scheme != "" {
return r.URL.Scheme
}
if r.TLS != nil {
return "https"
}
proto := r.Header.Get("X-Forwarded-Proto")
if proto != "" {
return proto
}
return "http"
}
// SanitizeFilename removes path traversal characters and trims whitespace.
func SanitizeFilename(filename string) string {
filename = strings.ReplaceAll(filename, "/", "")
filename = strings.ReplaceAll(filename, "\\", "")
filename = strings.ReplaceAll(filename, "..", "")
filename = strings.TrimSpace(filename)
return filename
}