All: Enhance Chat Mentions, Fix Threads CSS, Migrate Boards, and Add File-Based Avatar System #69

Open
jocadbz wants to merge 10 commits from jocadbz into master
22 changed files with 1338 additions and 451 deletions

3
.gitignore vendored
View File

@ -1,5 +1,8 @@
config/config.json config/config.json
config/about_page.htmlbody config/about_page.htmlbody
# Testing
files/
# nano # nano
.swp .swp

403
DOCUMENTATION.md Normal file
View File

@ -0,0 +1,403 @@
# ThreadR Rewritten - Technical Specification
## Project Overview
ThreadR Rewritten is a free and open-source forum engine, re-implemented in Go. It aims to provide a robust and extensible platform for users to host their own forum instances. The project, initially a PHP/MySQL school project, has been completely rewritten to leverage Go's performance and concurrency features. It supports traditional forum boards, real-time chat boards, user profiles, news announcements, and file uploads (specifically for profile pictures).
## File-by-File Explanation
This section details the purpose and functionality of each significant file and directory within the ThreadR project.
This, of course, assumes you have a decent understanding of Go.
### Configuration Files
* **config/config.json.sample**:
This file provides a template for the main application configuration. It defines critical parameters for the application to run, such as database credentials, domain, and file storage locations.
Example content:
{
"domain_name": "localhost",
"threadr_dir": "/threadr",
"db_username": "threadr_user",
"db_password": "threadr_password",
"db_database": "threadr_db",
"db_svr_host": "localhost:3306",
"file_storage_dir": "files"
}
* **config/config.json**:
The active configuration file, copied from `config.json.sample` and modified for the specific deployment. Contains sensitive information like database passwords.
* **config/about_page.htmlbody.sample**:
A template HTML snippet for the "About" page content. This allows administrators to customize the about page without modifying Go templates.
* **config/about_page.htmlbody**:
The active HTML content for the "About" page, copied from `about_page.htmlbody.sample` and modified as needed.
### Core Application Files
* **main.go**:
The entry point of the ThreadR application.
- Parses command-line flags (e.g., `--initialize` for database setup).
- Loads the `config/config.json` file.
- Establishes a connection to the MariaDB database.
- If `--initialize` is set:
- Calls `createTablesIfNotExist` to set up all necessary database tables.
- Calls `ensureAdminUser` to guide the creation of an initial admin user.
- Initializes Gorilla Sessions for session management.
- Loads HTML templates for rendering pages.
- Sets up all HTTP routes and maps them to their respective handler functions, wrapped with session and login middleware as needed.
- Starts the HTTP server on port 8080.
### Handlers Directory (`handlers/`)
This directory contains the HTTP handler functions that process incoming requests, interact with models, and render responses.
* **handlers/app.go**:
Defines common application-wide structures and middleware:
- `PageData`: A struct holding data passed to HTML templates for rendering common elements (title, navbar state, login status, cookie banner, base paths, current URL).
- `Config`: A struct to unmarshal application configuration from `config.json`.
Example JSON for `Config`:
{
"domain_name": "localhost",
"threadr_dir": "/threadr",
"db_username": "threadr_user",
"db_password": "threadr_password",
"db_database": "threadr_db",
"db_svr_host": "localhost:3306",
"file_storage_dir": "files"
}
- `App`: The main application context struct, holding pointers to the database connection, session store, configuration, and templates.
- `SessionMW`: Middleware to retrieve or create a new Gorilla session for each request, making the session available in the request context.
- `RequireLoginMW`: Middleware to enforce user authentication for specific routes, redirecting unauthenticated users to the login page.
* **handlers/about.go**:
Handles requests for the `/about/` page. Reads the `config/about_page.htmlbody` file and renders it within the `about.html` template.
* **handlers/accept_cookie.go**:
Handles the cookie banner acceptance. Sets a `threadr_cookie_banner` cookie in the user's browser for 30 days and redirects them back to their previous page or the home page.
* **handlers/board.go**:
Handles requests for individual forum boards (`/board/?id=<id>`).
- Fetches board details and threads within it.
- Enforces permissions for private boards (redirects if not logged in or unauthorized).
- Handles POST requests to create new threads within the board if the user is logged in and has permission.
- Renders the `board.html` template with board and thread data.
* **handlers/boards.go**:
Handles requests for the `/boards/` page, listing all available public and private boards.
- Checks if the logged-in user has admin privileges (`PermCreateBoard`).
- Handles POST requests to create new boards (classic or chat type) if the user is an admin.
- Filters private boards based on user permissions.
- Renders the `boards.html` template with lists of public and accessible private boards.
* **handlers/chat.go**:
Handles both rendering the chat interface and managing WebSocket connections for real-time chat.
- **HTTP Request (`/chat/?id=<id>`):**
- Authenticates the user and fetches board details.
- Enforces permissions for private chat boards.
- Fetches recent chat messages for the specified board.
- Renders the `chat.html` template.
- **WebSocket Request (`/chat/?ws=true&id=<id>`):**
- Upgrades the HTTP connection to a WebSocket.
- Manages client connections via a `ChatHub`.
- Receives JSON messages from clients, creates `models.ChatMessage`, saves them to the DB, and broadcasts them to all clients in the same board.
- `Client` struct: Represents an individual WebSocket connection with user and board ID.
- `ChatHub` struct: Manages active WebSocket clients, message broadcasting, and client registration/unregistration.
Example JSON message for broadcast:
{
"id": 123,
"boardId": 1,
"userId": 456,
"content": "Hello, world! @username",
"replyTo": -1,
"timestamp": "2024-07-30T10:30:00Z",
"username": "chatter1",
"pfpFileId": {
"Int64": 789,
"Valid": true
},
"mentions": ["username"]
}
* **handlers/file.go**:
Handles requests for serving uploaded files, primarily profile pictures (`/file?id=<id>`).
- Retrieves file metadata from the database using `models.GetFileByID`.
- Constructs the file path and serves the file using `http.ServeFile`.
* **handlers/home.go**:
Handles requests for the root path (`/`). Renders the `home.html` template, displaying a welcome message and the ThreadR logo.
* **handlers/like.go**:
Handles POST requests for liking or disliking posts (`/like/`).
- Requires login.
- Checks for existing likes/dislikes and updates or deletes them based on user action.
- Interacts with `models.Like` for database operations.
* **handlers/login.go**:
Handles user login (`/login/`).
- On GET: Renders the `login.html` template.
- On POST: Authenticates the user against the database, sets session values upon successful login, and redirects to user home. Shows an error if login fails.
* **handlers/logout.go**:
Handles user logout (`/logout/`). Clears the user's session and redirects to the home page.
* **handlers/news.go**:
Handles requests for the `/news/` page.
- Fetches and displays all news items from the database.
- If the user is an admin, it allows posting new news items via POST requests and deleting existing ones.
- Renders the `news.html` template.
* **handlers/profile.go**:
Handles requests for the user's profile page (`/profile/`).
- Requires login.
- Fetches user details from the database using `models.GetUserByID`.
- Renders the `profile.html` template, displaying user information.
* **handlers/profile_edit.go**:
Handles editing of user profiles, including display name, bio, and profile picture upload (`/profile/edit/`).
- Requires login.
- On GET: Fetches current user data and renders `profile_edit.html`.
- On POST: Processes form data, including file uploads.
- For profile pictures, it hashes the file, creates a `models.File` record, saves the file to disk, and updates the user's `pfp_file_id`.
- Updates the user's display name and bio in the database.
- Redirects to the profile page after successful update.
* **handlers/signup.go**:
Handles new user registration (`/signup/`).
- On GET: Renders the `signup.html` template.
- On POST: Creates a new user in the database after hashing the password. Redirects to the login page on success.
* **handlers/thread.go**:
Handles requests for individual discussion threads (`/thread/?id=<id>`).
- Fetches thread and associated posts.
- Enforces permissions for private boards (if the thread belongs to one).
- Handles POST requests to create new posts within the thread if the user is logged in and has permission.
- Renders the `thread.html` template with thread and post data.
* **handlers/userhome.go**:
Handles requests for the user's personal home page (`/userhome/`).
- Requires login.
- Fetches current user details.
- Renders the `userhome.html` template.
### Models Directory (`models/`)
This directory contains data structures and functions for interacting with the database. Each file typically corresponds to a database table or a logical data entity.
* **models/board.go**:
- `Board` struct: Represents a forum board.
Example `Board` struct:
type Board struct {
ID int
Name string
Description string
Private bool
PublicVisible bool
PinnedThreads []int // Stored as JSON array in DB
CustomLandingPage string
ColorScheme string
Type string // "classic" or "chat"
}
- `GetBoardByID`: Fetches a single board by its ID.
- `GetAllBoards`: Fetches all boards, optionally filtered by `private` status.
* **models/board_permission.go**:
- `BoardPermission` struct: Represents user permissions for a specific board.
- Defines bitmask constants for different permissions (`PermPostInBoard`, `PermModerateBoard`, `PermViewBoard`).
- `GetBoardPermission`: Retrieves a user's permissions for a given board.
- `SetBoardPermission`: Inserts or updates a user's permissions for a board.
- `HasBoardPermission`: Checks if a user has a specific permission for a board.
* **models/chat.go**:
- `ChatMessage` struct: Represents a single chat message in a chat board. Includes fields for user, content, reply, timestamp, and mentions.
Example `ChatMessage` struct:
type ChatMessage struct {
ID int `json:"id"`
BoardID int `json:"boardId"`
UserID int `json:"userId"`
Content string `json:"content"`
ReplyTo int `json:"replyTo"`
Timestamp time.Time `json:"timestamp"`
Username string `json:"username"`
PfpFileID sql.NullInt64 `json:"pfpFileId"`
Mentions []string `json:"mentions"`
}
Example JSON output (as seen in `handlers/chat.go` broadcast):
{
"id": 123,
"boardId": 1,
"userId": 456,
"content": "Hello, world! @username",
"replyTo": -1,
"timestamp": "2024-07-30T10:30:00Z",
"username": "chatter1",
"pfpFileId": {
"Int64": 789,
"Valid": true
},
"mentions": ["username"]
}
- `CreateChatMessage`: Inserts a new chat message into the database.
- `GetRecentChatMessages`: Retrieves a limited number of the most recent messages for a board.
- `GetChatMessageByID`: Fetches a single chat message by its ID.
- `extractMentions`: Utility function to parse usernames mentioned in a message.
* **models/file.go**:
- `File` struct: Represents metadata for an uploaded file (e.g., profile pictures).
Example `File` struct:
type File struct {
ID int
OriginalName string
Hash string
HashAlgorithm string
}
Hypothetical JSON representation:
{
"id": 789,
"originalName": "my_pfp.png",
"hash": "a1b2c3d4...",
"hashAlgorithm": "sha256"
}
- `GetFileByID`: Fetches file metadata by its ID.
- `CreateFile`: Creates a new file record in the database and returns its ID.
* **models/like.go**:
- `Like` struct: Represents a user's "like" or "dislike" on a post.
- `GetLikesByPostID`: Retrieves all likes/dislikes for a specific post.
- `GetLikeByPostAndUser`: Retrieves a specific like/dislike by post and user.
- `CreateLike`: Adds a new like/dislike.
- `UpdateLikeType`: Changes an existing like to a dislike or vice-versa.
- `DeleteLike`: Removes a like/dislike.
* **models/news.go**:
- `News` struct: Represents a news announcement.
- `GetAllNews`: Retrieves all news items, ordered by creation time.
- `CreateNews`: Adds a new news item.
- `DeleteNews`: Removes a news item.
* **models/notification.go**:
- `Notification` struct: Represents a user notification (stubbed for future expansion).
- `GetNotificationsByUserID`: Retrieves notifications for a specific user.
- `CreateNotification`: Stub for creating a notification.
- `MarkNotificationAsRead`: Stub for marking a notification as read.
* **models/post.go**:
- `Post` struct: Represents a forum post within a thread.
- `GetPostsByThreadID`: Retrieves all posts for a given thread.
- `CreatePost`: Adds a new post to a thread.
* **models/reaction.go**:
- `Reaction` struct: Represents a user's emoji reaction to a post (stubbed for future expansion).
- `GetReactionsByPostID`: Retrieves all reactions for a post.
- `CreateReaction`: Stub for creating a reaction.
- `DeleteReaction`: Stub for deleting a reaction.
* **models/repost.go**:
- `Repost` struct: Represents a re-post of a thread to another board (stubbed for future expansion).
- `GetRepostsByThreadID`: Retrieves all reposts for a thread.
- `CreateRepost`: Stub for creating a repost.
* **models/thread.go**:
- `Thread` struct: Represents a discussion thread within a board.
- `GetThreadByID`: Fetches a single thread by its ID.
- `GetThreadsByBoardID`: Fetches all threads for a given board.
- `CreateThread`: Adds a new thread to a board.
* **models/user.go**:
- `User` struct: Represents a user in the system, including authentication details, profile info, and global permissions.
Example `User` struct:
type User struct {
ID int
Username string
DisplayName string
PfpFileID sql.NullInt64 // Nullable foreign key to files.id
Bio string
AuthenticationString string
AuthenticationSalt string
AuthenticationAlgorithm string
CreatedAt time.Time
UpdatedAt time.Time
Verified bool
Permissions int64 // Bitmask for global permissions
}
Hypothetical JSON representation (sensitive fields omitted):
{
"id": 456,
"username": "testuser",
"displayName": "Test User",
"pfpFileId": { "Int64": 789, "Valid": true },
"bio": "Just a test user.",
"createdAt": "2024-01-01T00:00:00Z",
"updatedAt": "2024-07-30T10:00:00Z",
"verified": false,
"permissions": 3 // Assuming PermCreateBoard | PermManageUsers
}
- Defines bitmask constants for global permissions (`PermCreateBoard`, `PermManageUsers`).
- `GetUserByID`: Fetches a user by their ID.
- `GetUserByUsername`: Fetches a user by their username.
- `CheckPassword`: Verifies a given password against the stored hash, salt, and algorithm.
- `HashPassword`: Hashes a password using a salt and specified algorithm (currently SHA256).
- `CreateUser`: Creates a new user with hashed password.
- `UpdateUserProfile`: Updates a user's display name and bio.
- `UpdateUserPfp`: Updates a user's profile picture file ID.
- `HasGlobalPermission`: Checks if a user has a specific global permission.
### Static Directory (`static/`)
* **static/style.css**:
The main stylesheet for the ThreadR application, defining the visual theme, layout, and responsive design elements. It includes light and dark mode support.
* **static/img/ThreadR.png**:
The main logo or banner image for the ThreadR application, displayed on the home page.
### Templates Directory (`templates/`)
This directory holds HTML templates for rendering the user interface. It follows a structure of a base template, partials (reusable components), and page-specific templates.
* **templates/base.html**:
The foundational HTML template. It defines the basic HTML structure, includes the stylesheet, and incorporates the `navbar` and `cookie_banner` partials. It also defines a `content` block where page-specific content will be inserted.
* **templates/partials/cookie_banner.html**:
A reusable template snippet that renders a cookie consent banner at the bottom of the page if `ShowCookieBanner` is true in `PageData`.
* **templates/partials/navbar.html**:
A reusable template snippet that renders the navigation bar at the top of the page. It dynamically highlights the active page and shows different links based on `LoggedIn` status (e.g., Login/Signup vs. User Home/Profile/Logout).
* **templates/pages/about.html**:
Page-specific template for the "About" section. It's unique in that it directly renders `AboutContent` from the handler, allowing for fully custom HTML content without needing an additional `content` block.
* **templates/pages/board.html**:
Page-specific template for displaying an individual forum board, listing its threads and providing a form to create new threads.
* **templates/pages/boards.html**:
Page-specific template for listing all public and accessible private forum boards, and an admin form for creating new boards.
* **templates/pages/chat.html**:
Page-specific template for a real-time chat board. It includes:
- A header with board name and description.
- A scrollable `div` for chat messages.
- An input area with a `textarea` for messages and a "Send" button.
- Client-side JavaScript for WebSocket communication, message appending, replying to messages, and username autocomplete.
- Extensive inline CSS for chat-specific styling.
* **templates/pages/home.html**:
Page-specific template for the home page, displaying a welcome message and the `ThreadR.png` image.
* **templates/pages/login.html**:
Page-specific template for the user login form. Displays error messages if authentication fails.
* **templates/pages/news.html**:
Page-specific template for displaying news announcements. Includes forms for admins to post and delete news items.
* **templates/pages/profile.html**:
Page-specific template for displaying a user's profile information. Shows username, display name, profile picture (if uploaded), bio, and account creation/update times.
* **templates/pages/profile_edit.html**:
Page-specific template for editing a user's profile. Provides forms to update display name, bio, and upload a new profile picture.
* **templates/pages/signup.html**:
Page-specific template for the new user registration form.
* **templates/pages/thread.html**:
Page-specific template for displaying an individual discussion thread, listing its posts, and providing forms for users to post new messages or reply to existing ones.

