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

Open
jocadbz wants to merge 12 commits from jocadbz into master
25 changed files with 1653 additions and 706 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

398
DOCUMENTATION.md Normal file
View File

@ -0,0 +1,398 @@
# 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, file storage locations, and session security.
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",
"session_secret": "change-me-to-32-byte-random",
"session_secure": false
}
Notes:
- `session_secret` should be a 32+ byte random value. At runtime, it is overridden by the `THREADR_SESSION_SECRET` environment variable if present (recommended for production).
- `session_secure` controls whether cookies are marked `Secure`; set to `true` in HTTPS environments.
* **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` (and env overrides). Fields include DB settings, domain, file storage dir, `session_secret`, and `session_secure`.
- `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, applying secure cookie options (HttpOnly, SameSite=Lax, Secure configurable) and attaching the session to 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,8 @@
"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",
"session_secret": "change-me-to-32-byte-random",
"session_secure": false
} }

5
go.mod
View File

@ -6,11 +6,12 @@ require (
github.com/go-sql-driver/mysql v1.9.0 github.com/go-sql-driver/mysql v1.9.0
github.com/gorilla/sessions v1.4.0 github.com/gorilla/sessions v1.4.0
github.com/gorilla/websocket v1.5.0 github.com/gorilla/websocket v1.5.0
golang.org/x/crypto v0.45.0
golang.org/x/term v0.37.0
) )
require ( require (
filippo.io/edwards25519 v1.1.0 // indirect filippo.io/edwards25519 v1.1.0 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect github.com/gorilla/securecookie v1.1.2 // indirect
golang.org/x/sys v0.33.0 // indirect golang.org/x/sys v0.38.0 // indirect
golang.org/x/term v0.32.0 // indirect
) )

10
go.sum
View File

@ -10,7 +10,9 @@ github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzq
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=

View File

