diff --git a/.gitignore b/.gitignore index 2b677ff..836673d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,8 @@ config/config.json config/about_page.htmlbody +# Testing +files/ + # nano .swp diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md new file mode 100644 index 0000000..eca44b3 --- /dev/null +++ b/DOCUMENTATION.md @@ -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=`). + - 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=`):** + - 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=`):** + - 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=`). + - 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=`). + - 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. diff --git a/config/config.json.sample b/config/config.json.sample index 25c325f..bb96f0a 100644 --- a/config/config.json.sample +++ b/config/config.json.sample @@ -4,5 +4,6 @@ "db_username": "threadr_user", "db_password": "threadr_password", "db_database": "threadr_db", - "db_svr_host": "localhost:3306" + "db_svr_host": "localhost:3306", + "file_storage_dir": "files" } diff --git a/handlers/app.go b/handlers/app.go index 20cca0e..7f54ed0 100644 --- a/handlers/app.go +++ b/handlers/app.go @@ -1,11 +1,13 @@ package handlers import ( - "context" - "database/sql" - "html/template" - "net/http" - "github.com/gorilla/sessions" + "context" + "database/sql" + "html/template" + "log" + "net/http" + + "github.com/gorilla/sessions" ) type PageData struct { @@ -19,12 +21,13 @@ type PageData struct { } type Config struct { - DomainName string `json:"domain_name"` - ThreadrDir string `json:"threadr_dir"` - DBUsername string `json:"db_username"` - DBPassword string `json:"db_password"` - DBDatabase string `json:"db_database"` - DBServerHost string `json:"db_svr_host"` + DomainName string `json:"domain_name"` + ThreadrDir string `json:"threadr_dir"` + DBUsername string `json:"db_username"` + DBPassword string `json:"db_password"` + DBDatabase string `json:"db_database"` + DBServerHost string `json:"db_svr_host"` + FileStorageDir string `json:"file_storage_dir"` } type App struct { @@ -45,24 +48,21 @@ func (app *App) SessionMW(next http.HandlerFunc) http.HandlerFunc { HttpOnly: true, } } - if _, ok := session.Values["user_id"].(int); ok { - // Skip IP and User-Agent check for WebSocket connections - if r.URL.Query().Get("ws") != "true" { - 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) - } + + ctx := context.WithValue(r.Context(), "session", session) + r = r.WithContext(ctx) + 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) } -} +} \ No newline at end of file diff --git a/handlers/board.go b/handlers/board.go index 374cf5d..8486949 100644 --- a/handlers/board.go +++ b/handlers/board.go @@ -33,6 +33,11 @@ func BoardHandler(app *App) http.HandlerFunc { return } + if board.Type == "chat" { + http.Redirect(w, r, app.Config.ThreadrDir+"/chat/?id="+boardIDStr, http.StatusFound) + return + } + if board.Private { if !loggedIn { http.Redirect(w, r, app.Config.ThreadrDir+"/login/", http.StatusFound) diff --git a/handlers/boards.go b/handlers/boards.go index 54141c4..99d272f 100644 --- a/handlers/boards.go +++ b/handlers/boards.go @@ -28,25 +28,39 @@ func BoardsHandler(app *App) http.HandlerFunc { if r.Method == http.MethodPost && loggedIn && isAdmin { name := r.FormValue("name") description := r.FormValue("description") + boardType := r.FormValue("type") + if name == "" { http.Error(w, "Board name is required", http.StatusBadRequest) return } + if boardType != "classic" && boardType != "chat" { + boardType = "classic" + } + board := models.Board{ Name: name, Description: description, Private: false, PublicVisible: true, + Type: boardType, } - query := "INSERT INTO boards (name, description, private, public_visible) VALUES (?, ?, ?, ?)" - result, err := app.DB.Exec(query, board.Name, board.Description, board.Private, board.PublicVisible) + 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, board.Type) if err != nil { log.Printf("Error creating board: %v", err) http.Error(w, "Failed to create board", http.StatusInternalServerError) return } 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 } diff --git a/handlers/chat.go b/handlers/chat.go index ce229c1..e9210a9 100644 --- a/handlers/chat.go +++ b/handlers/chat.go @@ -2,10 +2,13 @@ package handlers import ( "encoding/json" + "html/template" "log" "net/http" + "strconv" "sync" "threadr/models" + "github.com/gorilla/sessions" "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 { - clients map[*websocket.Conn]int // Map of connections to user IDs - broadcast chan []byte - register chan *websocket.Conn - unregister chan *websocket.Conn + clients map[*Client]bool + broadcast chan models.ChatMessage + register chan *Client + unregister chan *Client mutex sync.Mutex } func NewChatHub() *ChatHub { return &ChatHub{ - clients: make(map[*websocket.Conn]int), - broadcast: make(chan []byte), - register: make(chan *websocket.Conn), - unregister: make(chan *websocket.Conn), + clients: make(map[*Client]bool), + broadcast: make(chan models.ChatMessage), + register: make(chan *Client), + unregister: make(chan *Client), } } @@ -41,21 +49,26 @@ func (h *ChatHub) Run() { select { case client := <-h.register: h.mutex.Lock() - h.clients[client] = 0 // UserID set later + h.clients[client] = true h.mutex.Unlock() case client := <-h.unregister: h.mutex.Lock() - delete(h.clients, client) + if _, ok := h.clients[client]; ok { + delete(h.clients, client) + client.conn.Close() + } h.mutex.Unlock() - client.Close() case message := <-h.broadcast: h.mutex.Lock() for client := range h.clients { - err := client.WriteMessage(websocket.TextMessage, message) - if err != nil { - log.Printf("Error broadcasting message: %v", err) - client.Close() - delete(h.clients, client) + if client.boardID == message.BoardID { + response, _ := json.Marshal(message) + err := client.conn.WriteMessage(websocket.TextMessage, response) + if err != nil { + log.Printf("Error broadcasting message: %v", err) + client.conn.Close() + delete(h.clients, client) + } } } h.mutex.Unlock() @@ -69,6 +82,12 @@ func init() { go hub.Run() } +type IncomingChatMessage struct { + Type string `json:"type"` + Content string `json:"content"` + ReplyTo int `json:"replyTo"` +} + func ChatHandler(app *App) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { session := r.Context().Value("session").(*sessions.Session) @@ -79,20 +98,64 @@ func ChatHandler(app *App) http.HandlerFunc { } 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" { - // Handle WebSocket connection ws, err := upgrader.Upgrade(w, r, nil) if err != nil { log.Printf("Error upgrading to WebSocket: %v", err) return } - hub.register <- ws - hub.mutex.Lock() - hub.clients[ws] = userID - hub.mutex.Unlock() + client := &Client{conn: ws, userID: userID, boardID: boardID} + hub.register <- client defer func() { - hub.unregister <- ws + hub.unregister <- client }() for { @@ -101,83 +164,72 @@ func ChatHandler(app *App) http.HandlerFunc { log.Printf("Error reading WebSocket message: %v", err) break } - var chatMsg struct { - Type string `json:"type"` - Content string `json:"content"` - ReplyTo int `json:"replyTo"` - } + var chatMsg IncomingChatMessage if err := json.Unmarshal(msg, &chatMsg); err != nil { log.Printf("Error unmarshaling message: %v", err) continue } if chatMsg.Type == "message" { - msgObj := models.ChatMessage{ - UserID: userID, - Content: chatMsg.Content, - ReplyTo: chatMsg.ReplyTo, - } - if err := models.CreateChatMessage(app.DB, msgObj); err != nil { + if err := models.CreateChatMessage(app.DB, boardID, userID, chatMsg.Content, chatMsg.ReplyTo); err != nil { log.Printf("Error saving chat message: %v", err) continue } - // Fetch the saved message with timestamp and user details var msgID int - app.DB.QueryRow("SELECT LAST_INSERT_ID()").Scan(&msgID) - savedMsg, err := models.GetChatMessageByID(app.DB, msgID) + err = app.DB.QueryRow("SELECT LAST_INSERT_ID()").Scan(&msgID) if err != nil { - log.Printf("Error fetching saved message: %v", err) + log.Printf("Error getting last insert id: %v", err) continue } - response, _ := json.Marshal(savedMsg) - hub.broadcast <- response + savedMsg, err := models.GetChatMessageByID(app.DB, msgID) + if err != nil { + log.Printf("Error fetching saved message for broadcast: %v", err) + continue + } + hub.broadcast <- *savedMsg } } return } - if r.URL.Query().Get("autocomplete") == "true" { - // 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) + messages, err := models.GetRecentChatMessages(app.DB, boardID, 50) if err != nil { log.Printf("Error fetching chat messages: %v", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } - // Reverse messages to show oldest first for i, j := 0, len(messages)-1; i < j; i, j = i+1, j-1 { 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 { PageData - Messages []models.ChatMessage + Board models.Board + Messages []models.ChatMessage + AllUsernames template.JS + CurrentUsername string }{ PageData: PageData{ - Title: "ThreadR - Chat", - Navbar: "chat", + Title: "ThreadR Chat - " + board.Name, + Navbar: "boards", LoggedIn: true, ShowCookieBanner: cookie == nil || cookie.Value != "accepted", BasePath: app.Config.ThreadrDir, StaticPath: app.Config.ThreadrDir + "/static", 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 { log.Printf("Error executing template in ChatHandler: %v", err) diff --git a/handlers/file.go b/handlers/file.go new file mode 100644 index 0000000..93279f4 --- /dev/null +++ b/handlers/file.go @@ -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) + } +} diff --git a/handlers/profile_edit.go b/handlers/profile_edit.go index c0d29fc..38a7483 100644 --- a/handlers/profile_edit.go +++ b/handlers/profile_edit.go @@ -1,34 +1,99 @@ package handlers import ( - "log" - "net/http" - "threadr/models" - "github.com/gorilla/sessions" + "crypto/sha256" + "fmt" + "io" + "log" + "net/http" + "os" + "path/filepath" + "threadr/models" + + "github.com/gorilla/sessions" ) func ProfileEditHandler(app *App) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - session := r.Context().Value("session").(*sessions.Session) - userID, ok := session.Values["user_id"].(int) - if !ok { - http.Redirect(w, r, app.Config.ThreadrDir+"/login/", http.StatusFound) - return - } + return func(w http.ResponseWriter, r *http.Request) { + session := r.Context().Value("session").(*sessions.Session) + userID, ok := session.Values["user_id"].(int) + if !ok { + http.Redirect(w, r, app.Config.ThreadrDir+"/login/", http.StatusFound) + return + } - if r.Method == http.MethodPost { - displayName := r.FormValue("display_name") - pfpURL := r.FormValue("pfp_url") - bio := r.FormValue("bio") - err := models.UpdateUserProfile(app.DB, userID, displayName, pfpURL, 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 - } + if r.Method == http.MethodPost { + // Handle file upload + file, handler, err := r.FormFile("pfp") + if err == nil { + defer file.Close() + + // Create a hash of the file + h := sha256.New() + if _, err := io.Copy(h, file); err != nil { + log.Printf("Error hashing file: %v", err) + http.Error(w, "Failed to process file", http.StatusInternalServerError) + 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) if err != nil { @@ -62,4 +127,4 @@ func ProfileEditHandler(app *App) http.HandlerFunc { return } } -} \ No newline at end of file +} diff --git a/main.go b/main.go index 3e37972..06ac7eb 100644 --- a/main.go +++ b/main.go @@ -43,33 +43,14 @@ func createTablesIfNotExist(db *sql.DB) error { public_visible BOOLEAN DEFAULT TRUE, pinned_threads TEXT, custom_landing_page TEXT, - color_scheme VARCHAR(255) + color_scheme VARCHAR(255), + type VARCHAR(20) DEFAULT 'classic' NOT NULL )`) if err != nil { return fmt.Errorf("error creating boards table: %v", err) } - // Create users 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) + // Create threads table _, err = db.Exec(` CREATE TABLE threads ( id INT AUTO_INCREMENT PRIMARY KEY, @@ -190,15 +171,52 @@ func createTablesIfNotExist(db *sql.DB) error { _, err = db.Exec(` CREATE TABLE chat_messages ( id INT AUTO_INCREMENT PRIMARY KEY, + board_id INT NOT NULL, user_id INT NOT NULL, content TEXT NOT NULL, 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 { 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.") return nil } @@ -283,6 +301,14 @@ func main() { } 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 if *initialize { log.Println("Initializing database...") @@ -360,7 +386,8 @@ func main() { 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+"/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.Fatal(http.ListenAndServe(":8080", nil)) -} +} \ No newline at end of file diff --git a/models/board.go b/models/board.go index fedbab5..375a35d 100644 --- a/models/board.go +++ b/models/board.go @@ -14,17 +14,18 @@ type Board struct { PinnedThreads []int // Stored as JSON CustomLandingPage string ColorScheme string + Type string } 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) board := &Board{} var pinnedThreadsJSON sql.NullString var customLandingPage sql.NullString var colorScheme 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 { 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) { - 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) if err != nil { return nil, err @@ -70,7 +71,7 @@ func GetAllBoards(db *sql.DB, private bool) ([]Board, error) { var customLandingPage sql.NullString var colorScheme 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 { return nil, err } diff --git a/models/chat.go b/models/chat.go index 7e33565..97301dc 100644 --- a/models/chat.go +++ b/models/chat.go @@ -2,34 +2,41 @@ package models import ( "database/sql" + "fmt" + "html" + "html/template" + "regexp" + "strings" "time" ) type ChatMessage struct { - ID int - UserID int - Content string - ReplyTo int // -1 if not a reply - Timestamp time.Time - Username string // For display, fetched from user - PfpURL string // For display, fetched from user - Mentions []string // List of mentioned usernames + ID int `json:"id"` + BoardID int `json:"boardId"` + UserID int `json:"userId"` + Content template.HTML `json:"content"` + ReplyTo int `json:"replyTo"` + Timestamp time.Time `json:"timestamp"` + Username string `json:"username"` + PfpFileID sql.NullInt64 `json:"pfpFileId"` + Mentions []string `json:"mentions"` } -func CreateChatMessage(db *sql.DB, msg ChatMessage) error { - query := "INSERT INTO chat_messages (user_id, content, reply_to, timestamp) VALUES (?, ?, ?, NOW())" - _, err := db.Exec(query, msg.UserID, msg.Content, msg.ReplyTo) +func CreateChatMessage(db *sql.DB, boardID, userID int, rawContent string, replyTo int) error { + query := "INSERT INTO chat_messages (board_id, user_id, content, reply_to, timestamp) VALUES (?, ?, ?, ?, NOW())" + _, err := db.Exec(query, boardID, userID, rawContent, replyTo) return err } -func GetRecentChatMessages(db *sql.DB, limit int) ([]ChatMessage, error) { +func GetRecentChatMessages(db *sql.DB, boardID int, limit int) ([]ChatMessage, error) { 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 JOIN users u ON cm.user_id = u.id + WHERE cm.board_id = ? ORDER BY cm.timestamp DESC LIMIT ?` - rows, err := db.Query(query, limit) + rows, err := db.Query(query, boardID, limit) if err != nil { return nil, err } @@ -39,20 +46,18 @@ func GetRecentChatMessages(db *sql.DB, limit int) ([]ChatMessage, error) { for rows.Next() { var msg ChatMessage var timestampStr string - var pfpURL sql.NullString - err := rows.Scan(&msg.ID, &msg.UserID, &msg.Content, &msg.ReplyTo, ×tampStr, &msg.Username, &pfpURL) + var rawContent string + err := rows.Scan(&msg.ID, &msg.UserID, &rawContent, &msg.ReplyTo, ×tampStr, &msg.Username, &msg.PfpFileID) if err != nil { return nil, err } + msg.BoardID = boardID msg.Timestamp, err = time.Parse("2006-01-02 15:04:05", timestampStr) if err != nil { msg.Timestamp = time.Time{} } - if pfpURL.Valid { - msg.PfpURL = pfpURL.String - } - // Parse mentions from content (simple @username detection) - msg.Mentions = extractMentions(msg.Content) + msg.Content = renderMarkdown(rawContent) + msg.Mentions = extractMentions(rawContent) messages = append(messages, msg) } 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) { 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 JOIN users u ON cm.user_id = u.id WHERE cm.id = ?` row := db.QueryRow(query, id) var msg ChatMessage var timestampStr string - var pfpURL sql.NullString - err := row.Scan(&msg.ID, &msg.UserID, &msg.Content, &msg.ReplyTo, ×tampStr, &msg.Username, &pfpURL) + var rawContent string + err := row.Scan(&msg.ID, &msg.BoardID, &msg.UserID, &rawContent, &msg.ReplyTo, ×tampStr, &msg.Username, &msg.PfpFileID) if err == sql.ErrNoRows { return nil, nil } @@ -79,54 +84,122 @@ func GetChatMessageByID(db *sql.DB, id int) (*ChatMessage, error) { if err != nil { msg.Timestamp = time.Time{} } - if pfpURL.Valid { - msg.PfpURL = pfpURL.String - } - msg.Mentions = extractMentions(msg.Content) + msg.Content = renderMarkdown(rawContent) + msg.Mentions = extractMentions(rawContent) 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 { - var mentions []string - var currentMention string - inMention := false - - for _, char := range content { - 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) + re := regexp.MustCompile(`@(\w+)`) + matches := re.FindAllStringSubmatch(content, -1) + mentions := make([]string, len(matches)) + for i, match := range matches { + mentions[i] = match[1] } return mentions +} + +func processInlineMarkdown(line string) string { + line = regexp.MustCompile("`([^`]+)`").ReplaceAllString(line, "$1") + line = regexp.MustCompile(`\*\*([^\*]+)\*\*`).ReplaceAllString(line, "$1") + line = regexp.MustCompile(`__([^_]+)__`).ReplaceAllString(line, "$1") + line = regexp.MustCompile(`\*([^\*]+)\*`).ReplaceAllString(line, "$1") + line = regexp.MustCompile(`_([^_]+)_`).ReplaceAllString(line, "$1") + line = regexp.MustCompile(`@(\w+)`).ReplaceAllString(line, `@$1`) + 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("
%s
", lang, escapedCode) + } else { + renderedCodeBlock = fmt.Sprintf("
%s
", escapedCode) + } + + placeholder := fmt.Sprintf("", 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("\n"); inList = false } + sb.WriteString("