View File

@ -4,5 +4,6 @@
"db_username": "threadr_user", "db_username": "threadr_user",
"db_password": "threadr_password", "db_password": "threadr_password",
"db_database": "threadr_db", "db_database": "threadr_db",
"db_svr_host": "localhost:3306" "db_svr_host": "localhost:3306",
"file_storage_dir": "files"
} }

View File

@ -1,11 +1,13 @@
package handlers package handlers
import ( import (
"context" "context"
"database/sql" "database/sql"
"html/template" "html/template"
"net/http" "log"
"github.com/gorilla/sessions" "net/http"
"github.com/gorilla/sessions"
) )
type PageData struct { type PageData struct {
@ -19,12 +21,13 @@ type PageData struct {
} }
type Config struct { type Config struct {
DomainName string `json:"domain_name"` DomainName string `json:"domain_name"`
ThreadrDir string `json:"threadr_dir"` ThreadrDir string `json:"threadr_dir"`
DBUsername string `json:"db_username"` DBUsername string `json:"db_username"`
DBPassword string `json:"db_password"` DBPassword string `json:"db_password"`
DBDatabase string `json:"db_database"` DBDatabase string `json:"db_database"`
DBServerHost string `json:"db_svr_host"` DBServerHost string `json:"db_svr_host"`
FileStorageDir string `json:"file_storage_dir"`
} }
type App struct { type App struct {
@ -45,24 +48,21 @@ func (app *App) SessionMW(next http.HandlerFunc) http.HandlerFunc {
HttpOnly: true, HttpOnly: true,
} }
} }
if _, ok := session.Values["user_id"].(int); ok {
// Skip IP and User-Agent check for WebSocket connections ctx := context.WithValue(r.Context(), "session", session)
if r.URL.Query().Get("ws") != "true" { r = r.WithContext(ctx)
if session.Values["user_ip"] != r.RemoteAddr || session.Values["user_agent"] != r.UserAgent() {
session.Values = make(map[interface{}]interface{})
session.Options.MaxAge = -1
session.Save(r, w)
http.Redirect(w, r, app.Config.ThreadrDir+"/login/?error=session", http.StatusFound)
return
}
}
ctx := context.WithValue(r.Context(), "session", session)
r = r.WithContext(ctx)
} else {
ctx := context.WithValue(r.Context(), "session", session)
r = r.WithContext(ctx)
}
next(w, r) next(w, r)
if err := session.Save(r, w); err != nil {
/*
Ok, so here's the thing
Errors coming from this function here "can" be ignored.
They mostly come from errors while setting cookies, so in some
environments this will trigger a lot, but they are harmless.
*/
log.Printf("Error saving session in SessionMW: %v", err)
}
} }
} }
@ -75,4 +75,4 @@ func (app *App) RequireLoginMW(next http.HandlerFunc) http.HandlerFunc {
} }
next(w, r) next(w, r)
} }
} }

View File