@ -1,78 +1,96 @@
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 {
Title string Title string
Navbar string Navbar string
LoggedIn bool LoggedIn bool
ShowCookieBanner bool ShowCookieBanner bool
BasePath string BasePath string
StaticPath string StaticPath string
CurrentURL string CurrentURL string
} }
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"`
SessionSecret string `json:"session_secret"`
SessionSecure bool `json:"session_secure"`
} }
type App struct { type App struct {
DB *sql.DB DB *sql.DB
Store *sessions.CookieStore Store *sessions.CookieStore
Config *Config Config *Config
Tmpl *template.Template Tmpl *template.Template
} }
func (app *App) SessionMW(next http.HandlerFunc) http.HandlerFunc { func (app *App) SessionMW(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
session, err := app.Store.Get(r, "session-name") session, err := app.Store.Get(r, "session-name")
if err != nil { if err != nil {
session = sessions.NewSession(app.Store, "session-name") session = sessions.NewSession(app.Store, "session-name")
session.Options = &sessions.Options{ }
Path: "/",
MaxAge: 86400 * 30, // 30 days // Enforce secure cookie options on every request.
HttpOnly: true, session.Options = app.cookieOptions(r)
}
} ctx := context.WithValue(r.Context(), "session", session)
if _, ok := session.Values["user_id"].(int); ok { r = r.WithContext(ctx)
// Skip IP and User-Agent check for WebSocket connections
if r.URL.Query().Get("ws") != "true" { next(w, r)
if session.Values["user_ip"] != r.RemoteAddr || session.Values["user_agent"] != r.UserAgent() {
session.Values = make(map[interface{}]interface{}) if err := session.Save(r, w); err != nil {
session.Options.MaxAge = -1 /*
session.Save(r, w) Ok, so here's the thing
http.Redirect(w, r, app.Config.ThreadrDir+"/login/?error=session", http.StatusFound) Errors coming from this function here "can" be ignored.
return They mostly come from errors while setting cookies, so in some
} environments this will trigger a lot, but they are harmless.
} */
ctx := context.WithValue(r.Context(), "session", session) log.Printf("Error saving session in SessionMW: %v", err)
r = r.WithContext(ctx) }
} else { }
ctx := context.WithValue(r.Context(), "session", session)
r = r.WithContext(ctx)
}
next(w, r)
}
} }
func (app *App) RequireLoginMW(next http.HandlerFunc) http.HandlerFunc { func (app *App) RequireLoginMW(next http.HandlerFunc) 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)
if _, ok := session.Values["user_id"].(int); !ok { if _, ok := session.Values["user_id"].(int); !ok {
http.Redirect(w, r, app.Config.ThreadrDir+"/login/?error=session", http.StatusFound) http.Redirect(w, r, app.Config.ThreadrDir+"/login/?error=session", http.StatusFound)
return return
} }
next(w, r) next(w, r)
} }
}
func (app *App) cookieOptions(r *http.Request) *sessions.Options {
secure := app.Config.SessionSecure
if r.TLS != nil {
secure = true
} // I dunno what I am doing honestly
options := &sessions.Options{
Path: app.Config.ThreadrDir + "/",
MaxAge: 86400 * 30,
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteLaxMode,
}
if app.Config.DomainName != "" {
options.Domain = app.Config.DomainName
}
return options
} }

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,68 +1,69 @@
package handlers package handlers
import ( import (
"database/sql" "database/sql"
"log" "log"
"net/http" "net/http"
"threadr/models" "threadr/models"
"github.com/gorilla/sessions"
"github.com/gorilla/sessions"
) )
func LoginHandler(app *App) http.HandlerFunc { func LoginHandler(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)
if r.Method == http.MethodPost { if r.Method == http.MethodPost {
username := r.FormValue("username") username := r.FormValue("username")
password := r.FormValue("password") password := r.FormValue("password")
user, err := models.GetUserByUsername(app.DB, username) user, err := models.GetUserByUsername(app.DB, username)
if err != nil && err != sql.ErrNoRows { if err != nil && err != sql.ErrNoRows {
log.Printf("Error fetching user in LoginHandler: %v", err) log.Printf("Error fetching user in LoginHandler: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError) http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return return
} }
if user == nil || !models.CheckPassword(password, user.AuthenticationSalt, user.AuthenticationAlgorithm, user.AuthenticationString) { if user == nil || !models.CheckPassword(password, user.AuthenticationSalt, user.AuthenticationAlgorithm, user.AuthenticationString) {
http.Redirect(w, r, app.Config.ThreadrDir+"/login/?error=invalid", http.StatusFound) http.Redirect(w, r, app.Config.ThreadrDir+"/login/?error=invalid", http.StatusFound)
return return
} }
session.Values["user_id"] = user.ID // Regenerate session to avoid fixation
session.Values["user_ip"] = r.RemoteAddr session.Options.MaxAge = -1
session.Values["user_agent"] = r.UserAgent() _ = session.Save(r, w)
session.Options = &sessions.Options{ session = sessions.NewSession(app.Store, "session-name")
Path: "/", session.Options = app.cookieOptions(r)
MaxAge: 86400 * 30, // 30 days session.Values["user_id"] = user.ID
HttpOnly: true, session.Values["user_ip"] = r.RemoteAddr
} session.Values["user_agent"] = r.UserAgent()
if err := session.Save(r, w); err != nil { if err := session.Save(r, w); err != nil {
log.Printf("Error saving session: %v", err) log.Printf("Error saving session: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError) http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return return
} }
http.Redirect(w, r, app.Config.ThreadrDir+"/userhome/", http.StatusFound) http.Redirect(w, r, app.Config.ThreadrDir+"/userhome/", http.StatusFound)
return return
} }
data := struct { data := struct {
PageData PageData
Error string Error string
}{ }{
PageData: PageData{ PageData: PageData{
Title: "ThreadR - Login", Title: "ThreadR - Login",
Navbar: "login", Navbar: "login",
LoggedIn: false, LoggedIn: false,
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,
}, },
Error: "", Error: "",
} }
if r.URL.Query().Get("error") == "invalid" { if r.URL.Query().Get("error") == "invalid" {
data.Error = "Invalid username or password" data.Error = "Invalid username or password"
} }
if err := app.Tmpl.ExecuteTemplate(w, "login", data); err != nil { if err := app.Tmpl.ExecuteTemplate(w, "login", data); err != nil {
log.Printf("Error executing template in LoginHandler: %v", err) log.Printf("Error executing template in LoginHandler: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError) http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return return
} }
} }
} }

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
} }
} }
} }

375
main.go
View File

