Compare commits
No commits in common. "main" and "overhaul" have entirely different histories.
0
.gitignore
vendored
8
.idea/.gitignore
generated
vendored
@ -1,8 +0,0 @@
|
||||
# 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
@ -1,9 +0,0 @@
|
||||
<component name="ProjectDictionaryState">
|
||||
<dictionary name="project">
|
||||
<words>
|
||||
<w>abcdefghijklmnopqrstuvwxyz</w>
|
||||
<w>bufsize</w>
|
||||
<w>omitempty</w>
|
||||
</words>
|
||||
</dictionary>
|
||||
</component>
|
16
.idea/inspectionProfiles/Project_Default.xml
generated
@ -1,16 +0,0 @@
|
||||
<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
@ -1,6 +0,0 @@
|
||||
<?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
@ -1,8 +0,0 @@
|
||||
<?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
@ -1,15 +0,0 @@
|
||||
<?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
@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
101
README.md
@ -1,101 +0,0 @@
|
||||
# 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
@ -1,210 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
19
config.json
Normal file
@ -0,0 +1,19 @@
|
||||
{
|
||||
"server": {
|
||||
"ipAddress": "192.168.1.222",
|
||||
"port": 8080
|
||||
},
|
||||
"paths": {
|
||||
"databasePath": "/home/radon/Documents/chat.db",
|
||||
"indexJsPath": "./public/index.js",
|
||||
"indexCssPath": "./public/style.css",
|
||||
"indexHtmlPath": "./public/index.html",
|
||||
"signupHtmlPath": "./public/signup.html",
|
||||
"loginHtmlPath": "./public/login.html"
|
||||
},
|
||||
"options": {
|
||||
"messageMaxAge": 259200,
|
||||
"nameMaxLength": 32,
|
||||
"messagesPerPage": 10
|
||||
}
|
||||
}
|
314
db/db.go
Normal file
@ -0,0 +1,314 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
Id string
|
||||
Username string
|
||||
Timezone string
|
||||
HashedPassword string
|
||||
}
|
||||
|
||||
type Message struct {
|
||||
Id 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,
|
||||
username 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,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
hashed_password TEXT NOT NULL,
|
||||
timezone TEXT DEFAULT 'America/New_York',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)`
|
||||
db.db.Exec(stmt)
|
||||
}
|
||||
|
||||
func (db *Database) UserTimezoneSet(username, timezone string) {
|
||||
_, err := db.db.Exec("UPDATE users SET timezone = ? WHERE username = ?", timezone, username)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (db *Database) UserAddWithPassword(username, unhashedPwd string) error {
|
||||
// unhashedPwd can not be larger than 72 bytes
|
||||
if len(unhashedPwd) > 72 {
|
||||
return fmt.Errorf("Password too long")
|
||||
}
|
||||
hashedPwd, err := bcrypt.GenerateFromPassword([]byte(unhashedPwd), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = db.db.Exec("INSERT INTO users (username, hashed_password) VALUES (?, ?)", username, hashedPwd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *Database) UserPasswordCheck(username, unhashedPwd string) bool {
|
||||
rows, err := db.db.Query("SELECT hashed_password FROM users WHERE username = ?", username)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var hashedPwd string
|
||||
rows.Next()
|
||||
rows.Scan(&hashedPwd)
|
||||
err = bcrypt.CompareHashAndPassword([]byte(hashedPwd), []byte(unhashedPwd))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (db *Database) MessageAdd(username string, content string) {
|
||||
_, err := db.db.Exec("INSERT INTO messages (username, content) VALUES (?, ?)", username, content)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (db *Database) UserGetTimezone(username string) string {
|
||||
rows, err := db.db.Query("SELECT timezone FROM users WHERE username = ?", username)
|
||||
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 username string
|
||||
var created_at string
|
||||
var timezone string
|
||||
rows.Scan(&id, &username, &created_at, &timezone)
|
||||
user := User{
|
||||
Id: id,
|
||||
Username: username,
|
||||
Timezone: timezone,
|
||||
}
|
||||
users = append(users, user)
|
||||
}
|
||||
return users
|
||||
}
|
||||
|
||||
func (db *Database) MessagesGet() []Message {
|
||||
rows, err := db.db.Query(`
|
||||
SELECT
|
||||
messages.id,
|
||||
messages.username,
|
||||
messages.content,
|
||||
strftime('%Y-%m-%d %H:%M:%S', messages.created_at) as created_at,
|
||||
messages.edited
|
||||
FROM
|
||||
messages
|
||||
`)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var messages []Message
|
||||
|
||||
for rows.Next() {
|
||||
var id string
|
||||
var content string
|
||||
var created_at string
|
||||
var username string
|
||||
var edited int
|
||||
rows.Scan(&id, &username, &content, &created_at, &edited)
|
||||
|
||||
editedBool := false
|
||||
if edited == 1 {
|
||||
editedBool = true
|
||||
}
|
||||
|
||||
message := Message{
|
||||
Id: id,
|
||||
Content: content,
|
||||
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(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) UserNameChange(oldUsername, newUsername string) {
|
||||
_, err := db.db.Exec("UPDATE users SET username = ? WHERE username = ?", newUsername, oldUsername)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (db *Database) UserMessagesGet(username string) []Message {
|
||||
rows, err := db.db.Query(`
|
||||
SELECT messages.*, users.username
|
||||
FROM messages
|
||||
LEFT JOIN users ON messages.username = users.username
|
||||
WHERE messages.username = ?
|
||||
ORDER BY messages.created_at DESC;
|
||||
`, username)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var messages []Message
|
||||
|
||||
for rows.Next() {
|
||||
var id string
|
||||
var content string
|
||||
var created_at string
|
||||
var username string
|
||||
var edited int
|
||||
rows.Scan(&id, &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,
|
||||
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, username string) (int, error) {
|
||||
res, err := db.db.Exec("DELETE FROM messages WHERE id = ? AND username = ?", id, username)
|
||||
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, username string) (int, error) {
|
||||
res, err := db.db.Exec("UPDATE messages SET content = ?, edited = 1 WHERE id = ? AND username = ?", content, id, username)
|
||||
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) UserDelete(username string) {
|
||||
_, err := db.db.Exec("DELETE FROM users WHERE username = ?", username)
|
||||
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
@ -1,33 +0,0 @@
|
||||
#!/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
@ -1,18 +0,0 @@
|
||||
// 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
|
8
go.mod
@ -1,5 +1,7 @@
|
||||
module radchat
|
||||
module chat
|
||||
|
||||
go 1.24.6
|
||||
go 1.23.4
|
||||
|
||||
require github.com/gorilla/websocket v1.5.3
|
||||
require github.com/mattn/go-sqlite3 v1.14.24 // direct
|
||||
|
||||
require golang.org/x/crypto v0.32.0 // indirect
|
||||
|
6
go.sum
@ -1,2 +1,4 @@
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
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=
|
||||
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
|
||||
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
|
||||
|
386
hub.go
@ -1,386 +0,0 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
86
main.go
@ -1,82 +1,22 @@
|
||||
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 (
|
||||
"flag"
|
||||
"chat/srv"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"radchat/server"
|
||||
"time"
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() {
|
||||
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")
|
||||
var config srv.Config
|
||||
|
||||
flag.Parse()
|
||||
|
||||
if *help {
|
||||
flag.PrintDefaults()
|
||||
return
|
||||
func init() {
|
||||
if len(os.Args) < 2 {
|
||||
fmt.Printf("Usage: %s <config.json>\n", os.Args[0])
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
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))
|
||||
config = srv.LoadConfig(os.Args[1])
|
||||
fmt.Println("Config loaded")
|
||||
}
|
||||
|
||||
func main() {
|
||||
srv.NewServer(config).Run()
|
||||
}
|
||||
|
125
message.go
@ -1,125 +0,0 @@
|
||||
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"`
|
||||
}
|
86
public/index.html
Normal file
@ -0,0 +1,86 @@
|
||||
<!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>
|
845
public/index.js
Normal file
@ -0,0 +1,845 @@
|
||||
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) {
|
||||
// redirect to login page
|
||||
window.location.href = "/login";
|
||||
//
|
||||
//
|
||||
// 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 getCurrentUsername() {
|
||||
try {
|
||||
const response = await fetch("/username/status");
|
||||
const data = await response.json();
|
||||
return data.username;
|
||||
} catch (error) {
|
||||
console.error("Error getting username:", 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 username = await getCurrentUsername();
|
||||
const response = await fetch("/messages", {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ username: username, 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();
|
178
public/login.html
Normal file
@ -0,0 +1,178 @@
|
||||
<!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">
|
||||
<style>
|
||||
:root{
|
||||
--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;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: var(--main-bg-color);
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
.login-container, .signup-container {
|
||||
background-color: var(--pum-bg-color);
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
margin: 1rem;
|
||||
}
|
||||
|
||||
.login-form, .signup-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: var(--radchat-color);
|
||||
text-align: center;
|
||||
margin: 0 0 1.5rem 0;
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.login-input, .signup-input {
|
||||
padding: 0.75rem;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background-color: var(--input-bg-color);
|
||||
color: var(--input-text-color);
|
||||
font-size: 1rem;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.login-button, .signup-button {
|
||||
padding: 0.75rem;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background-color: var(--input-button-inactive-bg);
|
||||
color: var(--input-button-inactive-fg);
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.login-button:hover, .signup-button:hover {
|
||||
background-color: var(--input-button-active-bg);
|
||||
color: var(--input-button-active-fg);
|
||||
}
|
||||
|
||||
.login-error, .signup-error {
|
||||
color: #e64553;
|
||||
text-align: center;
|
||||
min-height: 1.5rem;
|
||||
}
|
||||
|
||||
.signup-link {
|
||||
text-align: center;
|
||||
color: var(--message-color);
|
||||
}
|
||||
|
||||
.signup-link a {
|
||||
color: var(--user-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.signup-link a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
<title>RadChat Login</title>
|
||||
<script>
|
||||
function login() {
|
||||
const loginUsername = document.getElementById('loginUsername').value;
|
||||
const loginPassword = document.getElementById('loginPassword').value;
|
||||
const loginError = document.getElementById('loginError');
|
||||
fetch('/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username: loginUsername,
|
||||
password: loginPassword
|
||||
}),
|
||||
})
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
if (data.error) {
|
||||
loginError.innerHTML = data.error;
|
||||
} else {
|
||||
window.location.href = '/';
|
||||
}
|
||||
});
|
||||
return false;
|
||||
}
|
||||
</script>
|
||||
<link rel="stylesheet" href="root.css">
|
||||
|
||||
</head>
|
||||
<body>
|
||||
<!-- Login Form -->
|
||||
<div class="login-container">
|
||||
<div class="login-form">
|
||||
<h1>RadChat</h1>
|
||||
<form id="loginForm" onsubmit="return login()">
|
||||
<input
|
||||
type="text"
|
||||
id="loginUsername"
|
||||
class="login-input"
|
||||
placeholder="Username"
|
||||
required
|
||||
>
|
||||
<input
|
||||
type="password"
|
||||
id="loginPassword"
|
||||
class="login-input"
|
||||
placeholder="Password"
|
||||
required
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
class="login-button"
|
||||
>
|
||||
Login
|
||||
</button>
|
||||
<div id="loginError" class="login-error"></div>
|
||||
</form>
|
||||
<a href="/signup" class="signup-link">Signup</a>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
186
public/signup.html
Normal file
@ -0,0 +1,186 @@
|
||||
<!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">
|
||||
<style>
|
||||
:root{
|
||||
--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;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: var(--main-bg-color);
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
.login-container, .signup-container {
|
||||
background-color: var(--pum-bg-color);
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
margin: 1rem;
|
||||
}
|
||||
|
||||
.login-form, .signup-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: var(--radchat-color);
|
||||
text-align: center;
|
||||
margin: 0 0 1.5rem 0;
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.login-input, .signup-input {
|
||||
padding: 0.75rem;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background-color: var(--input-bg-color);
|
||||
color: var(--input-text-color);
|
||||
font-size: 1rem;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.login-button, .signup-button {
|
||||
padding: 0.75rem;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background-color: var(--input-button-inactive-bg);
|
||||
color: var(--input-button-inactive-fg);
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.login-button:hover, .signup-button:hover {
|
||||
background-color: var(--input-button-active-bg);
|
||||
color: var(--input-button-active-fg);
|
||||
}
|
||||
|
||||
.login-error, .signup-error {
|
||||
color: #e64553;
|
||||
text-align: center;
|
||||
min-height: 1.5rem;
|
||||
}
|
||||
|
||||
.signup-link {
|
||||
text-align: center;
|
||||
color: var(--message-color);
|
||||
}
|
||||
|
||||
.signup-link a {
|
||||
color: var(--user-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.signup-link a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
<title>RadChat Signup</title>
|
||||
<script>
|
||||
function signup() {
|
||||
const signupUsername = document.getElementById('signupUsername').value;
|
||||
const signupPassword = document.getElementById('signupPassword').value;
|
||||
const signupConfirmPassword = document.getElementById('signupConfirmPassword').value;
|
||||
const signupError = document.getElementById('signupError');
|
||||
if (signupPassword !== signupConfirmPassword) {
|
||||
signupError.innerHTML = 'Passwords do not match';
|
||||
return false;
|
||||
}
|
||||
fetch('/signup', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username: signupUsername,
|
||||
password: signupPassword,
|
||||
}),
|
||||
})
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
if (data.error) {
|
||||
signupError.innerHTML = data.error;
|
||||
} else {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
});
|
||||
return false;
|
||||
}
|
||||
</script>
|
||||
<link rel="stylesheet" href="root.css">
|
||||
</head>
|
||||
<body>
|
||||
<!-- Signup Form -->
|
||||
<div class="signup-container">
|
||||
<div class="signup-form">
|
||||
<h1>RadChat</h1>
|
||||
<form id="signupForm" onsubmit="return signup()">
|
||||
<input
|
||||
type="text"
|
||||
id="signupUsername"
|
||||
class="signup-input"
|
||||
placeholder="Username"
|
||||
required
|
||||
>
|
||||
<input
|
||||
type="password"
|
||||
id="signupPassword"
|
||||
class="signup-input"
|
||||
placeholder="Password"
|
||||
required
|
||||
>
|
||||
<input
|
||||
type="password"
|
||||
id="signupConfirmPassword"
|
||||
class="signup-input"
|
||||
placeholder="Confirm Password"
|
||||
required
|
||||
>
|
||||
<button type="submit" class="signup-button">Sign Up</button>
|
||||
</form>
|
||||
<div class="signup-error" id="signupError"></div>
|
||||
<div class="signup-link">
|
||||
Already have an account? <a href="/login">Log In</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
589
public/style.css
Normal file
@ -0,0 +1,589 @@
|
||||
[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;
|
||||
}
|
||||
}
|
26
readme.md
Normal file
@ -0,0 +1,26 @@
|
||||
# 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 live voice chat? (This will be fun, maybe a separate app)
|
||||
### Mid Priority
|
||||
- NEW LOGIN STUFF
|
||||
- IN PROGRESS: Add actual logging instead of ip based usernames, have messages tied to the logged in user not an ip (db changes)
|
||||
* Fix editing messages
|
||||
* Fix deleting messages
|
||||
* Fix changing username
|
||||
* Fix CSS for signin page
|
||||
* Add logout button to settings, should touch go, js all that, logout request
|
||||
* Fix CSS for login page
|
||||
### Low Priority
|
||||
- Nothing yet
|
@ -1,21 +0,0 @@
|
||||
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
|
||||
}
|
@ -1,12 +0,0 @@
|
||||
// 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
|
@ -1,32 +0,0 @@
|
||||
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,
|
||||
}
|
||||
}
|
@ -1,55 +0,0 @@
|
||||
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)
|
||||
})
|
||||
}
|
@ -1,34 +0,0 @@
|
||||
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
|
||||
},
|
||||
}
|
||||
}
|
373
srv/handle.go
Normal file
@ -0,0 +1,373 @@
|
||||
package srv
|
||||
|
||||
import (
|
||||
tu "chat/tu"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func generateName() string {
|
||||
adjectives := []string{"Unrelenting", "Mystical", "Radiant", "Curious", "Peaceful", "Ancient", "Wandering", "Silent", "Celestial", "Dancing", "Eternal", "Resolute", "Whispering", "Serene", "Wild"}
|
||||
colors := []string{"Purple", "Azure", "Crimson", "Golden", "Emerald", "Sapphire", "Obsidian", "Silver", "Amber", "Jade", "Indigo", "Violet", "Cerulean", "Copper", "Pearl"}
|
||||
nouns := []string{"Elephant", "Phoenix", "Dragon", "Warrior", "Spirit", "Tiger", "Raven", "Mountain", "River", "Storm", "Falcon", "Wolf", "Ocean", "Star", "Moon"}
|
||||
|
||||
return fmt.Sprintf("%s-%s-%s",
|
||||
adjectives[rand.Intn(len(adjectives))],
|
||||
colors[rand.Intn(len(colors))],
|
||||
nouns[rand.Intn(len(nouns))])
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
if !validUsername(req.Username) {
|
||||
http.Error(w, fmt.Sprintf(`{"error": "Username must only contain alphanumeric characters and/or underscores"}`), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if s.Database.UserNameExists(req.Username) {
|
||||
http.Error(w, fmt.Sprintf(`{"error": "Username already exists"}`), http.StatusConflict)
|
||||
return
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if username, ok := s.LoggedIn[clientIP]; ok {
|
||||
s.LogUserOut(username)
|
||||
s.Database.UserNameChange(username, req.Username)
|
||||
s.LogUserIn(clientIP, req.Username)
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "Username changed"})
|
||||
} else {
|
||||
http.Error(w, `{"error": "Failure to change username"}`, http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
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)
|
||||
username, ok := s.LoggedIn[clientIP]
|
||||
timeZone := "UTC"
|
||||
if ok {
|
||||
timeZone = s.Database.UserGetTimezone(username)
|
||||
}
|
||||
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")
|
||||
|
||||
var msg struct {
|
||||
Username string `json:"username"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&msg); err != nil {
|
||||
http.Error(w, `{"error": "Invalid JSON"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
clientIP := getClientIP(r)
|
||||
if username, ok := s.LoggedIn[clientIP]; ok {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.Database.MessageAdd(username, msg.Message)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"status": "Message received",
|
||||
"from": username,
|
||||
})
|
||||
} else {
|
||||
http.Error(w, `{"error": "Unauthorized"}`, http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
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
|
||||
// }
|
||||
clientIP := getClientIP(r)
|
||||
username, ok := s.LoggedIn[clientIP]
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"hasUsername": ok,
|
||||
"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()
|
||||
defer s.mu.Unlock()
|
||||
var users []string
|
||||
for ip := range s.Connected {
|
||||
// for all connected, get their usernames
|
||||
if username, ok := s.LoggedIn[ip]; ok {
|
||||
users = append(users, username)
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
defer s.mu.Unlock()
|
||||
if username, ok := s.LoggedIn[clientIP]; ok {
|
||||
s.Database.UserTimezoneSet(username, req.Timezone)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"status": "success",
|
||||
})
|
||||
} else {
|
||||
http.Error(w, `{"error": "User not registered"}`, http.StatusUnauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
file := readFile(s.Config.Paths.LoginHtmlPath)
|
||||
w.Write(file)
|
||||
return
|
||||
case http.MethodPost:
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
var req struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, `{"error": "Invalid JSON"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
validLogin := s.Database.UserPasswordCheck(req.Username, req.Password)
|
||||
if !validLogin {
|
||||
http.Error(w, `{"error": "Invalid username or password"}`, http.StatusUnauthorized)
|
||||
return
|
||||
} else {
|
||||
clientIP := getClientIP(r)
|
||||
s.LogUserIn(clientIP, req.Username)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"status": "Logged in",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleSignup(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
file := readFile(s.Config.Paths.SignupHtmlPath)
|
||||
w.Write(file)
|
||||
return
|
||||
case http.MethodPost:
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
var req struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, `{"error": "Invalid JSON"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// validate username length
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
// validate username
|
||||
if !validUsername(req.Username) {
|
||||
http.Error(w, fmt.Sprintf(`{"error": "Username must only contain alphanumeric characters and/or underscores"}`), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// validate user doesnt already exist
|
||||
if s.Database.UserNameExists(req.Username) {
|
||||
http.Error(w, fmt.Sprintf(`{"error": "Username already exists"}`), http.StatusConflict)
|
||||
return
|
||||
}
|
||||
|
||||
// add user to database with hashedpassword
|
||||
err := s.Database.UserAddWithPassword(req.Username, req.Password)
|
||||
if err != nil {
|
||||
fmt.Println("Database error while signing up a new user")
|
||||
http.Error(w, fmt.Sprintf(`{"error": "%v"}`, err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// log user in
|
||||
clientIP := getClientIP(r)
|
||||
s.LogUserIn(clientIP, req.Username)
|
||||
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"status": "User created",
|
||||
})
|
||||
}
|
||||
}
|
206
srv/srv.go
Normal file
@ -0,0 +1,206 @@
|
||||
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"`
|
||||
SignupHtmlPath string `json:"signupHtmlPath"`
|
||||
LoginHtmlPath string `json:"loginHtmlPath"`
|
||||
} `json:"paths"`
|
||||
Options struct {
|
||||
MessageMaxAge int `json:"messageMaxAge"`
|
||||
NameMaxLength int `json:"nameMaxLength"`
|
||||
MessagePerPage int `json:"messagePerPage"`
|
||||
} `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.SignupHtmlPath = pathMaker(config.Paths.SignupHtmlPath)
|
||||
config.Paths.LoginHtmlPath = pathMaker(config.Paths.LoginHtmlPath)
|
||||
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 {
|
||||
LoggedIn map[string]string // Map Username -> IP
|
||||
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{
|
||||
LoggedIn: make(map[string]string),
|
||||
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) LogUserIn(ip, username string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.LoggedIn[ip] = username
|
||||
}
|
||||
|
||||
func (s *Server) LogUserOut(username string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for ip, u := range s.LoggedIn {
|
||||
if u == username {
|
||||
delete(s.LoggedIn, ip)
|
||||
}
|
||||
}
|
||||
delete(s.LoggedIn, username)
|
||||
}
|
||||
|
||||
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)
|
||||
s.LogUserOut(s.LoggedIn[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)
|
||||
handler.HandleFunc("/signup", s.handleSignup)
|
||||
handler.HandleFunc("/login", s.handleLogin)
|
||||
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
Before Width: | Height: | Size: 41 KiB |
@ -1,156 +0,0 @@
|
||||
<!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()">×</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>
|
1785
static/styles.css
@ -1,3 +0,0 @@
|
||||
<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>
|
Before Width: | Height: | Size: 526 B |
@ -1,4 +0,0 @@
|
||||
<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>
|
Before Width: | Height: | Size: 585 B |
@ -1,6 +0,0 @@
|
||||
<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>
|
Before Width: | Height: | Size: 522 B |
@ -1,5 +0,0 @@
|
||||
<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>
|
Before Width: | Height: | Size: 467 B |
@ -1,7 +0,0 @@
|
||||
<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>
|
Before Width: | Height: | Size: 521 B |
@ -1,6 +0,0 @@
|
||||
<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>
|
Before Width: | Height: | Size: 467 B |
@ -1,3 +0,0 @@
|
||||
<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>
|
Before Width: | Height: | Size: 1.0 KiB |
26
tu/tu.go
Normal file
@ -0,0 +1,26 @@
|
||||
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
@ -1,112 +0,0 @@
|
||||
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
|
||||
}
|