@ -33,6 +33,11 @@ func BoardHandler(app *App) http.HandlerFunc {
return return
} }
if board.Type == "chat" {
http.Redirect(w, r, app.Config.ThreadrDir+"/chat/?id="+boardIDStr, http.StatusFound)
return
}
if board.Private { if board.Private {
if !loggedIn { if !loggedIn {
http.Redirect(w, r, app.Config.ThreadrDir+"/login/", http.StatusFound) http.Redirect(w, r, app.Config.ThreadrDir+"/login/", http.StatusFound)

View File

@ -28,25 +28,39 @@ func BoardsHandler(app *App) http.HandlerFunc {
if r.Method == http.MethodPost && loggedIn && isAdmin { if r.Method == http.MethodPost && loggedIn && isAdmin {
name := r.FormValue("name") name := r.FormValue("name")
description := r.FormValue("description") description := r.FormValue("description")
boardType := r.FormValue("type")
if name == "" { if name == "" {
http.Error(w, "Board name is required", http.StatusBadRequest) http.Error(w, "Board name is required", http.StatusBadRequest)
return return
} }
if boardType != "classic" && boardType != "chat" {
boardType = "classic"
}
board := models.Board{ board := models.Board{
Name: name, Name: name,
Description: description, Description: description,
Private: false, Private: false,
PublicVisible: true, PublicVisible: true,
Type: boardType,
} }
query := "INSERT INTO boards (name, description, private, public_visible) VALUES (?, ?, ?, ?)" query := "INSERT INTO boards (name, description, private, public_visible, type) VALUES (?, ?, ?, ?, ?)"
result, err := app.DB.Exec(query, board.Name, board.Description, board.Private, board.PublicVisible) result, err := app.DB.Exec(query, board.Name, board.Description, board.Private, board.PublicVisible, board.Type)
if err != nil { if err != nil {
log.Printf("Error creating board: %v", err) log.Printf("Error creating board: %v", err)
http.Error(w, "Failed to create board", http.StatusInternalServerError) http.Error(w, "Failed to create board", http.StatusInternalServerError)
return return
} }
boardID, _ := result.LastInsertId() boardID, _ := result.LastInsertId()
http.Redirect(w, r, app.Config.ThreadrDir+"/board/?id="+strconv.FormatInt(boardID, 10), http.StatusFound)
var redirectURL string
if boardType == "chat" {
redirectURL = app.Config.ThreadrDir + "/chat/?id=" + strconv.FormatInt(boardID, 10)
} else {
redirectURL = app.Config.ThreadrDir + "/board/?id=" + strconv.FormatInt(boardID, 10)
}
http.Redirect(w, r, redirectURL, http.StatusFound)
return return
} }

View File

@ -2,10 +2,13 @@ package handlers
import ( import (
"encoding/json" "encoding/json"
"html/template"
"log" "log"
"net/http" "net/http"
"strconv"
"sync" "sync"
"threadr/models" "threadr/models"
"github.com/gorilla/sessions" "github.com/gorilla/sessions"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
) )
@ -18,21 +21,26 @@ var upgrader = websocket.Upgrader{
}, },
} }
// ChatHub manages WebSocket connections and broadcasts messages type Client struct {
conn *websocket.Conn
userID int
boardID int
}
type ChatHub struct { type ChatHub struct {
clients map[*websocket.Conn]int // Map of connections to user IDs clients map[*Client]bool
broadcast chan []byte broadcast chan models.ChatMessage
register chan *websocket.Conn register chan *Client
unregister chan *websocket.Conn unregister chan *Client
mutex sync.Mutex mutex sync.Mutex
} }
func NewChatHub() *ChatHub { func NewChatHub() *ChatHub {
return &ChatHub{ return &ChatHub{
clients: make(map[*websocket.Conn]int), clients: make(map[*Client]bool),
broadcast: make(chan []byte), broadcast: make(chan models.ChatMessage),
register: make(chan *websocket.Conn), register: make(chan *Client),
unregister: make(chan *websocket.Conn), unregister: make(chan *Client),
} }
} }
@ -41,21 +49,26 @@ func (h *ChatHub) Run() {
select { select {
case client := <-h.register: case client := <-h.register:
h.mutex.Lock() h.mutex.Lock()
h.clients[client] = 0 // UserID set later h.clients[client] = true
h.mutex.Unlock() h.mutex.Unlock()
case client := <-h.unregister: case client := <-h.unregister:
h.mutex.Lock() h.mutex.Lock()
delete(h.clients, client) if _, ok := h.clients[client]; ok {
delete(h.clients, client)
client.conn.Close()
}
h.mutex.Unlock() h.mutex.Unlock()
client.Close()
case message := <-h.broadcast: case message := <-h.broadcast:
h.mutex.Lock() h.mutex.Lock()
for client := range h.clients { for client := range h.clients {
err := client.WriteMessage(websocket.TextMessage, message) if client.boardID == message.BoardID {
if err != nil { response, _ := json.Marshal(message)
log.Printf("Error broadcasting message: %v", err) err := client.conn.WriteMessage(websocket.TextMessage, response)
client.Close() if err != nil {
delete(h.clients, client) log.Printf("Error broadcasting message: %v", err)
client.conn.Close()
delete(h.clients, client)
}
} }
} }
h.mutex.Unlock() h.mutex.Unlock()
@ -69,6 +82,12 @@ func init() {
go hub.Run() go hub.Run()
} }
type IncomingChatMessage struct {
Type string `json:"type"`
Content string `json:"content"`
ReplyTo int `json:"replyTo"`
}
func ChatHandler(app *App) http.HandlerFunc { func ChatHandler(app *App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*sessions.Session) session := r.Context().Value("session").(*sessions.Session)
@ -79,20 +98,64 @@ func ChatHandler(app *App) http.HandlerFunc {
} }
cookie, _ := r.Cookie("threadr_cookie_banner") cookie, _ := r.Cookie("threadr_cookie_banner")
boardIDStr := r.URL.Query().Get("id")
boardID, err := strconv.Atoi(boardIDStr)
if err != nil {
http.Error(w, "Invalid board ID", http.StatusBadRequest)
return
}
board, err := models.GetBoardByID(app.DB, boardID)
if err != nil {
log.Printf("Error fetching board: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
if board == nil {
http.Error(w, "Chat board not found", http.StatusNotFound)
return
}
if board.Type != "chat" {
http.Error(w, "This is not a chat board", http.StatusBadRequest)
return
}
if board.Private {
hasPerm, err := models.HasBoardPermission(app.DB, userID, boardID, models.PermViewBoard)
if err != nil {
log.Printf("Error checking permission: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
if !hasPerm {
http.Error(w, "You do not have permission to view this chat", http.StatusForbidden)
return
}
}
currentUser, err := models.GetUserByID(app.DB, userID)
if err != nil {
log.Printf("Error fetching current user: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
if currentUser == nil {
http.Error(w, "User not found", http.StatusNotFound)
return
}
currentUsername := currentUser.Username
if r.URL.Query().Get("ws") == "true" { if r.URL.Query().Get("ws") == "true" {
// Handle WebSocket connection
ws, err := upgrader.Upgrade(w, r, nil) ws, err := upgrader.Upgrade(w, r, nil)
if err != nil { if err != nil {
log.Printf("Error upgrading to WebSocket: %v", err) log.Printf("Error upgrading to WebSocket: %v", err)
return return
} }
hub.register <- ws client := &Client{conn: ws, userID: userID, boardID: boardID}
hub.mutex.Lock() hub.register <- client
hub.clients[ws] = userID
hub.mutex.Unlock()
defer func() { defer func() {
hub.unregister <- ws hub.unregister <- client
}() }()
for { for {
@ -101,83 +164,72 @@ func ChatHandler(app *App) http.HandlerFunc {
log.Printf("Error reading WebSocket message: %v", err) log.Printf("Error reading WebSocket message: %v", err)
break break
} }
var chatMsg struct { var chatMsg IncomingChatMessage
Type string `json:"type"`
Content string `json:"content"`
ReplyTo int `json:"replyTo"`
}
if err := json.Unmarshal(msg, &chatMsg); err != nil { if err := json.Unmarshal(msg, &chatMsg); err != nil {
log.Printf("Error unmarshaling message: %v", err) log.Printf("Error unmarshaling message: %v", err)
continue continue
} }
if chatMsg.Type == "message" { if chatMsg.Type == "message" {
msgObj := models.ChatMessage{ if err := models.CreateChatMessage(app.DB, boardID, userID, chatMsg.Content, chatMsg.ReplyTo); err != nil {
UserID: userID,
Content: chatMsg.Content,
ReplyTo: chatMsg.ReplyTo,
}
if err := models.CreateChatMessage(app.DB, msgObj); err != nil {
log.Printf("Error saving chat message: %v", err) log.Printf("Error saving chat message: %v", err)
continue continue
} }
// Fetch the saved message with timestamp and user details
var msgID int var msgID int
app.DB.QueryRow("SELECT LAST_INSERT_ID()").Scan(&msgID) err = app.DB.QueryRow("SELECT LAST_INSERT_ID()").Scan(&msgID)
savedMsg, err := models.GetChatMessageByID(app.DB, msgID)
if err != nil { if err != nil {
log.Printf("Error fetching saved message: %v", err) log.Printf("Error getting last insert id: %v", err)
continue continue
} }
response, _ := json.Marshal(savedMsg) savedMsg, err := models.GetChatMessageByID(app.DB, msgID)
hub.broadcast <- response if err != nil {
log.Printf("Error fetching saved message for broadcast: %v", err)
continue
}
hub.broadcast <- *savedMsg
} }
} }
return return
} }
if r.URL.Query().Get("autocomplete") == "true" { messages, err := models.GetRecentChatMessages(app.DB, boardID, 50)
// Handle autocomplete for mentions
prefix := r.URL.Query().Get("prefix")
usernames, err := models.GetUsernamesMatching(app.DB, prefix)
if err != nil {
log.Printf("Error fetching usernames for autocomplete: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
response, _ := json.Marshal(usernames)
w.Header().Set("Content-Type", "application/json")
w.Write(response)
return
}
// Render chat page
messages, err := models.GetRecentChatMessages(app.DB, 50)
if err != nil { if err != nil {
log.Printf("Error fetching chat messages: %v", err) log.Printf("Error fetching chat messages: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError) http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return return
} }
// Reverse messages to show oldest first
for i, j := 0, len(messages)-1; i < j; i, j = i+1, j-1 { for i, j := 0, len(messages)-1; i < j; i, j = i+1, j-1 {
messages[i], messages[j] = messages[j], messages[i] messages[i], messages[j] = messages[j], messages[i]
} }
allUsernames, err := models.GetUsernamesInBoard(app.DB, boardID)
if err != nil {
log.Printf("Error fetching usernames for board: %v", err)
allUsernames = []string{}
}
allUsernamesJSON, _ := json.Marshal(allUsernames)
data := struct { data := struct {
PageData PageData
Messages []models.ChatMessage Board models.Board
Messages []models.ChatMessage
AllUsernames template.JS
CurrentUsername string
}{ }{
PageData: PageData{ PageData: PageData{
Title: "ThreadR - Chat", Title: "ThreadR Chat - " + board.Name,
Navbar: "chat", Navbar: "boards",
LoggedIn: true, LoggedIn: true,
ShowCookieBanner: cookie == nil || cookie.Value != "accepted", ShowCookieBanner: cookie == nil || cookie.Value != "accepted",
BasePath: app.Config.ThreadrDir, BasePath: app.Config.ThreadrDir,
StaticPath: app.Config.ThreadrDir + "/static", StaticPath: app.Config.ThreadrDir + "/static",
CurrentURL: r.URL.Path, CurrentURL: r.URL.Path,
}, },
Messages: messages, Board: *board,
Messages: messages,
AllUsernames: template.JS(allUsernamesJSON),
CurrentUsername: currentUsername,
} }
if err := app.Tmpl.ExecuteTemplate(w, "chat", data); err != nil { if err := app.Tmpl.ExecuteTemplate(w, "chat", data); err != nil {
log.Printf("Error executing template in ChatHandler: %v", err) log.Printf("Error executing template in ChatHandler: %v", err)

32
handlers/file.go Normal file
View File

@ -0,0 +1,32 @@
package handlers
import (
"fmt"
"net/http"
"path/filepath"
"strconv"
"threadr/models"
)
func FileHandler(app *App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
fileIDStr := r.URL.Query().Get("id")
fileID, err := strconv.ParseInt(fileIDStr, 10, 64)
if err != nil {
http.NotFound(w, r)
return
}
file, err := models.GetFileByID(app.DB, fileID)
if err != nil || file == nil {
http.NotFound(w, r)
return
}
fileExt := filepath.Ext(file.OriginalName)
fileName := fmt.Sprintf("%d%s", fileID, fileExt)
filePath := filepath.Join(app.Config.FileStorageDir, fileName)
http.ServeFile(w, r, filePath)
}
}

View File

@ -1,34 +1,99 @@
package handlers package handlers
import ( import (
"log" "crypto/sha256"
"net/http" "fmt"
"threadr/models" "io"
"github.com/gorilla/sessions" "log"
"net/http"
"os"
"path/filepath"
"threadr/models"
"github.com/gorilla/sessions"
) )
func ProfileEditHandler(app *App) http.HandlerFunc { func ProfileEditHandler(app *App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*sessions.Session) session := r.Context().Value("session").(*sessions.Session)
userID, ok := session.Values["user_id"].(int) userID, ok := session.Values["user_id"].(int)
if !ok { if !ok {
http.Redirect(w, r, app.Config.ThreadrDir+"/login/", http.StatusFound) http.Redirect(w, r, app.Config.ThreadrDir+"/login/", http.StatusFound)
return return
} }
if r.Method == http.MethodPost { if r.Method == http.MethodPost {
displayName := r.FormValue("display_name") // Handle file upload
pfpURL := r.FormValue("pfp_url") file, handler, err := r.FormFile("pfp")
bio := r.FormValue("bio") if err == nil {
err := models.UpdateUserProfile(app.DB, userID, displayName, pfpURL, bio) defer file.Close()
if err != nil {
log.Printf("Error updating profile: %v", err) // Create a hash of the file
http.Error(w, "Failed to update profile", http.StatusInternalServerError) h := sha256.New()
return if _, err := io.Copy(h, file); err != nil {
} log.Printf("Error hashing file: %v", err)
http.Redirect(w, r, app.Config.ThreadrDir+"/profile/", http.StatusFound) http.Error(w, "Failed to process file", http.StatusInternalServerError)
return return
} }
fileHash := fmt.Sprintf("%x", h.Sum(nil))
// Create file record in the database
fileRecord := models.File{
OriginalName: handler.Filename,
Hash: fileHash,
HashAlgorithm: "sha256",
}
fileID, err := models.CreateFile(app.DB, fileRecord)
if err != nil {
log.Printf("Error creating file record: %v", err)
http.Error(w, "Failed to save file information", http.StatusInternalServerError)
return
}
// Save the file to disk
fileExt := filepath.Ext(handler.Filename)
newFileName := fmt.Sprintf("%d%s", fileID, fileExt)
filePath := filepath.Join(app.Config.FileStorageDir, newFileName)
// Reset file pointer
file.Seek(0, 0)
dst, err := os.Create(filePath)
if err != nil {
log.Printf("Error creating file on disk: %v", err)
http.Error(w, "Failed to save file", http.StatusInternalServerError)
return
}
defer dst.Close()
if _, err := io.Copy(dst, file); err != nil {
log.Printf("Error saving file to disk: %v", err)
http.Error(w, "Failed to save file", http.StatusInternalServerError)
return
}
// Update user's pfp_file_id
err = models.UpdateUserPfp(app.DB, userID, fileID)
if err != nil {
log.Printf("Error updating user pfp: %v", err)
http.Error(w, "Failed to update profile", http.StatusInternalServerError)
return
}
}
// Update other profile fields
displayName := r.FormValue("display_name")
bio := r.FormValue("bio")
err = models.UpdateUserProfile(app.DB, userID, displayName, bio)
if err != nil {
log.Printf("Error updating profile: %v", err)
http.Error(w, "Failed to update profile", http.StatusInternalServerError)
return
}
http.Redirect(w, r, app.Config.ThreadrDir+"/profile/", http.StatusFound)
return
}
user, err := models.GetUserByID(app.DB, userID) user, err := models.GetUserByID(app.DB, userID)
if err != nil { if err != nil {
@ -62,4 +127,4 @@ func ProfileEditHandler(app *App) http.HandlerFunc {
return return
} }
} }
} }

75
main.go
View File

@ -43,33 +43,14 @@ func createTablesIfNotExist(db *sql.DB) error {
public_visible BOOLEAN DEFAULT TRUE, public_visible BOOLEAN DEFAULT TRUE,
pinned_threads TEXT, pinned_threads TEXT,
custom_landing_page TEXT, custom_landing_page TEXT,
color_scheme VARCHAR(255) color_scheme VARCHAR(255),
type VARCHAR(20) DEFAULT 'classic' NOT NULL
)`) )`)
if err != nil { if err != nil {
return fmt.Errorf("error creating boards table: %v", err) return fmt.Errorf("error creating boards table: %v", err)
} }
// Create users table // Create threads table
_, err = db.Exec(`
CREATE TABLE users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(255) NOT NULL UNIQUE,
display_name VARCHAR(255),
pfp_url VARCHAR(255),
bio TEXT,
authentication_string VARCHAR(128) NOT NULL,
authentication_salt VARCHAR(255) NOT NULL,
authentication_algorithm VARCHAR(50) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
verified BOOLEAN DEFAULT FALSE,
permissions BIGINT DEFAULT 0
)`)
if err != nil {
return fmt.Errorf("error creating users table: %v", err)
}
// Create threads table (without type field)
_, err = db.Exec(` _, err = db.Exec(`
CREATE TABLE threads ( CREATE TABLE threads (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
@ -190,15 +171,52 @@ func createTablesIfNotExist(db *sql.DB) error {
_, err = db.Exec(` _, err = db.Exec(`
CREATE TABLE chat_messages ( CREATE TABLE chat_messages (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
board_id INT NOT NULL,
user_id INT NOT NULL, user_id INT NOT NULL,
content TEXT NOT NULL, content TEXT NOT NULL,
reply_to INT DEFAULT -1, reply_to INT DEFAULT -1,
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (board_id) REFERENCES boards(id) ON DELETE CASCADE
)`) )`)
if err != nil { if err != nil {
return fmt.Errorf("error creating chat_messages table: %v", err) return fmt.Errorf("error creating chat_messages table: %v", err)
} }
// Create files table (Hope this does not break anything)
_, err = db.Exec(`
CREATE TABLE files (
id INT AUTO_INCREMENT PRIMARY KEY,
original_name VARCHAR(255) NOT NULL,
hash VARCHAR(255) NOT NULL,
hash_algorithm VARCHAR(50) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)`)
if err != nil {
return fmt.Errorf("error creating files table: %v", err)
}
// Create users table (KEEP THIS HERE!)
// Otherwise SQL bitches about the foreign key.
_, err = db.Exec(`
CREATE TABLE users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(255) NOT NULL UNIQUE,
display_name VARCHAR(255),
pfp_file_id INT,
bio TEXT,
authentication_string VARCHAR(128) NOT NULL,
authentication_salt VARCHAR(255) NOT NULL,
authentication_algorithm VARCHAR(50) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
verified BOOLEAN DEFAULT FALSE,
permissions BIGINT DEFAULT 0,
FOREIGN KEY (pfp_file_id) REFERENCES files(id)
)`)
if err != nil {
return fmt.Errorf("error creating users table: %v", err)
}
log.Println("Database tables created.") log.Println("Database tables created.")
return nil return nil
} }
@ -283,6 +301,14 @@ func main() {
} }
defer db.Close() defer db.Close()
// Create the file directory
// TODO: Wouldn't this be better suited on the initialize function?
// Discussion pending.
err = os.MkdirAll(config.FileStorageDir, 0700)
if err != nil {
log.Fatal("Error creating file storage directory:", err)
}
// Perform initialization if the flag is set // Perform initialization if the flag is set
if *initialize { if *initialize {
log.Println("Initializing database...") log.Println("Initializing database...")
@ -360,7 +386,8 @@ func main() {
http.HandleFunc(config.ThreadrDir+"/signup/", app.SessionMW(handlers.SignupHandler(app))) http.HandleFunc(config.ThreadrDir+"/signup/", app.SessionMW(handlers.SignupHandler(app)))
http.HandleFunc(config.ThreadrDir+"/accept_cookie/", app.SessionMW(handlers.AcceptCookieHandler(app))) http.HandleFunc(config.ThreadrDir+"/accept_cookie/", app.SessionMW(handlers.AcceptCookieHandler(app)))
http.HandleFunc(config.ThreadrDir+"/chat/", app.SessionMW(app.RequireLoginMW(handlers.ChatHandler(app)))) http.HandleFunc(config.ThreadrDir+"/chat/", app.SessionMW(app.RequireLoginMW(handlers.ChatHandler(app))))
http.HandleFunc(config.ThreadrDir+"/file", app.SessionMW(handlers.FileHandler(app)))
log.Println("Server starting on :8080") log.Println("Server starting on :8080")
log.Fatal(http.ListenAndServe(":8080", nil)) log.Fatal(http.ListenAndServe(":8080", nil))
} }

View File

@ -14,17 +14,18 @@ type Board struct {
PinnedThreads []int // Stored as JSON PinnedThreads []int // Stored as JSON
CustomLandingPage string CustomLandingPage string
ColorScheme string ColorScheme string
Type string
} }
func GetBoardByID(db *sql.DB, id int) (*Board, error) { func GetBoardByID(db *sql.DB, id int) (*Board, error) {
query := "SELECT id, name, description, private, public_visible, pinned_threads, custom_landing_page, color_scheme FROM boards WHERE id = ?" query := "SELECT id, name, description, private, public_visible, pinned_threads, custom_landing_page, color_scheme, type FROM boards WHERE id = ?"
row := db.QueryRow(query, id) row := db.QueryRow(query, id)
board := &Board{} board := &Board{}
var pinnedThreadsJSON sql.NullString var pinnedThreadsJSON sql.NullString
var customLandingPage sql.NullString var customLandingPage sql.NullString
var colorScheme sql.NullString var colorScheme sql.NullString
var description sql.NullString var description sql.NullString
err := row.Scan(&board.ID, &board.Name, &description, &board.Private, &board.PublicVisible, &pinnedThreadsJSON, &customLandingPage, &colorScheme) err := row.Scan(&board.ID, &board.Name, &description, &board.Private, &board.PublicVisible, &pinnedThreadsJSON, &customLandingPage, &colorScheme, &board.Type)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return nil, nil return nil, nil
} }
@ -56,7 +57,7 @@ func GetBoardByID(db *sql.DB, id int) (*Board, error) {
} }
func GetAllBoards(db *sql.DB, private bool) ([]Board, error) { func GetAllBoards(db *sql.DB, private bool) ([]Board, error) {
query := "SELECT id, name, description, private, public_visible, pinned_threads, custom_landing_page, color_scheme FROM boards WHERE private = ? ORDER BY id ASC" query := "SELECT id, name, description, private, public_visible, pinned_threads, custom_landing_page, color_scheme, type FROM boards WHERE private = ? ORDER BY id ASC"
rows, err := db.Query(query, private) rows, err := db.Query(query, private)
if err != nil { if err != nil {
return nil, err return nil, err
@ -70,7 +71,7 @@ func GetAllBoards(db *sql.DB, private bool) ([]Board, error) {
var customLandingPage sql.NullString var customLandingPage sql.NullString
var colorScheme sql.NullString var colorScheme sql.NullString
var description sql.NullString var description sql.NullString
err := rows.Scan(&board.ID, &board.Name, &description, &board.Private, &board.PublicVisible, &pinnedThreadsJSON, &customLandingPage, &colorScheme) err := rows.Scan(&board.ID, &board.Name, &description, &board.Private, &board.PublicVisible, &pinnedThreadsJSON, &customLandingPage, &colorScheme, &board.Type)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@ -2,34 +2,41 @@ package models
import ( import (
"database/sql" "database/sql"
"fmt"
"html"
"html/template"
"regexp"
"strings"
"time" "time"
) )
type ChatMessage struct { type ChatMessage struct {
ID int ID int `json:"id"`
UserID int BoardID int `json:"boardId"`
Content string UserID int `json:"userId"`
ReplyTo int // -1 if not a reply Content template.HTML `json:"content"`
Timestamp time.Time ReplyTo int `json:"replyTo"`
Username string // For display, fetched from user Timestamp time.Time `json:"timestamp"`
PfpURL string // For display, fetched from user Username string `json:"username"`
Mentions []string // List of mentioned usernames PfpFileID sql.NullInt64 `json:"pfpFileId"`
Mentions []string `json:"mentions"`
} }
func CreateChatMessage(db *sql.DB, msg ChatMessage) error { func CreateChatMessage(db *sql.DB, boardID, userID int, rawContent string, replyTo int) error {
query := "INSERT INTO chat_messages (user_id, content, reply_to, timestamp) VALUES (?, ?, ?, NOW())" query := "INSERT INTO chat_messages (board_id, user_id, content, reply_to, timestamp) VALUES (?, ?, ?, ?, NOW())"
_, err := db.Exec(query, msg.UserID, msg.Content, msg.ReplyTo) _, err := db.Exec(query, boardID, userID, rawContent, replyTo)
return err return err
} }
func GetRecentChatMessages(db *sql.DB, limit int) ([]ChatMessage, error) { func GetRecentChatMessages(db *sql.DB, boardID int, limit int) ([]ChatMessage, error) {
query := ` query := `
SELECT cm.id, cm.user_id, cm.content, cm.reply_to, cm.timestamp, u.username, u.pfp_url SELECT cm.id, cm.user_id, cm.content, cm.reply_to, cm.timestamp, u.username, u.pfp_file_id
FROM chat_messages cm FROM chat_messages cm
JOIN users u ON cm.user_id = u.id JOIN users u ON cm.user_id = u.id
WHERE cm.board_id = ?
ORDER BY cm.timestamp DESC ORDER BY cm.timestamp DESC
LIMIT ?` LIMIT ?`
rows, err := db.Query(query, limit) rows, err := db.Query(query, boardID, limit)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -39,20 +46,18 @@ func GetRecentChatMessages(db *sql.DB, limit int) ([]ChatMessage, error) {
for rows.Next() { for rows.Next() {
var msg ChatMessage var msg ChatMessage
var timestampStr string var timestampStr string
var pfpURL sql.NullString var rawContent string
err := rows.Scan(&msg.ID, &msg.UserID, &msg.Content, &msg.ReplyTo, &timestampStr, &msg.Username, &pfpURL) err := rows.Scan(&msg.ID, &msg.UserID, &rawContent, &msg.ReplyTo, &timestampStr, &msg.Username, &msg.PfpFileID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
msg.BoardID = boardID
msg.Timestamp, err = time.Parse("2006-01-02 15:04:05", timestampStr) msg.Timestamp, err = time.Parse("2006-01-02 15:04:05", timestampStr)
if err != nil { if err != nil {
msg.Timestamp = time.Time{} msg.Timestamp = time.Time{}
} }
if pfpURL.Valid { msg.Content = renderMarkdown(rawContent)
msg.PfpURL = pfpURL.String msg.Mentions = extractMentions(rawContent)
}
// Parse mentions from content (simple @username detection)
msg.Mentions = extractMentions(msg.Content)
messages = append(messages, msg) messages = append(messages, msg)
} }
return messages, nil return messages, nil
@ -60,15 +65,15 @@ func GetRecentChatMessages(db *sql.DB, limit int) ([]ChatMessage, error) {
func GetChatMessageByID(db *sql.DB, id int) (*ChatMessage, error) { func GetChatMessageByID(db *sql.DB, id int) (*ChatMessage, error) {
query := ` query := `
SELECT cm.id, cm.user_id, cm.content, cm.reply_to, cm.timestamp, u.username, u.pfp_url SELECT cm.id, cm.board_id, cm.user_id, cm.content, cm.reply_to, cm.timestamp, u.username, u.pfp_file_id
FROM chat_messages cm FROM chat_messages cm
JOIN users u ON cm.user_id = u.id JOIN users u ON cm.user_id = u.id
WHERE cm.id = ?` WHERE cm.id = ?`
row := db.QueryRow(query, id) row := db.QueryRow(query, id)
var msg ChatMessage var msg ChatMessage
var timestampStr string var timestampStr string
var pfpURL sql.NullString var rawContent string
err := row.Scan(&msg.ID, &msg.UserID, &msg.Content, &msg.ReplyTo, &timestampStr, &msg.Username, &pfpURL) err := row.Scan(&msg.ID, &msg.BoardID, &msg.UserID, &rawContent, &msg.ReplyTo, &timestampStr, &msg.Username, &msg.PfpFileID)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return nil, nil return nil, nil
} }
@ -79,54 +84,122 @@ func GetChatMessageByID(db *sql.DB, id int) (*ChatMessage, error) {
if err != nil { if err != nil {
msg.Timestamp = time.Time{} msg.Timestamp = time.Time{}
} }
if pfpURL.Valid { msg.Content = renderMarkdown(rawContent)
msg.PfpURL = pfpURL.String msg.Mentions = extractMentions(rawContent)
}
msg.Mentions = extractMentions(msg.Content)
return &msg, nil return &msg, nil
} }
func GetUsernamesMatching(db *sql.DB, prefix string) ([]string, error) {
query := "SELECT username FROM users WHERE username LIKE ? LIMIT 10"
rows, err := db.Query(query, prefix+"%")
if err != nil {
return nil, err
}
defer rows.Close()
var usernames []string
for rows.Next() {
var username string
if err := rows.Scan(&username); err != nil {
return nil, err
}
usernames = append(usernames, username)
}
return usernames, nil
}
// Simple utility to extract mentions from content
func extractMentions(content string) []string { func extractMentions(content string) []string {
var mentions []string re := regexp.MustCompile(`@(\w+)`)
var currentMention string matches := re.FindAllStringSubmatch(content, -1)
inMention := false mentions := make([]string, len(matches))
for i, match := range matches {
for _, char := range content { mentions[i] = match[1]
if char == '@' {
inMention = true
currentMention = "@"
} else if inMention && (char == ' ' || char == '\n' || char == '\t') {
if len(currentMention) > 1 {
mentions = append(mentions, currentMention)
}
inMention = false
currentMention = ""
} else if inMention {
currentMention += string(char)
}
}
if inMention && len(currentMention) > 1 {
mentions = append(mentions, currentMention)
} }
return mentions return mentions
}
func processInlineMarkdown(line string) string {
line = regexp.MustCompile("`([^`]+)`").ReplaceAllString(line, "<code>$1</code>")
line = regexp.MustCompile(`\*\*([^\*]+)\*\*`).ReplaceAllString(line, "<strong>$1</strong>")
line = regexp.MustCompile(`__([^_]+)__`).ReplaceAllString(line, "<strong>$1</strong>")
line = regexp.MustCompile(`\*([^\*]+)\*`).ReplaceAllString(line, "<em>$1</em>")
line = regexp.MustCompile(`_([^_]+)_`).ReplaceAllString(line, "<em>$1</em>")
line = regexp.MustCompile(`@(\w+)`).ReplaceAllString(line, `<span class="chat-message-mention">@$1</span>`)
return line
}
func renderMarkdown(content string) template.HTML {
var sb strings.Builder
codeBlockRegex := regexp.MustCompile("(?s)```(\\w*)\n(.*?)\n```")
codeBlocks := make(map[string]string)
codeBlockCounter := 0
contentWithoutCodeBlocks := codeBlockRegex.ReplaceAllStringFunc(content, func(match string) string {
submatches := codeBlockRegex.FindStringSubmatch(match)
lang := submatches[1]
codeContent := submatches[2]
escapedCode := html.EscapeString(codeContent)
var renderedCodeBlock string
if lang != "" {
renderedCodeBlock = fmt.Sprintf("<pre><code class=\"language-%s\">%s</code></pre>", lang, escapedCode)
} else {
renderedCodeBlock = fmt.Sprintf("<pre><code>%s</code></pre>", escapedCode)
}
placeholder := fmt.Sprintf("<!--CODEBLOCK_%d-->", codeBlockCounter)
codeBlocks[placeholder] = renderedCodeBlock
codeBlockCounter++
return placeholder
})
lines := strings.Split(contentWithoutCodeBlocks, "\n")
inList := false
for _, line := range lines {
trimmedLine := strings.TrimSpace(line)
if strings.HasPrefix(trimmedLine, "### ") {
if inList { sb.WriteString("</ul>\n"); inList = false }
sb.WriteString("<h3>")
sb.WriteString(processInlineMarkdown(strings.TrimPrefix(trimmedLine, "### ")))
sb.WriteString("</h3>\n")
continue
} else if strings.HasPrefix(trimmedLine, "## ") {
if inList { sb.WriteString("</ul>\n"); inList = false }
sb.WriteString("<h2>")
sb.WriteString(processInlineMarkdown(strings.TrimPrefix(trimmedLine, "## ")))
sb.WriteString("</h2>\n")
continue
} else if strings.HasPrefix(trimmedLine, "# ") {
if inList { sb.WriteString("</ul>\n"); inList = false }
sb.WriteString("<h1>")
sb.WriteString(processInlineMarkdown(strings.TrimPrefix(trimmedLine, "# ")))
sb.WriteString("</h1>\n")
continue
}
if strings.HasPrefix(trimmedLine, "* ") || strings.HasPrefix(trimmedLine, "- ") {
if !inList {
sb.WriteString("<ul>\n")
inList = true
}
listItemContent := strings.TrimPrefix(strings.TrimPrefix(trimmedLine, "* "), "- ")
sb.WriteString("<li>")
sb.WriteString(processInlineMarkdown(listItemContent))
sb.WriteString("</li>\n")
continue
}
if inList {
sb.WriteString("</ul>\n")
inList = false
}
if trimmedLine != "" {
sb.WriteString("<p>")
sb.WriteString(processInlineMarkdown(trimmedLine))
sb.WriteString("</p>\n")
} else {
sb.WriteString("\n")
}
}
if inList {
sb.WriteString("</ul>\n")
}
finalContent := sb.String()
for placeholder, blockHTML := range codeBlocks {
finalContent = strings.ReplaceAll(finalContent, placeholder, blockHTML)
}
finalContent = regexp.MustCompile(`\n{3,}`).ReplaceAllString(finalContent, "\n\n")
return template.HTML(finalContent)
} }

35
models/file.go Normal file
View File

@ -0,0 +1,35 @@
package models
import (
"database/sql"
)
type File struct {
ID int
OriginalName string
Hash string
HashAlgorithm string
}
func GetFileByID(db *sql.DB, id int64) (*File, error) {
query := "SELECT id, original_name, hash, hash_algorithm FROM files WHERE id = ?"
row := db.QueryRow(query, id)
file := &File{}
err := row.Scan(&file.ID, &file.OriginalName, &file.Hash, &file.HashAlgorithm)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return file, nil
}
func CreateFile(db *sql.DB, file File) (int64, error) {
query := "INSERT INTO files (original_name, hash, hash_algorithm) VALUES (?, ?, ?)"
result, err := db.Exec(query, file.OriginalName, file.Hash, file.HashAlgorithm)
if err != nil {
return 0, err
}
return result.LastInsertId()
}

View File

@ -70,7 +70,7 @@ func GetThreadsByBoardID(db *sql.DB, boardID int) ([]Thread, error) {
} }
func CreateThread(db *sql.DB, thread Thread) error { func CreateThread(db *sql.DB, thread Thread) error {
query := "INSERT INTO threads (board_id, title, created_by_user_id, created_at, updated_at, type) VALUES (?, ?, ?, NOW(), NOW(), 'classic')" query := "INSERT INTO threads (board_id, title, created_by_user_id, created_at, updated_at) VALUES (?, ?, ?, NOW(), NOW())"
_, err := db.Exec(query, thread.BoardID, thread.Title, thread.CreatedByUserID) _, err := db.Exec(query, thread.BoardID, thread.Title, thread.CreatedByUserID)
return err return err
} }

View File

@ -1,161 +1,179 @@
package models package models
import ( import (
"crypto/sha256" "crypto/sha256"
"database/sql" "database/sql"
"fmt" "fmt"
"time" "time"
) )
type User struct { type User struct {
ID int ID int
Username string Username string
DisplayName string DisplayName string
PfpURL string PfpFileID sql.NullInt64
Bio string Bio string
AuthenticationString string AuthenticationString string
AuthenticationSalt string AuthenticationSalt string
AuthenticationAlgorithm string AuthenticationAlgorithm string
CreatedAt time.Time CreatedAt time.Time
UpdatedAt time.Time UpdatedAt time.Time
Verified bool Verified bool
Permissions int64 Permissions int64
} }
func GetUserByID(db *sql.DB, id int) (*User, error) { func GetUserByID(db *sql.DB, id int) (*User, error) {
query := "SELECT id, username, display_name, pfp_url, bio, authentication_string, authentication_salt, authentication_algorithm, created_at, updated_at, verified, permissions FROM users WHERE id = ?" query := "SELECT id, username, display_name, pfp_file_id, bio, authentication_string, authentication_salt, authentication_algorithm, created_at, updated_at, verified, permissions FROM users WHERE id = ?"
row := db.QueryRow(query, id) row := db.QueryRow(query, id)
user := &User{} user := &User{}
var displayName sql.NullString var displayName sql.NullString
var pfpURL sql.NullString var bio sql.NullString
var bio sql.NullString var createdAtString sql.NullString
var createdAtString sql.NullString var updatedAtString sql.NullString
var updatedAtString sql.NullString err := row.Scan(&user.ID, &user.Username, &displayName, &user.PfpFileID, &bio, &user.AuthenticationString, &user.AuthenticationSalt, &user.AuthenticationAlgorithm, &createdAtString, &updatedAtString, &user.Verified, &user.Permissions)
err := row.Scan(&user.ID, &user.Username, &displayName, &pfpURL, &bio, &user.AuthenticationString, &user.AuthenticationSalt, &user.AuthenticationAlgorithm, &createdAtString, &updatedAtString, &user.Verified, &user.Permissions) if err == sql.ErrNoRows {
if err == sql.ErrNoRows { return nil, nil
return nil, nil }
} if err != nil {
if err != nil { return nil, err
return nil, err }
} if displayName.Valid {
if displayName.Valid { user.DisplayName = displayName.String
user.DisplayName = displayName.String } else {
} else { user.DisplayName = ""
user.DisplayName = "" }
} if bio.Valid {
if pfpURL.Valid { user.Bio = bio.String
user.PfpURL = pfpURL.String } else {
} else { user.Bio = ""
user.PfpURL = "" }
} if createdAtString.Valid {
if bio.Valid { user.CreatedAt, err = time.Parse("2006-01-02 15:04:05", createdAtString.String)
user.Bio = bio.String if err != nil {
} else { return nil, fmt.Errorf("error parsing created_at: %v", err)
user.Bio = "" }
} } else {
if createdAtString.Valid { user.CreatedAt = time.Time{}
user.CreatedAt, err = time.Parse("2006-01-02 15:04:05", createdAtString.String) }
if err != nil { if updatedAtString.Valid {
return nil, fmt.Errorf("error parsing created_at: %v", err) user.UpdatedAt, err = time.Parse("2006-01-02 15:04:05", updatedAtString.String)
} if err != nil {
} else { return nil, fmt.Errorf("error parsing updated_at: %v", err)
user.CreatedAt = time.Time{} }
} } else {
if updatedAtString.Valid { user.UpdatedAt = time.Time{}
user.UpdatedAt, err = time.Parse("2006-01-02 15:04:05", updatedAtString.String) }
if err != nil { return user, nil
return nil, fmt.Errorf("error parsing updated_at: %v", err)
}
} else {
user.UpdatedAt = time.Time{}
}
return user, nil
} }
func GetUserByUsername(db *sql.DB, username string) (*User, error) { func GetUserByUsername(db *sql.DB, username string) (*User, error) {
query := "SELECT id, username, display_name, pfp_url, bio, authentication_string, authentication_salt, authentication_algorithm, created_at, updated_at, verified, permissions FROM users WHERE username = ?" query := "SELECT id, username, display_name, pfp_file_id, bio, authentication_string, authentication_salt, authentication_algorithm, created_at, updated_at, verified, permissions FROM users WHERE username = ?"
row := db.QueryRow(query, username) row := db.QueryRow(query, username)
user := &User{} user := &User{}
var displayName sql.NullString var displayName sql.NullString
var pfpURL sql.NullString var bio sql.NullString
var bio sql.NullString var createdAtString sql.NullString
var createdAtString sql.NullString var updatedAtString sql.NullString
var updatedAtString sql.NullString err := row.Scan(&user.ID, &user.Username, &displayName, &user.PfpFileID, &bio, &user.AuthenticationString, &user.AuthenticationSalt, &user.AuthenticationAlgorithm, &createdAtString, &updatedAtString, &user.Verified, &user.Permissions)
err := row.Scan(&user.ID, &user.Username, &displayName, &pfpURL, &bio, &user.AuthenticationString, &user.AuthenticationSalt, &user.AuthenticationAlgorithm, &createdAtString, &updatedAtString, &user.Verified, &user.Permissions) if err != nil {
if err != nil { return nil, err
return nil, err }
} if displayName.Valid {
if displayName.Valid { user.DisplayName = displayName.String
user.DisplayName = displayName.String } else {
} else { user.DisplayName = ""
user.DisplayName = "" }
} if bio.Valid {
if pfpURL.Valid { user.Bio = bio.String
user.PfpURL = pfpURL.String } else {
} else { user.Bio = ""
user.PfpURL = "" }
} if createdAtString.Valid {
if bio.Valid { user.CreatedAt, err = time.Parse("2006-01-02 15:04:05", createdAtString.String)
user.Bio = bio.String if err != nil {
} else { return nil, fmt.Errorf("error parsing created_at: %v", err)
user.Bio = "" }
} } else {
if createdAtString.Valid { user.CreatedAt = time.Time{}
user.CreatedAt, err = time.Parse("2006-01-02 15:04:05", createdAtString.String) }
if err != nil { if updatedAtString.Valid {
return nil, fmt.Errorf("error parsing created_at: %v", err) user.UpdatedAt, err = time.Parse("2006-01-02 15:04:05", updatedAtString.String)
} if err != nil {
} else { return nil, fmt.Errorf("error parsing updated_at: %v", err)
user.CreatedAt = time.Time{} }
} } else {
if updatedAtString.Valid { user.UpdatedAt = time.Time{}
user.UpdatedAt, err = time.Parse("2006-01-02 15:04:05", updatedAtString.String) }
if err != nil { return user, nil
return nil, fmt.Errorf("error parsing updated_at: %v", err)
}
} else {
user.UpdatedAt = time.Time{}
}
return user, nil
} }
func CheckPassword(password, salt, algorithm, hash string) bool { func CheckPassword(password, salt, algorithm, hash string) bool {
if algorithm != "sha256" { if algorithm != "sha256" {
return false return false
} }
computedHash := HashPassword(password, salt, algorithm) computedHash := HashPassword(password, salt, algorithm)
return computedHash == hash return computedHash == hash
} }
func HashPassword(password, salt, algorithm string) string { func HashPassword(password, salt, algorithm string) string {
if algorithm != "sha256" { if algorithm != "sha256" {
return "" return ""
} }
data := password + salt data := password + salt
hash := sha256.Sum256([]byte(data)) hash := sha256.Sum256([]byte(data))
return fmt.Sprintf("%x", hash) return fmt.Sprintf("%x", hash)
} }
func CreateUser(db *sql.DB, username, password string) error { func CreateUser(db *sql.DB, username, password string) error {
salt := "random-salt" // Replace with secure random generation salt := "random-salt" // Replace with secure random generation
algorithm := "sha256" algorithm := "sha256"
hash := HashPassword(password, salt, algorithm) hash := HashPassword(password, salt, algorithm)
query := "INSERT INTO users (username, authentication_string, authentication_salt, authentication_algorithm, created_at, updated_at, verified, permissions) VALUES (?, ?, ?, ?, NOW(), NOW(), ?, 0)" query := "INSERT INTO users (username, authentication_string, authentication_salt, authentication_algorithm, created_at, updated_at, verified, permissions) VALUES (?, ?, ?, ?, NOW(), NOW(), ?, 0)"
_, err := db.Exec(query, username, hash, salt, algorithm, false) _, err := db.Exec(query, username, hash, salt, algorithm, false)
return err return err
} }
func UpdateUserProfile(db *sql.DB, userID int, displayName, pfpURL, bio string) error { func UpdateUserProfile(db *sql.DB, userID int, displayName, bio string) error {
query := "UPDATE users SET display_name = ?, pfp_url = ?, bio = ?, updated_at = NOW() WHERE id = ?" query := "UPDATE users SET display_name = ?, bio = ?, updated_at = NOW() WHERE id = ?"
_, err := db.Exec(query, displayName, pfpURL, bio, userID) _, err := db.Exec(query, displayName, bio, userID)
return err return err
}
func UpdateUserPfp(db *sql.DB, userID int, pfpFileID int64) error {
query := "UPDATE users SET pfp_file_id = ? WHERE id = ?"
_, err := db.Exec(query, pfpFileID, userID)
return err
} }
const ( const (
PermCreateBoard int64 = 1 << 0 PermCreateBoard int64 = 1 << 0
PermManageUsers int64 = 1 << 1 PermManageUsers int64 = 1 << 1
) )
func HasGlobalPermission(user *User, perm int64) bool { func HasGlobalPermission(user *User, perm int64) bool {
return user.Permissions&perm != 0 return user.Permissions&perm != 0
}
func GetUsernamesInBoard(db *sql.DB, boardID int) ([]string, error) {
query := `
SELECT DISTINCT u.username
FROM users u
JOIN chat_messages cm ON u.id = cm.user_id
WHERE cm.board_id = ?
ORDER BY u.username ASC`
rows, err := db.Query(query, boardID)
if err != nil {
return nil, err
}
defer rows.Close()
var usernames []string
for rows.Next() {
var username string
if err := rows.Scan(&username); err != nil {
return nil, err
}
usernames = append(usernames, username)
}
return usernames, nil
} }

View File

@ -176,7 +176,7 @@ p, a, li {
color: #001858; color: #001858;
} }
/* Enhanced styles for boards */ /* Styles for board lists */
ul.board-list { ul.board-list {
list-style-type: none; list-style-type: none;
padding: 0; padding: 0;
@ -185,7 +185,7 @@ ul.board-list {
li.board-item { li.board-item {
margin-bottom: 1em; margin-bottom: 1em;
padding: 1em; padding: 1.2em 1.5em;
background-color: #fef6e4; background-color: #fef6e4;
border: 1px solid #001858; border: 1px solid #001858;
border-radius: 8px; border-radius: 8px;
@ -201,7 +201,7 @@ li.board-item a {
color: #001858; color: #001858;
font-weight: bold; font-weight: bold;
text-decoration: none; text-decoration: none;
font-size: 1.2em; font-size: 1.4em;
} }
li.board-item a:hover { li.board-item a:hover {
@ -212,10 +212,50 @@ li.board-item a:hover {
p.board-desc { p.board-desc {
margin: 0.5em 0 0 0; margin: 0.5em 0 0 0;
color: #001858; color: #001858;
font-size: 0.9em; font-size: 1em;
} }
/* Enhanced styles for thread posts */ /* Styles for thread lists */
ul.thread-list {
list-style-type: none;
padding: 0;
margin: 0;
}
li.thread-item {
margin-bottom: 1em;
padding: 1.2em 1.5em;
background-color: #fef6e4;
border: 1px solid #001858;
border-radius: 8px;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
li.thread-item:hover {
transform: translateY(-3px);
box-shadow: 0px 6px 14px 0px rgba(0,0,0,0.15);
}
li.thread-item a {
color: #001858;
font-weight: bold;
text-decoration: none;
font-size: 1.4em;
}
li.thread-item a:hover {
color: #f582ae;
text-decoration: underline;
}
p.thread-info {
margin: 0.5em 0 0 0;
color: #001858;
font-size: 1em;
}
/* Specific styles for individual thread posts */
.thread-posts { .thread-posts {
width: 80%; width: 80%;
max-width: 800px; max-width: 800px;
@ -226,7 +266,7 @@ p.board-desc {
border: 1px solid #001858; border: 1px solid #001858;
border-radius: 8px; border-radius: 8px;
margin-bottom: 1.5em; margin-bottom: 1.5em;
padding: 1em; padding: 1.2em 1.5em;
transition: transform 0.2s ease, box-shadow 0.2s ease; transition: transform 0.2s ease, box-shadow 0.2s ease;
} }
@ -239,27 +279,27 @@ p.board-desc {
background-color: #001858; background-color: #001858;
color: #fef6e4; color: #fef6e4;
padding: 0.5em; padding: 0.5em;
margin: -1em -1em 1em -1em; margin: -1.2em -1.5em 1em -1.5em;
border-radius: 6px 6px 0 0; border-radius: 6px 6px 0 0;
border-bottom: 1px solid #001858; border-bottom: 1px solid #001858;
} }
.post-item header h3 { .post-item header h3 {
margin: 0; margin: 0;
font-size: 1.1em; font-size: 1.2em;
} }
.post-item header p { .post-item header p {
margin: 0.3em 0 0 0; margin: 0.3em 0 0 0;
font-size: 0.85em; font-size: 0.95em;
opacity: 0.9; opacity: 0.9;
} }
.post-content { .post-content {
margin: 0; margin: 0;
padding: 0.5em; padding: 0.8em;
line-height: 1.5; line-height: 1.5;
font-size: 0.95em; font-size: 1em;
} }
.post-actions { .post-actions {
@ -272,8 +312,8 @@ p.board-desc {
.post-actions a { .post-actions a {
color: #001858; color: #001858;
text-decoration: none; text-decoration: none;
font-size: 0.9em; font-size: 1em;
padding: 0.3em 0.6em; padding: 0.4em 0.8em;
border: 1px solid #001858; border: 1px solid #001858;
border-radius: 4px; border-radius: 4px;
transition: background-color 0.2s ease; transition: background-color 0.2s ease;
@ -284,6 +324,23 @@ p.board-desc {
color: #fef6e4; color: #fef6e4;
} }
/* New style for highlighted chat messages */
.chat-message-highlighted {
border: 2px solid #f582ae; /* Pink border */
background-color: #ffe0f0; /* Light pink background */
animation: highlight-fade 2s ease-out;
}
@keyframes highlight-fade {
from {
background-color: #f582ae;
border-color: #f582ae;
}
to {
background-color: #ffe0f0;
border-color: #f582ae;
}
}
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
body { body {
background-color: #333; background-color: #333;
@ -313,17 +370,17 @@ p.board-desc {
input[type="submit"]:hover, button:hover { input[type="submit"]:hover, button:hover {
background-color: #8bd3dd; background-color: #8bd3dd;
} }
li.board-item { li.board-item, li.thread-item {
background-color: #444; background-color: #444;
border-color: #fef6e4; border-color: #fef6e4;
} }
li.board-item a { li.board-item a, li.thread-item a {
color: #fef6e4; color: #fef6e4;
} }
li.board-item a:hover { li.board-item a:hover, li.thread-item a:hover {
color: #f582ae; color: #f582ae;
} }
p.board-desc { p.board-desc, p.thread-info {
color: #fef6e4; color: #fef6e4;
} }
.post-item { .post-item {
@ -347,6 +404,22 @@ p.board-desc {
p, a, li { p, a, li {
color: #fef6e4; color: #fef6e4;
} }
/* Dark mode highlight */
.chat-message-highlighted {
border: 2px solid #f582ae; /* Pink border */
background-color: #6a0e3f; /* Darker pink background */
animation: highlight-fade-dark 2s ease-out;
}
@keyframes highlight-fade-dark {
from {
background-color: #f582ae;
border-color: #f582ae;
}
to {
background-color: #6a0e3f;
border-color: #f582ae;
}
}
} }
@media (max-width: 600px) { @media (max-width: 600px) {

View File

@ -15,9 +15,12 @@
<section> <section>
<h3>Threads</h3> <h3>Threads</h3>
{{if .Threads}} {{if .Threads}}
<ul> <ul class="thread-list">
{{range .Threads}} {{range .Threads}}
<li><a href="{{$.BasePath}}/thread/?id={{.ID}}">{{.Title}}</a> - Updated on {{.UpdatedAt.Format "02/01/2006 - 15:04"}}</li> <li class="thread-item">
<a href="{{$.BasePath}}/thread/?id={{.ID}}">{{.Title}}</a>
<p class="thread-info">Updated on {{.UpdatedAt.Format "02/01/2006 - 15:04"}}</p>
</li>
{{end}} {{end}}
</ul> </ul>
{{else}} {{else}}

View File

@ -17,7 +17,11 @@
<ul class="board-list"> <ul class="board-list">
{{range .PublicBoards}} {{range .PublicBoards}}
<li class="board-item"> <li class="board-item">
{{if eq .Type "chat"}}
<a href="{{$.BasePath}}/chat/?id={{.ID}}">{{.Name}} (Chat)</a>
{{else}}
<a href="{{$.BasePath}}/board/?id={{.ID}}">{{.Name}}</a> <a href="{{$.BasePath}}/board/?id={{.ID}}">{{.Name}}</a>
{{end}}
<p class="board-desc">{{.Description}}</p> <p class="board-desc">{{.Description}}</p>
</li> </li>
{{end}} {{end}}
@ -33,7 +37,11 @@
<ul class="board-list"> <ul class="board-list">
{{range .PrivateBoards}} {{range .PrivateBoards}}
<li class="board-item"> <li class="board-item">
{{if eq .Type "chat"}}
<a href="{{$.BasePath}}/chat/?id={{.ID}}">{{.Name}} (Chat)</a>
{{else}}
<a href="{{$.BasePath}}/board/?id={{.ID}}">{{.Name}}</a> <a href="{{$.BasePath}}/board/?id={{.ID}}">{{.Name}}</a>
{{end}}
<p class="board-desc">{{.Description}}</p> <p class="board-desc">{{.Description}}</p>
</li> </li>
{{end}} {{end}}
@ -51,6 +59,11 @@
<input type="text" id="name" name="name" required><br> <input type="text" id="name" name="name" required><br>
<label for="description">Description:</label> <label for="description">Description:</label>
<textarea id="description" name="description"></textarea><br> <textarea id="description" name="description"></textarea><br>
<label for="type">Board Type:</label>
<select id="type" name="type">
<option value="classic">Classic Board</option>
<option value="chat">Chat Board</option>
</select><br>
<input type="submit" value="Create Board"> <input type="submit" value="Create Board">
</form> </form>
</section> </section>

View File

@ -21,7 +21,7 @@
} }
.chat-container { .chat-container {
width: 100%; width: 100%;
height: calc(100% - 2em); /* Adjust for header */ height: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
border: none; border: none;
@ -29,6 +29,11 @@
background-color: #fef6e4; background-color: #fef6e4;
box-shadow: none; box-shadow: none;
} }
.chat-header {
padding: 10px;
text-align: center;
border-bottom: 1px solid #001858;
}
.chat-messages { .chat-messages {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
@ -167,9 +172,27 @@
background: none; background: none;
color: #f582ae; color: #f582ae;
} }
/* New style for highlighted messages */
.chat-message-highlighted {
border: 2px solid #f582ae; /* Pink border */
background-color: #ffe0f0; /* Light pink background */
animation: highlight-fade 2s ease-out;
}
@keyframes highlight-fade {
from {
background-color: #f582ae;
border-color: #f582ae;
}
to {
background-color: #ffe0f0;
border-color: #f582ae;
}
}
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
.chat-container { .chat-container {
background-color: #444; background-color: #444;
}
.chat-header {
border-color: #fef6e4; border-color: #fef6e4;
} }
.chat-message-username { .chat-message-username {
@ -210,22 +233,39 @@
.reply-indicator button:hover { .reply-indicator button:hover {
color: #f582ae; color: #f582ae;
} }
/* Dark mode highlight */
.chat-message-highlighted {
border: 2px solid #f582ae; /* Pink border */
background-color: #6a0e3f; /* Darker pink background */
animation: highlight-fade-dark 2s ease-out;
}
@keyframes highlight-fade-dark {
from {
background-color: #f582ae;
border-color: #f582ae;
}
to {
background-color: #6a0e3f;
border-color: #f582ae;
}
}
} }
</style> </style>
</head> </head>
<body> <body>
{{template "navbar" .}} {{template "navbar" .}}
<main> <main>
<header style="display: none;">
<h2>General Chat</h2>
</header>
<div class="chat-container"> <div class="chat-container">
<header class="chat-header">
<h2>{{.Board.Name}}</h2>
<p>{{.Board.Description}}</p>
</header>
<div class="chat-messages" id="chat-messages"> <div class="chat-messages" id="chat-messages">
{{range .Messages}} {{range .Messages}}
<div class="chat-message" id="msg-{{.ID}}"> <div class="chat-message{{if .Mentions}}{{range .Mentions}}{{if eq . $.CurrentUsername}} chat-message-highlighted{{end}}{{end}}{{end}}" id="msg-{{.ID}}">
<div class="chat-message-header"> <div class="chat-message-header">
{{if .PfpURL}} {{if .PfpFileID.Valid}}
<img src="{{.PfpURL}}" alt="PFP" class="chat-message-pfp"> <img src="{{$.BasePath}}/file?id={{.PfpFileID.Int64}}" alt="PFP" class="chat-message-pfp">
{{else}} {{else}}
<div class="chat-message-pfp" style="background-color: #001858;"></div> <div class="chat-message-pfp" style="background-color: #001858;"></div>
{{end}} {{end}}
@ -233,11 +273,11 @@
<span class="chat-message-timestamp">{{.Timestamp.Format "02/01/2006 15:04"}}</span> <span class="chat-message-timestamp">{{.Timestamp.Format "02/01/2006 15:04"}}</span>
</div> </div>
{{if gt .ReplyTo 0}} {{if gt .ReplyTo 0}}
<div class="chat-message-reply" onclick="scrollToMessage({{.ReplyTo}})">Replying to {{.Username}}</div> <div class="chat-message-reply" onclick="scrollToMessage({{.ReplyTo}})">Replying to message...</div>
{{end}} {{end}}
<div class="chat-message-content">{{.Content | html}}</div> <div class="chat-message-content">{{.Content}}</div>
<div class="post-actions"> <div class="post-actions">
<a href="javascript:void(0)" onclick="replyToMessage({{.ID}}, '{{.Username}}')">Reply</a> <a href="javascript:void(0)" onclick="replyToMessage({{printf "%v" .ID}}, '{{.Username}}')">Reply</a>
</div> </div>
</div> </div>
{{end}} {{end}}
@ -257,19 +297,20 @@
<script> <script>
let ws; let ws;
let autocompleteActive = false; let autocompleteActive = false;
let autocompletePrefix = '';
let replyToId = -1; let replyToId = -1;
let replyUsername = ''; const allUsernames = {{.AllUsernames}};
const currentUsername = "{{.CurrentUsername}}";
function connectWebSocket() { function connectWebSocket() {
ws = new WebSocket('ws://' + window.location.host + '{{.BasePath}}/chat/?ws=true', [], { credentials: 'include' }); const boardID = {{.Board.ID}};
ws = new WebSocket('ws://' + window.location.host + '{{.BasePath}}/chat/?ws=true&id=' + boardID);
ws.onmessage = function(event) { ws.onmessage = function(event) {
const msg = JSON.parse(event.data); const msg = JSON.parse(event.data);
appendMessage(msg); appendMessage(msg);
}; };
ws.onclose = function() { ws.onclose = function() {
console.log("WebSocket closed, reconnecting..."); console.log("WebSocket closed, reconnecting...");
setTimeout(connectWebSocket, 5000); // Reconnect after 5s setTimeout(connectWebSocket, 5000);
}; };
ws.onerror = function(error) { ws.onerror = function(error) {
console.error("WebSocket error:", error); console.error("WebSocket error:", error);
@ -288,7 +329,7 @@
if (ws && ws.readyState === WebSocket.OPEN) { if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(msg)); ws.send(JSON.stringify(msg));
input.value = ''; input.value = '';
cancelReply(); // Reset reply state after sending cancelReply();
} else { } else {
console.error("WebSocket is not open. Current state:", ws ? ws.readyState : 'undefined'); console.error("WebSocket is not open. Current state:", ws ? ws.readyState : 'undefined');
} }
@ -297,22 +338,28 @@
function appendMessage(msg) { function appendMessage(msg) {
const messages = document.getElementById('chat-messages'); const messages = document.getElementById('chat-messages');
const msgDiv = document.createElement('div'); const msgDiv = document.createElement('div');
msgDiv.className = 'chat-message'; let highlightClass = '';
msgDiv.id = 'msg-' + msg.ID; if (msg.mentions && msg.mentions.includes(currentUsername)) {
let pfpHTML = msg.PfpURL ? `<img src="${msg.PfpURL}" alt="PFP" class="chat-message-pfp">` : `<div class="chat-message-pfp" style="background-color: #001858;"></div>`; highlightClass = ' chat-message-highlighted';
let replyHTML = msg.ReplyTo > 0 ? `<div class="chat-message-reply" onclick="scrollToMessage(${msg.ReplyTo})">Replying to ${msg.Username}</div>` : ''; }
// Process content for mentions msgDiv.className = 'chat-message' + highlightClass;
let content = msg.Content.replace(/@[\w]+/g, match => `<span class="chat-message-mention">${match}</span>`); msgDiv.id = 'msg-' + msg.id;
let pfpHTML = `<div class="chat-message-pfp" style="background-color: #001858;"></div>`;
if (msg.pfpFileId && msg.pfpFileId.Valid) {
pfpHTML = `<img src="{{.BasePath}}/file?id=${msg.pfpFileId.Int64}&t=${new Date().getTime()}" alt="PFP" class="chat-message-pfp">`;
}
let replyHTML = msg.replyTo > 0 ? `<div class="chat-message-reply" onclick="scrollToMessage(${msg.replyTo})">Replying to message...</div>` : '';
msgDiv.innerHTML = ` msgDiv.innerHTML = `
<div class="chat-message-header"> <div class="chat-message-header">
${pfpHTML} ${pfpHTML}
<span class="chat-message-username">${msg.Username}</span> <span class="chat-message-username">${msg.username}</span>
<span class="chat-message-timestamp">${new Date(msg.Timestamp).toLocaleString()}</span> <span class="chat-message-timestamp">${new Date(msg.timestamp).toLocaleString()}</span>
</div> </div>
${replyHTML} ${replyHTML}
<div class="chat-message-content">${content}</div> <div class="chat-message-content">${msg.content}</div>
<div class="post-actions"> <div class="post-actions">
<a href="javascript:void(0)" onclick="replyToMessage(${msg.ID}, '${msg.Username}')">Reply</a> <a href="javascript:void(0)" onclick="replyToMessage(${msg.id}, '${msg.username}')">Reply</a>
</div> </div>
`; `;
messages.appendChild(msgDiv); messages.appendChild(msgDiv);
@ -321,7 +368,6 @@
function replyToMessage(id, username) { function replyToMessage(id, username) {
replyToId = id; replyToId = id;
replyUsername = username;
const replyIndicator = document.getElementById('reply-indicator'); const replyIndicator = document.getElementById('reply-indicator');
const replyUsernameSpan = document.getElementById('reply-username'); const replyUsernameSpan = document.getElementById('reply-username');
replyUsernameSpan.textContent = `Replying to ${username}`; replyUsernameSpan.textContent = `Replying to ${username}`;
@ -331,7 +377,6 @@
function cancelReply() { function cancelReply() {
replyToId = -1; replyToId = -1;
replyUsername = '';
const replyIndicator = document.getElementById('reply-indicator'); const replyIndicator = document.getElementById('reply-indicator');
replyIndicator.style.display = 'none'; replyIndicator.style.display = 'none';
} }
@ -339,22 +384,27 @@
function scrollToMessage(id) { function scrollToMessage(id) {
const msgElement = document.getElementById('msg-' + id); const msgElement = document.getElementById('msg-' + id);
if (msgElement) { if (msgElement) {
msgElement.scrollIntoView({ behavior: 'smooth' }); msgElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
msgElement.style.transition = 'background-color 0.5s';
msgElement.style.backgroundColor = '#f582ae';
setTimeout(() => { msgElement.style.backgroundColor = ''; }, 1000);
} }
} }
function showAutocompletePopup(usernames, x, y) { function showAutocompletePopup(suggestions, x, y) {
const popup = document.getElementById('autocomplete-popup'); const popup = document.getElementById('autocomplete-popup');
popup.innerHTML = ''; popup.innerHTML = '';
popup.style.position = 'fixed';
popup.style.left = x + 'px'; popup.style.left = x + 'px';
popup.style.top = y + 'px'; popup.style.top = y + 'px';
popup.style.display = 'block'; popup.style.display = 'block';
autocompleteActive = true; autocompleteActive = true;
usernames.forEach(username => { suggestions.forEach(username => {
const item = document.createElement('div'); const item = document.createElement('div');
item.className = 'autocomplete-item'; item.className = 'autocomplete-item';
item.textContent = username; item.textContent = username;
item.onclick = () => { item.onmousedown = (e) => {
e.preventDefault();
completeMention(username); completeMention(username);
popup.style.display = 'none'; popup.style.display = 'none';
autocompleteActive = false; autocompleteActive = false;
@ -366,65 +416,85 @@
function completeMention(username) { function completeMention(username) {
const input = document.getElementById('chat-input-text'); const input = document.getElementById('chat-input-text');
const text = input.value; const text = input.value;
const atIndex = text.lastIndexOf('@', input.selectionStart - 1); const caretPos = input.selectionStart;
const textBeforeCaret = text.substring(0, caretPos);
const atIndex = textBeforeCaret.lastIndexOf('@');
if (atIndex !== -1) { if (atIndex !== -1) {
const before = text.substring(0, atIndex); const prefix = text.substring(0, atIndex);
const after = text.substring(input.selectionStart); const suffix = text.substring(caretPos);
input.value = before + username + (after.startsWith(' ') ? '' : ' ') + after; input.value = prefix + '@' + username + ' ' + suffix;
const newCaretPos = prefix.length + 1 + username.length + 1;
input.focus(); input.focus();
input.setSelectionRange(newCaretPos, newCaretPos);
} }
} }
document.getElementById('chat-input-text').addEventListener('input', async (e) => { function getCaretCoordinates(element, position) {
const text = e.target.value; const mirrorDivId = 'input-mirror-div';
const caretPos = e.target.selectionStart; let div = document.createElement('div');
const atIndex = text.lastIndexOf('@', caretPos - 1); div.id = mirrorDivId;
if (atIndex !== -1 && (caretPos === text.length || text[caretPos] === ' ')) { document.body.appendChild(div);
const prefix = text.substring(atIndex + 1, caretPos); const style = window.getComputedStyle(element);
autocompletePrefix = prefix; const properties = ['border', 'boxSizing', 'fontFamily', 'fontSize', 'fontWeight', 'letterSpacing', 'lineHeight', 'padding', 'textDecoration', 'textIndent', 'textTransform', 'whiteSpace', 'wordSpacing', 'wordWrap', 'width'];
const response = await fetch('{{.BasePath}}/chat/?autocomplete=true&prefix=' + encodeURIComponent(prefix)); properties.forEach(prop => { div.style[prop] = style[prop]; });
const usernames = await response.json(); div.style.position = 'absolute';
if (usernames.length > 0) { div.style.top = '-9999px';
const rect = e.target.getBoundingClientRect(); div.style.left = '0px';
// Approximate caret position (this is a rough estimate) div.textContent = element.value.substring(0, position);
const charWidth = 8; // Rough estimate of character width in pixels
const caretX = rect.left + (caretPos - text.lastIndexOf('\n', caretPos - 1) - 1) * charWidth; const span = document.createElement('span');
showAutocompletePopup(usernames, caretX, rect.top - 10); span.textContent = element.value.substring(position) || '.';
} else { div.appendChild(span);
document.getElementById('autocomplete-popup').style.display = 'none';
autocompleteActive = false; const coords = { top: span.offsetTop, left: span.offsetLeft };
document.body.removeChild(div);
return coords;
}
document.getElementById('chat-input-text').addEventListener('input', (e) => {
const input = e.target;
const text = input.value;
const caretPos = input.selectionStart;
const popup = document.getElementById('autocomplete-popup');
const textBeforeCaret = text.substring(0, caretPos);
const atIndex = textBeforeCaret.lastIndexOf('@');
if (atIndex !== -1 && (atIndex === 0 || /\s/.test(text.charAt(atIndex - 1)))) {
const query = textBeforeCaret.substring(atIndex + 1);
if (!/\s/.test(query)) {
const suggestions = allUsernames.filter(u => u.toLowerCase().startsWith(query.toLowerCase())).slice(0, 10);
if (suggestions.length > 0 && query.length > 0) {
const coords = getCaretCoordinates(input, atIndex);
const rect = input.getBoundingClientRect();
showAutocompletePopup(suggestions, rect.left + coords.left, rect.top + coords.top + 20);
} else {
popup.style.display = 'none';
autocompleteActive = false;
}
return;
} }
} else {
document.getElementById('autocomplete-popup').style.display = 'none';
autocompleteActive = false;
} }
popup.style.display = 'none';
autocompleteActive = false;
});
document.getElementById('chat-input-text').addEventListener('blur', () => {
setTimeout(() => {
if (!document.querySelector('.autocomplete-popup:hover')) {
document.getElementById('autocomplete-popup').style.display = 'none';
autocompleteActive = false;
}
}, 150);
}); });
document.getElementById('chat-input-text').addEventListener('keydown', (e) => { document.getElementById('chat-input-text').addEventListener('keydown', (e) => {
if (autocompleteActive) { if (e.key === 'Enter' && !e.shiftKey) {
const popup = document.getElementById('autocomplete-popup');
const items = popup.getElementsByClassName('autocomplete-item');
if (e.key === 'Enter' && items.length > 0) {
items[0].click();
e.preventDefault();
} else if (e.key === 'ArrowDown' && items.length > 0) {
items[0].focus();
e.preventDefault();
}
} else if (e.key === 'Enter' && !e.shiftKey) {
sendMessage();
e.preventDefault(); e.preventDefault();
sendMessage();
} }
}); });
document.addEventListener('click', (e) => {
if (!e.target.closest('#autocomplete-popup') && !e.target.closest('#chat-input-text')) {
document.getElementById('autocomplete-popup').style.display = 'none';
autocompleteActive = false;
}
});
// Connect WebSocket on page load
window.onload = function() { window.onload = function() {
connectWebSocket(); connectWebSocket();
document.getElementById('chat-messages').scrollTop = document.getElementById('chat-messages').scrollHeight; document.getElementById('chat-messages').scrollTop = document.getElementById('chat-messages').scrollHeight;

View File

@ -14,8 +14,8 @@
<section> <section>
<p>Username: {{.User.Username}}</p> <p>Username: {{.User.Username}}</p>
<p>Display Name: {{.DisplayName}}</p> <p>Display Name: {{.DisplayName}}</p>
{{if .User.PfpURL}} {{if .User.PfpFileID.Valid}}
<img src="{{.User.PfpURL}}" alt="Profile Picture"> <img src="{{.BasePath}}/file?id={{.User.PfpFileID.Int64}}" alt="Profile Picture">
{{end}} {{end}}
<p>Bio: {{.User.Bio}}</p> <p>Bio: {{.User.Bio}}</p>
<p>Joined: {{.User.CreatedAt}}</p> <p>Joined: {{.User.CreatedAt}}</p>
@ -27,4 +27,4 @@
{{template "cookie_banner" .}} {{template "cookie_banner" .}}
</body> </body>
</html> </html>
{{end}} {{end}}

View File

@ -12,11 +12,11 @@
<h2>Edit Profile</h2> <h2>Edit Profile</h2>
</header> </header>
<section> <section>
<form method="post" action="{{.BasePath}}/profile/edit/"> <form method="post" action="{{.BasePath}}/profile/edit/" enctype="multipart/form-data">
<label for="display_name">Display Name:</label> <label for="display_name">Display Name:</label>
<input type="text" id="display_name" name="display_name" value="{{.User.DisplayName}}"><br> <input type="text" id="display_name" name="display_name" value="{{.User.DisplayName}}"><br>
<label for="pfp_url">Profile Picture URL:</label> <label for="pfp">Profile Picture:</label>
<input type="text" id="pfp_url" name="pfp_url" value="{{.User.PfpURL}}"><br> <input type="file" id="pfp" name="pfp"><br>
<label for="bio">Bio:</label> <label for="bio">Bio:</label>
<textarea id="bio" name="bio">{{.User.Bio}}</textarea><br> <textarea id="bio" name="bio">{{.User.Bio}}</textarea><br>
<input type="submit" value="Save"> <input type="submit" value="Save">
@ -26,4 +26,4 @@
{{template "cookie_banner" .}} {{template "cookie_banner" .}}
</body> </body>
</html> </html>
{{end}} {{end}}

View File

@ -4,7 +4,6 @@
{{if .LoggedIn}} {{if .LoggedIn}}
<li><a {{if eq .Navbar "userhome"}}class="active"{{end}} href="{{.BasePath}}/userhome/">User Home</a></li> <li><a {{if eq .Navbar "userhome"}}class="active"{{end}} href="{{.BasePath}}/userhome/">User Home</a></li>
<li><a {{if eq .Navbar "profile"}}class="active"{{end}} href="{{.BasePath}}/profile/">Profile</a></li> <li><a {{if eq .Navbar "profile"}}class="active"{{end}} href="{{.BasePath}}/profile/">Profile</a></li>
<li><a {{if eq .Navbar "chat"}}class="active"{{end}} href="{{.BasePath}}/chat/">Chat</a></li>
<li><a href="{{.BasePath}}/logout/">Logout</a></li> <li><a href="{{.BasePath}}/logout/">Logout</a></li>
{{else}} {{else}}
<li><a {{if eq .Navbar "login"}}class="active"{{end}} href="{{.BasePath}}/login/">Login</a></li> <li><a {{if eq .Navbar "login"}}class="active"{{end}} href="{{.BasePath}}/login/">Login</a></li>