@ -22,19 +22,19 @@ import (
) )
func loadConfig(filename string) (*handlers.Config, error) { func loadConfig(filename string) (*handlers.Config, error) {
file, err := os.Open(filename) file, err := os.Open(filename)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer file.Close() defer file.Close()
var config handlers.Config var config handlers.Config
err = json.NewDecoder(file).Decode(&config) err = json.NewDecoder(file).Decode(&config)
return &config, err return &config, err
} }
func createTablesIfNotExist(db *sql.DB) error { func createTablesIfNotExist(db *sql.DB) error {
// Create boards table // Create boards table
_, err := db.Exec(` _, err := db.Exec(`
CREATE TABLE boards ( CREATE TABLE boards (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL,
@ -43,34 +43,15 @@ 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(` _, 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(`
CREATE TABLE threads ( CREATE TABLE threads (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
board_id INT NOT NULL, board_id INT NOT NULL,
@ -81,12 +62,12 @@ func createTablesIfNotExist(db *sql.DB) error {
accepted_answer_post_id INT, accepted_answer_post_id INT,
FOREIGN KEY (board_id) REFERENCES boards(id) FOREIGN KEY (board_id) REFERENCES boards(id)
)`) )`)
if err != nil { if err != nil {
return fmt.Errorf("error creating threads table: %v", err) return fmt.Errorf("error creating threads table: %v", err)
} }
// Create posts table // Create posts table
_, err = db.Exec(` _, err = db.Exec(`
CREATE TABLE posts ( CREATE TABLE posts (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
thread_id INT NOT NULL, thread_id INT NOT NULL,
@ -100,12 +81,12 @@ func createTablesIfNotExist(db *sql.DB) error {
reply_to INT DEFAULT -1, reply_to INT DEFAULT -1,
FOREIGN KEY (thread_id) REFERENCES threads(id) FOREIGN KEY (thread_id) REFERENCES threads(id)
)`) )`)
if err != nil { if err != nil {
return fmt.Errorf("error creating posts table: %v", err) return fmt.Errorf("error creating posts table: %v", err)
} }
// Create likes table // Create likes table
_, err = db.Exec(` _, err = db.Exec(`
CREATE TABLE likes ( CREATE TABLE likes (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
post_id INT NOT NULL, post_id INT NOT NULL,
@ -114,12 +95,12 @@ func createTablesIfNotExist(db *sql.DB) error {
UNIQUE KEY unique_like (post_id, user_id), UNIQUE KEY unique_like (post_id, user_id),
FOREIGN KEY (post_id) REFERENCES posts(id) FOREIGN KEY (post_id) REFERENCES posts(id)
)`) )`)
if err != nil { if err != nil {
return fmt.Errorf("error creating likes table: %v", err) return fmt.Errorf("error creating likes table: %v", err)
} }
// Create board_permissions table // Create board_permissions table
_, err = db.Exec(` _, err = db.Exec(`
CREATE TABLE board_permissions ( CREATE TABLE board_permissions (
user_id INT NOT NULL, user_id INT NOT NULL,
board_id INT NOT NULL, board_id INT NOT NULL,
@ -127,12 +108,12 @@ func createTablesIfNotExist(db *sql.DB) error {
PRIMARY KEY (user_id, board_id), PRIMARY KEY (user_id, board_id),
FOREIGN KEY (board_id) REFERENCES boards(id) FOREIGN KEY (board_id) REFERENCES boards(id)
)`) )`)
if err != nil { if err != nil {
return fmt.Errorf("error creating board_permissions table: %v", err) return fmt.Errorf("error creating board_permissions table: %v", err)
} }
// Create notifications table // Create notifications table
_, err = db.Exec(` _, err = db.Exec(`
CREATE TABLE notifications ( CREATE TABLE notifications (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL, user_id INT NOT NULL,
@ -141,12 +122,12 @@ func createTablesIfNotExist(db *sql.DB) error {
is_read BOOLEAN DEFAULT FALSE, is_read BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)`) )`)
if err != nil { if err != nil {
return fmt.Errorf("error creating notifications table: %v", err) return fmt.Errorf("error creating notifications table: %v", err)
} }
// Create reactions table // Create reactions table
_, err = db.Exec(` _, err = db.Exec(`
CREATE TABLE reactions ( CREATE TABLE reactions (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
post_id INT NOT NULL, post_id INT NOT NULL,
@ -154,12 +135,12 @@ func createTablesIfNotExist(db *sql.DB) error {
emoji VARCHAR(10) NOT NULL, emoji VARCHAR(10) NOT NULL,
FOREIGN KEY (post_id) REFERENCES posts(id) FOREIGN KEY (post_id) REFERENCES posts(id)
)`) )`)
if err != nil { if err != nil {
return fmt.Errorf("error creating reactions table: %v", err) return fmt.Errorf("error creating reactions table: %v", err)
} }
// Create reposts table // Create reposts table
_, err = db.Exec(` _, err = db.Exec(`
CREATE TABLE reposts ( CREATE TABLE reposts (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
thread_id INT NOT NULL, thread_id INT NOT NULL,
@ -169,12 +150,12 @@ func createTablesIfNotExist(db *sql.DB) error {
FOREIGN KEY (thread_id) REFERENCES threads(id), FOREIGN KEY (thread_id) REFERENCES threads(id),
FOREIGN KEY (board_id) REFERENCES boards(id) FOREIGN KEY (board_id) REFERENCES boards(id)
)`) )`)
if err != nil { if err != nil {
return fmt.Errorf("error creating reposts table: %v", err) return fmt.Errorf("error creating reposts table: %v", err)
} }
// Create news table // Create news table
_, err = db.Exec(` _, err = db.Exec(`
CREATE TABLE news ( CREATE TABLE news (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(255) NOT NULL, title VARCHAR(255) NOT NULL,
@ -182,25 +163,62 @@ func createTablesIfNotExist(db *sql.DB) error {
posted_by INT NOT NULL, posted_by INT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)`) )`)
if err != nil { if err != nil {
return fmt.Errorf("error creating news table: %v", err) return fmt.Errorf("error creating news table: %v", err)
} }
// Create chat_messages table // Create chat_messages table
_, 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)
} }
log.Println("Database tables created.") // Create files table (Hope this does not break anything)
return nil _, 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
} }
func ensureAdminUser(db *sql.DB) error { func ensureAdminUser(db *sql.DB) error {
@ -266,101 +284,128 @@ func ensureAdminUser(db *sql.DB) error {
} }
func main() { func main() {
// Define command-line flag for initialization // Define command-line flag for initialization
initialize := flag.Bool("initialize", false, "Initialize database tables and admin user") initialize := flag.Bool("initialize", false, "Initialize database tables and admin user")
flag.BoolVar(initialize, "i", false, "Short for --initialize") flag.BoolVar(initialize, "i", false, "Short for --initialize")
flag.Parse() flag.Parse()
config, err := loadConfig("config/config.json") config, err := loadConfig("config/config.json")
if err != nil { if err != nil {
log.Fatal("Error loading config:", err) log.Fatal("Error loading config:", err)
} }
dsn := fmt.Sprintf("%s:%s@tcp(%s)/%s", config.DBUsername, config.DBPassword, config.DBServerHost, config.DBDatabase) // Allow environment variable override for the session secret to avoid hardcoding secrets in files.
db, err := sql.Open("mysql", dsn) if envSecret := os.Getenv("THREADR_SESSION_SECRET"); envSecret != "" {
if err != nil { config.SessionSecret = envSecret
log.Fatal("Error connecting to database:", err) }
} if len(config.SessionSecret) < 32 {
defer db.Close() log.Fatal("Session secret must be at least 32 bytes; set THREADR_SESSION_SECRET or session_secret in config")
}
// Perform initialization if the flag is set dsn := fmt.Sprintf("%s:%s@tcp(%s)/%s", config.DBUsername, config.DBPassword, config.DBServerHost, config.DBDatabase)
if *initialize { db, err := sql.Open("mysql", dsn)
log.Println("Initializing database...") if err != nil {
err = createTablesIfNotExist(db) log.Fatal("Error connecting to database:", err)
if err != nil { }
log.Fatal("Error creating database tables:", err) defer db.Close()
}
err = ensureAdminUser(db) // Create the file directory
if err != nil { // TODO: Wouldn't this be better suited on the initialize function?
log.Fatal("Error ensuring admin user:", err) // Discussion pending.
} err = os.MkdirAll(config.FileStorageDir, 0700)
if err != nil {
log.Fatal("Error creating file storage directory:", err)
}
log.Println("Initialization completed successfully. Exiting.") // Perform initialization if the flag is set
return if *initialize {
} log.Println("Initializing database...")
err = createTablesIfNotExist(db)
if err != nil {
log.Fatal("Error creating database tables:", err)
}
// Normal startup (without automatic table creation) err = ensureAdminUser(db)
log.Println("Starting ThreadR server...") if err != nil {
log.Fatal("Error ensuring admin user:", err)
}
dir, err := os.Getwd() log.Println("Initialization completed successfully. Exiting.")
if err != nil { return
log.Fatal("Error getting working directory:", err) }
}
// Parse partial templates // Normal startup (without automatic table creation)
tmpl := template.Must(template.ParseFiles( log.Println("Starting ThreadR server...")
filepath.Join(dir, "templates/partials/navbar.html"),
filepath.Join(dir, "templates/partials/cookie_banner.html"),
))
// Parse page-specific templates with unique names dir, err := os.Getwd()
tmpl, err = tmpl.ParseFiles( if err != nil {
filepath.Join(dir, "templates/pages/about.html"), log.Fatal("Error getting working directory:", err)
filepath.Join(dir, "templates/pages/board.html"), }
filepath.Join(dir, "templates/pages/boards.html"),
filepath.Join(dir, "templates/pages/home.html"),
filepath.Join(dir, "templates/pages/login.html"),
filepath.Join(dir, "templates/pages/news.html"),
filepath.Join(dir, "templates/pages/profile.html"),
filepath.Join(dir, "templates/pages/profile_edit.html"),
filepath.Join(dir, "templates/pages/signup.html"),
filepath.Join(dir, "templates/pages/thread.html"),
filepath.Join(dir, "templates/pages/userhome.html"),
filepath.Join(dir, "templates/pages/chat.html"),
)
if err != nil {
log.Fatal("Error parsing page templates:", err)
}
store := sessions.NewCookieStore([]byte("secret-key")) // Replace with secure key in production // Parse partial templates
tmpl := template.Must(template.ParseFiles(
filepath.Join(dir, "templates/partials/navbar.html"),
filepath.Join(dir, "templates/partials/cookie_banner.html"),
))
app := &handlers.App{ // Parse page-specific templates with unique names
DB: db, tmpl, err = tmpl.ParseFiles(
Store: store, filepath.Join(dir, "templates/pages/about.html"),
Config: config, filepath.Join(dir, "templates/pages/board.html"),
Tmpl: tmpl, filepath.Join(dir, "templates/pages/boards.html"),
} filepath.Join(dir, "templates/pages/home.html"),
filepath.Join(dir, "templates/pages/login.html"),
filepath.Join(dir, "templates/pages/news.html"),
filepath.Join(dir, "templates/pages/profile.html"),
filepath.Join(dir, "templates/pages/profile_edit.html"),
filepath.Join(dir, "templates/pages/signup.html"),
filepath.Join(dir, "templates/pages/thread.html"),
filepath.Join(dir, "templates/pages/userhome.html"),
filepath.Join(dir, "templates/pages/chat.html"),
)
if err != nil {
log.Fatal("Error parsing page templates:", err)
}
fs := http.FileServer(http.Dir("static")) store := sessions.NewCookieStore([]byte(config.SessionSecret))
http.Handle(config.ThreadrDir+"/static/", http.StripPrefix(config.ThreadrDir+"/static/", fs)) store.Options = &sessions.Options{
Path: config.ThreadrDir + "/",
MaxAge: 86400 * 30,
HttpOnly: true,
Secure: config.SessionSecure,
SameSite: http.SameSiteLaxMode,
}
if config.DomainName != "" {
store.Options.Domain = config.DomainName
}
http.HandleFunc(config.ThreadrDir+"/", app.SessionMW(handlers.HomeHandler(app))) app := &handlers.App{
http.HandleFunc(config.ThreadrDir+"/login/", app.SessionMW(handlers.LoginHandler(app))) DB: db,
http.HandleFunc(config.ThreadrDir+"/logout/", app.SessionMW(handlers.LogoutHandler(app))) Store: store,
http.HandleFunc(config.ThreadrDir+"/userhome/", app.SessionMW(app.RequireLoginMW(handlers.UserHomeHandler(app)))) Config: config,
http.HandleFunc(config.ThreadrDir+"/boards/", app.SessionMW(handlers.BoardsHandler(app))) Tmpl: tmpl,
http.HandleFunc(config.ThreadrDir+"/board/", app.SessionMW(handlers.BoardHandler(app))) }
http.HandleFunc(config.ThreadrDir+"/thread/", app.SessionMW(handlers.ThreadHandler(app)))
http.HandleFunc(config.ThreadrDir+"/about/", app.SessionMW(handlers.AboutHandler(app)))
http.HandleFunc(config.ThreadrDir+"/profile/", app.SessionMW(app.RequireLoginMW(handlers.ProfileHandler(app))))
http.HandleFunc(config.ThreadrDir+"/profile/edit/", app.SessionMW(app.RequireLoginMW(handlers.ProfileEditHandler(app))))
http.HandleFunc(config.ThreadrDir+"/like/", app.SessionMW(app.RequireLoginMW(handlers.LikeHandler(app))))
http.HandleFunc(config.ThreadrDir+"/news/", app.SessionMW(handlers.NewsHandler(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+"/chat/", app.SessionMW(app.RequireLoginMW(handlers.ChatHandler(app))))
log.Println("Server starting on :8080") fs := http.FileServer(http.Dir("static"))
log.Fatal(http.ListenAndServe(":8080", nil)) http.Handle(config.ThreadrDir+"/static/", http.StripPrefix(config.ThreadrDir+"/static/", fs))
http.HandleFunc(config.ThreadrDir+"/", app.SessionMW(handlers.HomeHandler(app)))
http.HandleFunc(config.ThreadrDir+"/login/", app.SessionMW(handlers.LoginHandler(app)))
http.HandleFunc(config.ThreadrDir+"/logout/", app.SessionMW(handlers.LogoutHandler(app)))
http.HandleFunc(config.ThreadrDir+"/userhome/", app.SessionMW(app.RequireLoginMW(handlers.UserHomeHandler(app))))
http.HandleFunc(config.ThreadrDir+"/boards/", app.SessionMW(handlers.BoardsHandler(app)))
http.HandleFunc(config.ThreadrDir+"/board/", app.SessionMW(handlers.BoardHandler(app)))
http.HandleFunc(config.ThreadrDir+"/thread/", app.SessionMW(handlers.ThreadHandler(app)))
http.HandleFunc(config.ThreadrDir+"/about/", app.SessionMW(handlers.AboutHandler(app)))
http.HandleFunc(config.ThreadrDir+"/profile/", app.SessionMW(app.RequireLoginMW(handlers.ProfileHandler(app))))
http.HandleFunc(config.ThreadrDir+"/profile/edit/", app.SessionMW(app.RequireLoginMW(handlers.ProfileEditHandler(app))))
http.HandleFunc(config.ThreadrDir+"/like/", app.SessionMW(app.RequireLoginMW(handlers.LikeHandler(app))))
http.HandleFunc(config.ThreadrDir+"/news/", app.SessionMW(handlers.NewsHandler(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+"/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))
} }

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,202 @@
package models package models
import ( import (
"crypto/sha256" "crypto/rand"
"database/sql" "crypto/sha256"
"fmt" "database/sql"
"time" "fmt"
"time"
"golang.org/x/crypto/bcrypt"
) )
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) {
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
}
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
}
func CheckPassword(password, salt, algorithm, hash string) bool {
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)
}
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
}
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
} }
const ( const (
PermCreateBoard int64 = 1 << 0 // bcryptCost defines the work factor for bcrypt password hashing.
PermManageUsers int64 = 1 << 1 // 12 is a reasonable default; increase for stronger machines.
bcryptCost = 12
)
func GetUserByID(db *sql.DB, id int) (*User, error) {
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_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 {
switch algorithm {
case "bcrypt":
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
case "sha256":
computedHash := HashPassword(password, salt, algorithm)
return computedHash == hash
default:
return false
}
}
func HashPassword(password, salt, algorithm string) string {
switch algorithm {
case "sha256":
data := password + salt
hash := sha256.Sum256([]byte(data))
return fmt.Sprintf("%x", hash)
default:
return ""
}
}
func CreateUser(db *sql.DB, username, password string) error {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
if err != nil {
return err
}
// Keep salt column for backward compatibility, or else this apparently shits itself
var saltBytes [16]byte
if _, err := rand.Read(saltBytes[:]); err != nil {
return fmt.Errorf("failed to generate salt: %w", err)
}
salt := fmt.Sprintf("%x", saltBytes)
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, string(hash), salt, "bcrypt", false)
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
) )
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>