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/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
|
||||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
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
|
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 (
|
import (
|
||||||
"flag"
|
"chat/srv"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"os"
|
||||||
"net/http"
|
|
||||||
"path/filepath"
|
|
||||||
"radchat/server"
|
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
var config srv.Config
|
||||||
var ip = flag.String("ip", "localhost", "Server address (e.g., 192.168.1.1)")
|
|
||||||
var port = flag.Int("port", 8080, "Server port")
|
|
||||||
var bufSize = flag.Int("bufsize", 256, "Channel buffer size")
|
|
||||||
var gzipEnabled = flag.Bool("gzip-enable", false, "Enable gzip compression")
|
|
||||||
var cachingDisabled = flag.Bool("cache-disable", false, "Disable caching")
|
|
||||||
var filesDirectory = flag.String("files", "./files", "Directory to store upload files")
|
|
||||||
var filesTimeout = flag.Int("files-timeout", 3600, "File timeout in seconds")
|
|
||||||
var origin = flag.String("origin", "", "Origin to allow (e.g. example.com), leave blank to allow all")
|
|
||||||
var help = flag.Bool("help", false, "Show help")
|
|
||||||
|
|
||||||
flag.Parse()
|
func init() {
|
||||||
|
if len(os.Args) < 2 {
|
||||||
if *help {
|
fmt.Printf("Usage: %s <config.json>\n", os.Args[0])
|
||||||
flag.PrintDefaults()
|
os.Exit(1)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
config = srv.LoadConfig(os.Args[1])
|
||||||
filesDir := filepath.Clean(*filesDirectory)
|
fmt.Println("Config loaded")
|
||||||
_ = DeleteDirContents(filesDir)
|
}
|
||||||
err := MakeDir(filesDir)
|
|
||||||
if err != nil {
|
func main() {
|
||||||
log.Fatal(err)
|
srv.NewServer(config).Run()
|
||||||
}
|
|
||||||
|
|
||||||
address := fmt.Sprintf("%s:%d", *ip, *port)
|
|
||||||
|
|
||||||
hub := server.NewHub(*bufSize, server.Upgrader(*origin, *origin == ""))
|
|
||||||
go Run(hub)
|
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
|
|
||||||
mux.HandleFunc("/files", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
HandleFileUpload(hub, filesDir, time.Duration(*filesTimeout)*time.Second, w, r)
|
|
||||||
})
|
|
||||||
|
|
||||||
mux.HandleFunc("/files/", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
HandleFileDownload(filesDir, w, r)
|
|
||||||
})
|
|
||||||
|
|
||||||
mux.HandleFunc("/user-count", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
HandleUserCountCheck(hub, w, r)
|
|
||||||
})
|
|
||||||
|
|
||||||
mux.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
HandleWebSocket(hub, w, r)
|
|
||||||
})
|
|
||||||
|
|
||||||
mux.HandleFunc("/check-username", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
HandleUsernameCheck(hub, w, r)
|
|
||||||
})
|
|
||||||
|
|
||||||
var middleware []func(http.Handler) http.Handler
|
|
||||||
|
|
||||||
if *cachingDisabled {
|
|
||||||
middleware = append(middleware, server.CacheDisableMiddleware)
|
|
||||||
}
|
|
||||||
if *gzipEnabled {
|
|
||||||
middleware = append(middleware, server.GzipMiddleware)
|
|
||||||
}
|
|
||||||
|
|
||||||
fileServer := http.FileServer(http.Dir("./static/"))
|
|
||||||
mux.Handle("/", server.MiddlewareChain(middleware...)(fileServer))
|
|
||||||
|
|
||||||
log.Printf("Voice chat server starting on %s...\n", address)
|
|
||||||
log.Fatal(http.ListenAndServe(address, mux))
|
|
||||||
}
|
}
|
||||||
|
125
message.go
@ -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
|
|
||||||
}
|
|