") + sb.WriteString(processInlineMarkdown(strings.TrimPrefix(trimmedLine, "### "))) + sb.WriteString("

\n") + continue + } else if strings.HasPrefix(trimmedLine, "## ") { + if inList { sb.WriteString("\n"); inList = false } + sb.WriteString("

") + sb.WriteString(processInlineMarkdown(strings.TrimPrefix(trimmedLine, "## "))) + sb.WriteString("

\n") + continue + } else if strings.HasPrefix(trimmedLine, "# ") { + if inList { sb.WriteString("\n"); inList = false } + sb.WriteString("

") + sb.WriteString(processInlineMarkdown(strings.TrimPrefix(trimmedLine, "# "))) + sb.WriteString("

\n") + continue + } + + if strings.HasPrefix(trimmedLine, "* ") || strings.HasPrefix(trimmedLine, "- ") { + if !inList { + sb.WriteString("
    \n") + inList = true + } + listItemContent := strings.TrimPrefix(strings.TrimPrefix(trimmedLine, "* "), "- ") + sb.WriteString("
  • ") + sb.WriteString(processInlineMarkdown(listItemContent)) + sb.WriteString("
  • \n") + continue + } + + if inList { + sb.WriteString("
\n") + inList = false + } + + if trimmedLine != "" { + sb.WriteString("

") + sb.WriteString(processInlineMarkdown(trimmedLine)) + sb.WriteString("

\n") + } else { + sb.WriteString("\n") + } + } + + if inList { + sb.WriteString("\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) } \ No newline at end of file diff --git a/models/file.go b/models/file.go new file mode 100644 index 0000000..10fffa5 --- /dev/null +++ b/models/file.go @@ -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() +} diff --git a/models/thread.go b/models/thread.go index 891b329..be2da30 100644 --- a/models/thread.go +++ b/models/thread.go @@ -70,7 +70,7 @@ func GetThreadsByBoardID(db *sql.DB, boardID int) ([]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) return err } \ No newline at end of file diff --git a/models/user.go b/models/user.go index 1c6d03a..281867c 100644 --- a/models/user.go +++ b/models/user.go @@ -1,161 +1,179 @@ package models import ( - "crypto/sha256" - "database/sql" - "fmt" - "time" + "crypto/sha256" + "database/sql" + "fmt" + "time" ) type User struct { - ID int - Username string - DisplayName string - PfpURL string - Bio string - AuthenticationString string - AuthenticationSalt string - AuthenticationAlgorithm string - CreatedAt time.Time - UpdatedAt time.Time - Verified bool - Permissions int64 + ID int + Username string + DisplayName string + PfpFileID sql.NullInt64 + Bio string + AuthenticationString string + AuthenticationSalt string + AuthenticationAlgorithm string + CreatedAt time.Time + UpdatedAt time.Time + Verified bool + Permissions int64 } 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 = ?" - row := db.QueryRow(query, id) - user := &User{} - var displayName sql.NullString - var pfpURL sql.NullString - var bio sql.NullString - var createdAtString sql.NullString - var updatedAtString sql.NullString - 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 { - return nil, nil - } - if err != nil { - return nil, err - } - if displayName.Valid { - user.DisplayName = displayName.String - } else { - user.DisplayName = "" - } - if pfpURL.Valid { - user.PfpURL = pfpURL.String - } else { - user.PfpURL = "" - } - if bio.Valid { - user.Bio = bio.String - } else { - user.Bio = "" - } - if createdAtString.Valid { - user.CreatedAt, err = time.Parse("2006-01-02 15:04:05", createdAtString.String) - if err != nil { - return nil, fmt.Errorf("error parsing created_at: %v", err) - } - } else { - user.CreatedAt = time.Time{} - } - if updatedAtString.Valid { - user.UpdatedAt, err = time.Parse("2006-01-02 15:04:05", updatedAtString.String) - if err != nil { - return nil, fmt.Errorf("error parsing updated_at: %v", err) - } - } else { - user.UpdatedAt = time.Time{} - } - return user, nil + 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) + user := &User{} + var displayName sql.NullString + var bio sql.NullString + var createdAtString 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) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + if displayName.Valid { + user.DisplayName = displayName.String + } else { + user.DisplayName = "" + } + if bio.Valid { + user.Bio = bio.String + } else { + user.Bio = "" + } + if createdAtString.Valid { + user.CreatedAt, err = time.Parse("2006-01-02 15:04:05", createdAtString.String) + if err != nil { + return nil, fmt.Errorf("error parsing created_at: %v", err) + } + } else { + user.CreatedAt = time.Time{} + } + if updatedAtString.Valid { + user.UpdatedAt, err = time.Parse("2006-01-02 15:04:05", updatedAtString.String) + if err != 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) { - 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 = ?" - row := db.QueryRow(query, username) - user := &User{} - var displayName sql.NullString - var pfpURL sql.NullString - var bio sql.NullString - var createdAtString sql.NullString - var updatedAtString sql.NullString - 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 { - return nil, err - } - if displayName.Valid { - user.DisplayName = displayName.String - } else { - user.DisplayName = "" - } - if pfpURL.Valid { - user.PfpURL = pfpURL.String - } else { - user.PfpURL = "" - } - if bio.Valid { - user.Bio = bio.String - } else { - user.Bio = "" - } - if createdAtString.Valid { - user.CreatedAt, err = time.Parse("2006-01-02 15:04:05", createdAtString.String) - if err != nil { - return nil, fmt.Errorf("error parsing created_at: %v", err) - } - } else { - user.CreatedAt = time.Time{} - } - if updatedAtString.Valid { - user.UpdatedAt, err = time.Parse("2006-01-02 15:04:05", updatedAtString.String) - if err != nil { - return nil, fmt.Errorf("error parsing updated_at: %v", err) - } - } else { - user.UpdatedAt = time.Time{} - } - return user, nil + 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) + user := &User{} + var displayName sql.NullString + var bio sql.NullString + var createdAtString 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) + if err != nil { + return nil, err + } + if displayName.Valid { + user.DisplayName = displayName.String + } else { + user.DisplayName = "" + } + if bio.Valid { + user.Bio = bio.String + } else { + user.Bio = "" + } + if createdAtString.Valid { + user.CreatedAt, err = time.Parse("2006-01-02 15:04:05", createdAtString.String) + if err != nil { + return nil, fmt.Errorf("error parsing created_at: %v", err) + } + } else { + user.CreatedAt = time.Time{} + } + if updatedAtString.Valid { + user.UpdatedAt, err = time.Parse("2006-01-02 15:04:05", updatedAtString.String) + if err != 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 { - if algorithm != "sha256" { - return false - } - computedHash := HashPassword(password, salt, algorithm) - return computedHash == hash + if algorithm != "sha256" { + return false + } + computedHash := HashPassword(password, salt, algorithm) + return computedHash == hash } func HashPassword(password, salt, algorithm string) string { - if algorithm != "sha256" { - return "" - } - data := password + salt - hash := sha256.Sum256([]byte(data)) - return fmt.Sprintf("%x", hash) + if algorithm != "sha256" { + return "" + } + data := password + salt + hash := sha256.Sum256([]byte(data)) + return fmt.Sprintf("%x", hash) } func CreateUser(db *sql.DB, username, password string) error { - salt := "random-salt" // Replace with secure random generation - algorithm := "sha256" - 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)" - _, err := db.Exec(query, username, hash, salt, algorithm, false) - return err + salt := "random-salt" // Replace with secure random generation + algorithm := "sha256" + 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)" + _, err := db.Exec(query, username, hash, salt, algorithm, false) + return err } -func UpdateUserProfile(db *sql.DB, userID int, displayName, pfpURL, bio string) error { - query := "UPDATE users SET display_name = ?, pfp_url = ?, bio = ?, updated_at = NOW() WHERE id = ?" - _, err := db.Exec(query, displayName, pfpURL, bio, userID) - return err +func UpdateUserProfile(db *sql.DB, userID int, displayName, bio string) error { + query := "UPDATE users SET display_name = ?, bio = ?, updated_at = NOW() WHERE id = ?" + _, err := db.Exec(query, displayName, bio, userID) + 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 ( - PermCreateBoard int64 = 1 << 0 - PermManageUsers int64 = 1 << 1 + PermCreateBoard int64 = 1 << 0 + PermManageUsers int64 = 1 << 1 ) 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 } \ No newline at end of file diff --git a/static/style.css b/static/style.css index e2a9f56..eda23dd 100644 --- a/static/style.css +++ b/static/style.css @@ -176,7 +176,7 @@ p, a, li { color: #001858; } -/* Enhanced styles for boards */ +/* Styles for board lists */ ul.board-list { list-style-type: none; padding: 0; @@ -185,7 +185,7 @@ ul.board-list { li.board-item { margin-bottom: 1em; - padding: 1em; + padding: 1.2em 1.5em; background-color: #fef6e4; border: 1px solid #001858; border-radius: 8px; @@ -201,7 +201,7 @@ li.board-item a { color: #001858; font-weight: bold; text-decoration: none; - font-size: 1.2em; + font-size: 1.4em; } li.board-item a:hover { @@ -212,10 +212,50 @@ li.board-item a:hover { p.board-desc { margin: 0.5em 0 0 0; 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 { width: 80%; max-width: 800px; @@ -226,7 +266,7 @@ p.board-desc { border: 1px solid #001858; border-radius: 8px; margin-bottom: 1.5em; - padding: 1em; + padding: 1.2em 1.5em; transition: transform 0.2s ease, box-shadow 0.2s ease; } @@ -239,27 +279,27 @@ p.board-desc { background-color: #001858; color: #fef6e4; padding: 0.5em; - margin: -1em -1em 1em -1em; + margin: -1.2em -1.5em 1em -1.5em; border-radius: 6px 6px 0 0; border-bottom: 1px solid #001858; } .post-item header h3 { margin: 0; - font-size: 1.1em; + font-size: 1.2em; } .post-item header p { margin: 0.3em 0 0 0; - font-size: 0.85em; + font-size: 0.95em; opacity: 0.9; } .post-content { margin: 0; - padding: 0.5em; + padding: 0.8em; line-height: 1.5; - font-size: 0.95em; + font-size: 1em; } .post-actions { @@ -272,8 +312,8 @@ p.board-desc { .post-actions a { color: #001858; text-decoration: none; - font-size: 0.9em; - padding: 0.3em 0.6em; + font-size: 1em; + padding: 0.4em 0.8em; border: 1px solid #001858; border-radius: 4px; transition: background-color 0.2s ease; @@ -284,6 +324,23 @@ p.board-desc { 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) { body { background-color: #333; @@ -313,17 +370,17 @@ p.board-desc { input[type="submit"]:hover, button:hover { background-color: #8bd3dd; } - li.board-item { + li.board-item, li.thread-item { background-color: #444; border-color: #fef6e4; } - li.board-item a { + li.board-item a, li.thread-item a { color: #fef6e4; } - li.board-item a:hover { + li.board-item a:hover, li.thread-item a:hover { color: #f582ae; } - p.board-desc { + p.board-desc, p.thread-info { color: #fef6e4; } .post-item { @@ -347,6 +404,22 @@ p.board-desc { p, a, li { 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) { diff --git a/templates/pages/board.html b/templates/pages/board.html index c285327..e21f2d0 100644 --- a/templates/pages/board.html +++ b/templates/pages/board.html @@ -15,9 +15,12 @@

Threads

{{if .Threads}} -
    +
      {{range .Threads}} -
    • {{.Title}} - Updated on {{.UpdatedAt.Format "02/01/2006 - 15:04"}}
    • +
    • + {{.Title}} +

      Updated on {{.UpdatedAt.Format "02/01/2006 - 15:04"}}

      +
    • {{end}}
    {{else}} diff --git a/templates/pages/boards.html b/templates/pages/boards.html index d4350e6..bbeaf0e 100644 --- a/templates/pages/boards.html +++ b/templates/pages/boards.html @@ -17,7 +17,11 @@
      {{range .PublicBoards}}
    • + {{if eq .Type "chat"}} + {{.Name}} (Chat) + {{else}} {{.Name}} + {{end}}

      {{.Description}}

    • {{end}} @@ -33,7 +37,11 @@
        {{range .PrivateBoards}}
      • + {{if eq .Type "chat"}} + {{.Name}} (Chat) + {{else}} {{.Name}} + {{end}}

        {{.Description}}

      • {{end}} @@ -51,6 +59,11 @@

        + +
diff --git a/templates/pages/chat.html b/templates/pages/chat.html index 494de47..e1a44f7 100644 --- a/templates/pages/chat.html +++ b/templates/pages/chat.html @@ -21,7 +21,7 @@ } .chat-container { width: 100%; - height: calc(100% - 2em); /* Adjust for header */ + height: 100%; display: flex; flex-direction: column; border: none; @@ -29,6 +29,11 @@ background-color: #fef6e4; box-shadow: none; } + .chat-header { + padding: 10px; + text-align: center; + border-bottom: 1px solid #001858; + } .chat-messages { flex: 1; overflow-y: auto; @@ -167,9 +172,27 @@ background: none; 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) { .chat-container { background-color: #444; + } + .chat-header { border-color: #fef6e4; } .chat-message-username { @@ -210,22 +233,39 @@ .reply-indicator button:hover { 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; + } + } } {{template "navbar" .}}
-
-

General Chat

-
+
+

{{.Board.Name}}

+

{{.Board.Description}}

+
{{range .Messages}} -
+
- {{if .PfpURL}} - PFP + {{if .PfpFileID.Valid}} + PFP {{else}}
{{end}} @@ -233,11 +273,11 @@ {{.Timestamp.Format "02/01/2006 15:04"}}
{{if gt .ReplyTo 0}} -
Replying to {{.Username}}
+
Replying to message...
{{end}} -
{{.Content | html}}
+
{{.Content}}
- Reply + Reply
{{end}} @@ -257,19 +297,20 @@