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

Open
jocadbz wants to merge 31 commits from jocadbz into master
45 changed files with 3814 additions and 1264 deletions

5
.gitignore vendored
View File

@ -1,5 +1,10 @@
config/config.json
config/about_page.htmlbody
# Testing
files/
# nano
.swp
threadr
*.todo

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_password": "threadr_password",
"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/gorilla/sessions v1.4.0
github.com/gorilla/websocket v1.5.0
golang.org/x/crypto v0.45.0
golang.org/x/term v0.37.0
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/term v0.32.0 // indirect
golang.org/x/sys v0.38.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/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
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/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
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,98 @@
package handlers
import (
"context"
"database/sql"
"html/template"
"net/http"
"github.com/gorilla/sessions"
"context"
"database/sql"
"html/template"
"log"
"net/http"
"github.com/gorilla/sessions"
)
type PageData struct {
Title string
Navbar string
LoggedIn bool
ShowCookieBanner bool
BasePath string
StaticPath string
CurrentURL string
Title string
Navbar string
LoggedIn bool
ShowCookieBanner bool
BasePath string
StaticPath string
CurrentURL string
ContentTemplate string
BodyClass string
}
type Config struct {
DomainName string `json:"domain_name"`
ThreadrDir string `json:"threadr_dir"`
DBUsername string `json:"db_username"`
DBPassword string `json:"db_password"`
DBDatabase string `json:"db_database"`
DBServerHost string `json:"db_svr_host"`
DomainName string `json:"domain_name"`
ThreadrDir string `json:"threadr_dir"`
DBUsername string `json:"db_username"`
DBPassword string `json:"db_password"`
DBDatabase string `json:"db_database"`
DBServerHost string `json:"db_svr_host"`
FileStorageDir string `json:"file_storage_dir"`
SessionSecret string `json:"session_secret"`
SessionSecure bool `json:"session_secure"`
}
type App struct {
DB *sql.DB
Store *sessions.CookieStore
Config *Config
Tmpl *template.Template
DB *sql.DB
Store *sessions.CookieStore
Config *Config
Tmpl *template.Template
}
func (app *App) SessionMW(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
session, err := app.Store.Get(r, "session-name")
if err != nil {
session = sessions.NewSession(app.Store, "session-name")
session.Options = &sessions.Options{
Path: "/",
MaxAge: 86400 * 30, // 30 days
HttpOnly: true,
}
}
if _, ok := session.Values["user_id"].(int); ok {
// Skip IP and User-Agent check for WebSocket connections
if r.URL.Query().Get("ws") != "true" {
if session.Values["user_ip"] != r.RemoteAddr || session.Values["user_agent"] != r.UserAgent() {
session.Values = make(map[interface{}]interface{})
session.Options.MaxAge = -1
session.Save(r, w)
http.Redirect(w, r, app.Config.ThreadrDir+"/login/?error=session", http.StatusFound)
return
}
}
ctx := context.WithValue(r.Context(), "session", session)
r = r.WithContext(ctx)
} else {
ctx := context.WithValue(r.Context(), "session", session)
r = r.WithContext(ctx)
}
next(w, r)
}
return func(w http.ResponseWriter, r *http.Request) {
session, err := app.Store.Get(r, "session-name")
if err != nil {
session = sessions.NewSession(app.Store, "session-name")
}
// Enforce secure cookie options on every request.
session.Options = app.cookieOptions(r)
ctx := context.WithValue(r.Context(), "session", session)
r = r.WithContext(ctx)
next(w, r)
if err := session.Save(r, w); err != nil {
/*
Ok, so here's the thing
Errors coming from this function here "can" be ignored.
They mostly come from errors while setting cookies, so in some
environments this will trigger a lot, but they are harmless.
*/
log.Printf("Error saving session in SessionMW: %v", err)
}
}
}
func (app *App) RequireLoginMW(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*sessions.Session)
if _, ok := session.Values["user_id"].(int); !ok {
http.Redirect(w, r, app.Config.ThreadrDir+"/login/?error=session", http.StatusFound)
return
}
next(w, r)
}
return func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*sessions.Session)
if _, ok := session.Values["user_id"].(int); !ok {
http.Redirect(w, r, app.Config.ThreadrDir+"/login/?error=session", http.StatusFound)
return
}
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
}
if board.Type == "chat" {
http.Redirect(w, r, app.Config.ThreadrDir+"/chat/?id="+boardIDStr, http.StatusFound)
return
}
if board.Private {
if !loggedIn {
http.Redirect(w, r, app.Config.ThreadrDir+"/login/", http.StatusFound)

View File

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

View File

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

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

84
handlers/preferences.go Normal file
View File

@ -0,0 +1,84 @@
package handlers
import (
"log"
"net/http"
"threadr/models"
"github.com/gorilla/sessions"
)
func PreferencesHandler(app *App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*sessions.Session)
userID, ok := session.Values["user_id"].(int)
if !ok {
http.Redirect(w, r, app.Config.ThreadrDir+"/login/", http.StatusFound)
return
}
// Handle POST request (saving preferences)
if r.Method == http.MethodPost {
// Get form values
autoSaveDrafts := r.FormValue("auto_save_drafts") == "on"
// Get current preferences (or create if not exists)
prefs, err := models.GetUserPreferences(app.DB, userID)
if err != nil {
log.Printf("Error fetching preferences: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
// Update preferences
prefs.AutoSaveDrafts = autoSaveDrafts
err = models.UpdateUserPreferences(app.DB, prefs)
if err != nil {
log.Printf("Error updating preferences: %v", err)
http.Error(w, "Failed to save preferences", http.StatusInternalServerError)
return
}
// Redirect back to preferences page with success
http.Redirect(w, r, app.Config.ThreadrDir+"/preferences/?saved=true", http.StatusFound)
return
}
// Handle GET request (displaying preferences form)
prefs, err := models.GetUserPreferences(app.DB, userID)
if err != nil {
log.Printf("Error fetching preferences: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
// Check if we should show success message
showSuccess := r.URL.Query().Get("saved") == "true"
data := struct {
PageData
Preferences *models.UserPreferences
ShowSuccess bool
}{
PageData: PageData{
Title: "ThreadR - Preferences",
Navbar: "preferences",
LoggedIn: true,
ShowCookieBanner: false,
BasePath: app.Config.ThreadrDir,
StaticPath: app.Config.ThreadrDir + "/static",
CurrentURL: r.URL.Path,
ContentTemplate: "preferences-content",
},
Preferences: prefs,
ShowSuccess: showSuccess,
}
if err := app.Tmpl.ExecuteTemplate(w, "preferences", data); err != nil {
log.Printf("Error executing template in PreferencesHandler: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
}
}

View File

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

View File

@ -1,66 +1,94 @@
package handlers
import (
"log"
"net/http"
"threadr/models"
"github.com/gorilla/sessions"
"github.com/gorilla/sessions"
"log"
"net/http"
"threadr/models"
)
func SignupHandler(app *App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*sessions.Session)
cookie, _ := r.Cookie("threadr_cookie_banner")
if r.Method == http.MethodPost {
username := r.FormValue("username")
password := r.FormValue("password")
err := models.CreateUser(app.DB, username, password)
if err != nil {
log.Printf("Error creating user: %v", err)
data := struct {
PageData
Error string
}{
PageData: PageData{
Title: "ThreadR - Sign Up",
Navbar: "signup",
LoggedIn: false,
ShowCookieBanner: cookie == nil || cookie.Value != "accepted",
BasePath: app.Config.ThreadrDir,
StaticPath: app.Config.ThreadrDir + "/static",
CurrentURL: r.URL.Path,
},
Error: "An error occurred during sign up. Please try again.",
}
if err := app.Tmpl.ExecuteTemplate(w, "signup", data); err != nil {
log.Printf("Error executing template in SignupHandler: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
return
}
http.Redirect(w, r, app.Config.ThreadrDir+"/login/", http.StatusFound)
return
}
data := struct {
PageData
Error string
}{
PageData: PageData{
Title: "ThreadR - Sign Up",
Navbar: "signup",
LoggedIn: session.Values["user_id"] != nil,
ShowCookieBanner: cookie == nil || cookie.Value != "accepted",
BasePath: app.Config.ThreadrDir,
StaticPath: app.Config.ThreadrDir + "/static",
CurrentURL: r.URL.Path,
},
Error: "",
}
if err := app.Tmpl.ExecuteTemplate(w, "signup", data); err != nil {
log.Printf("Error executing template in SignupHandler: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
}
return func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*sessions.Session)
cookie, _ := r.Cookie("threadr_cookie_banner")
if r.Method == http.MethodPost {
username := r.FormValue("username")
password := r.FormValue("password")
passwordConfirm := r.FormValue("password_confirm")
// Server-side validation for password confirmation
if password != passwordConfirm {
log.Printf("Password confirmation mismatch for user: %s", username)
data := struct {
PageData
Error string
}{
PageData: PageData{
Title: "ThreadR - Sign Up",
Navbar: "signup",
LoggedIn: false,
ShowCookieBanner: cookie == nil || cookie.Value != "accepted",
BasePath: app.Config.ThreadrDir,
StaticPath: app.Config.ThreadrDir + "/static",
CurrentURL: r.URL.Path,
},
Error: "Passwords do not match. Please try again.",
}
if err := app.Tmpl.ExecuteTemplate(w, "signup", data); err != nil {
log.Printf("Error executing template in SignupHandler: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
return
}
err := models.CreateUser(app.DB, username, password)
if err != nil {
log.Printf("Error creating user: %v", err)
data := struct {
PageData
Error string
}{
PageData: PageData{
Title: "ThreadR - Sign Up",
Navbar: "signup",
LoggedIn: false,
ShowCookieBanner: cookie == nil || cookie.Value != "accepted",
BasePath: app.Config.ThreadrDir,
StaticPath: app.Config.ThreadrDir + "/static",
CurrentURL: r.URL.Path,
},
Error: "An error occurred during sign up. Please try again.",
}
if err := app.Tmpl.ExecuteTemplate(w, "signup", data); err != nil {
log.Printf("Error executing template in SignupHandler: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
return
}
http.Redirect(w, r, app.Config.ThreadrDir+"/login/", http.StatusFound)
return
}
data := struct {
PageData
Error string
}{
PageData: PageData{
Title: "ThreadR - Sign Up",
Navbar: "signup",
LoggedIn: session.Values["user_id"] != nil,
ShowCookieBanner: cookie == nil || cookie.Value != "accepted",
BasePath: app.Config.ThreadrDir,
StaticPath: app.Config.ThreadrDir + "/static",
CurrentURL: r.URL.Path,
},
Error: "",
}
if err := app.Tmpl.ExecuteTemplate(w, "signup", data); err != nil {
log.Printf("Error executing template in SignupHandler: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
}
}

View File

@ -1,135 +1,137 @@
package handlers
import (
"log"
"net/http"
"strconv"
"threadr/models"
"github.com/gorilla/sessions"
"github.com/gorilla/sessions"
"log"
"net/http"
"strconv"
"threadr/models"
)
func ThreadHandler(app *App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*sessions.Session)
loggedIn := session.Values["user_id"] != nil
userID, _ := session.Values["user_id"].(int)
cookie, _ := r.Cookie("threadr_cookie_banner")
return func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*sessions.Session)
loggedIn := session.Values["user_id"] != nil
userID, _ := session.Values["user_id"].(int)
cookie, _ := r.Cookie("threadr_cookie_banner")
threadIDStr := r.URL.Query().Get("id")
threadID, err := strconv.Atoi(threadIDStr)
if err != nil {
http.Error(w, "Invalid thread ID", http.StatusBadRequest)
return
}
threadIDStr := r.URL.Query().Get("id")
threadID, err := strconv.Atoi(threadIDStr)
if err != nil {
http.Error(w, "Invalid thread ID", http.StatusBadRequest)
return
}
thread, err := models.GetThreadByID(app.DB, threadID)
if err != nil {
log.Printf("Error fetching thread: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
if thread == nil {
http.Error(w, "Thread not found", http.StatusNotFound)
return
}
thread, err := models.GetThreadByID(app.DB, threadID)
if err != nil {
log.Printf("Error fetching thread: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
if thread == nil {
http.Error(w, "Thread not found", http.StatusNotFound)
return
}
board, err := models.GetBoardByID(app.DB, thread.BoardID)
if err != nil {
log.Printf("Error fetching board: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
if board.Private {
if !loggedIn {
http.Redirect(w, r, app.Config.ThreadrDir+"/login/", http.StatusFound)
return
}
hasPerm, err := models.HasBoardPermission(app.DB, userID, board.ID, 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 board", http.StatusForbidden)
return
}
}
board, err := models.GetBoardByID(app.DB, thread.BoardID)
if err != nil {
log.Printf("Error fetching board: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
if board.Private {
if !loggedIn {
http.Redirect(w, r, app.Config.ThreadrDir+"/login/", http.StatusFound)
return
}
hasPerm, err := models.HasBoardPermission(app.DB, userID, board.ID, 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 board", http.StatusForbidden)
return
}
}
if r.Method == http.MethodPost && loggedIn {
action := r.URL.Query().Get("action")
if action == "submit" {
content := r.FormValue("content")
replyToStr := r.URL.Query().Get("to")
replyTo := -1
if replyToStr != "" {
replyTo, err = strconv.Atoi(replyToStr)
if err != nil {
http.Error(w, "Invalid reply_to ID", http.StatusBadRequest)
return
}
}
if content == "" {
http.Error(w, "Content cannot be empty", http.StatusBadRequest)
return
}
if board.Private {
hasPerm, err := models.HasBoardPermission(app.DB, userID, board.ID, models.PermPostInBoard)
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 post in this board", http.StatusForbidden)
return
}
}
post := models.Post{
ThreadID: threadID,
UserID: userID,
Content: content,
ReplyTo: replyTo,
}
err = models.CreatePost(app.DB, post)
if err != nil {
log.Printf("Error creating post: %v", err)
http.Error(w, "Failed to create post", http.StatusInternalServerError)
return
}
http.Redirect(w, r, app.Config.ThreadrDir+"/thread/?id="+threadIDStr, http.StatusFound)
return
}
}
if r.Method == http.MethodPost && loggedIn {
action := r.URL.Query().Get("action")
if action == "submit" {
content := r.FormValue("content")
replyToStr := r.URL.Query().Get("to")
replyTo := -1
if replyToStr != "" {
replyTo, err = strconv.Atoi(replyToStr)
if err != nil {
http.Error(w, "Invalid reply_to ID", http.StatusBadRequest)
return
}
}
if content == "" {
http.Error(w, "Content cannot be empty", http.StatusBadRequest)
return
}
if board.Private {
hasPerm, err := models.HasBoardPermission(app.DB, userID, board.ID, models.PermPostInBoard)
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 post in this board", http.StatusForbidden)
return
}
}
post := models.Post{
ThreadID: threadID,
UserID: userID,
Content: content,
ReplyTo: replyTo,
}
err = models.CreatePost(app.DB, post)
if err != nil {
log.Printf("Error creating post: %v", err)
http.Error(w, "Failed to create post", http.StatusInternalServerError)
return
}
http.Redirect(w, r, app.Config.ThreadrDir+"/thread/?id="+threadIDStr, http.StatusFound)
return
}
}
posts, err := models.GetPostsByThreadID(app.DB, threadID)
if err != nil {
log.Printf("Error fetching posts: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
posts, err := models.GetPostsByThreadID(app.DB, threadID)
if err != nil {
log.Printf("Error fetching posts: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
data := struct {
PageData
Thread models.Thread
Posts []models.Post
}{
PageData: PageData{
Title: "ThreadR - " + thread.Title,
Navbar: "boards",
LoggedIn: loggedIn,
ShowCookieBanner: cookie == nil || cookie.Value != "accepted",
BasePath: app.Config.ThreadrDir,
StaticPath: app.Config.ThreadrDir + "/static",
CurrentURL: r.URL.Path,
},
Thread: *thread,
Posts: posts,
}
if err := app.Tmpl.ExecuteTemplate(w, "thread", data); err != nil {
log.Printf("Error executing template in ThreadHandler: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
}
data := struct {
PageData
Thread models.Thread
Board models.Board
Posts []models.Post
}{
PageData: PageData{
Title: "ThreadR - " + thread.Title,
Navbar: "boards",
LoggedIn: loggedIn,
ShowCookieBanner: cookie == nil || cookie.Value != "accepted",
BasePath: app.Config.ThreadrDir,
StaticPath: app.Config.ThreadrDir + "/static",
CurrentURL: r.URL.Path,
},
Thread: *thread,
Board: *board,
Posts: posts,
}
if err := app.Tmpl.ExecuteTemplate(w, "thread", data); err != nil {
log.Printf("Error executing template in ThreadHandler: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
}
}

391
main.go
View File

@ -22,19 +22,19 @@ import (
)
func loadConfig(filename string) (*handlers.Config, error) {
file, err := os.Open(filename)
if err != nil {
return nil, err
}
defer file.Close()
var config handlers.Config
err = json.NewDecoder(file).Decode(&config)
return &config, err
file, err := os.Open(filename)
if err != nil {
return nil, err
}
defer file.Close()
var config handlers.Config
err = json.NewDecoder(file).Decode(&config)
return &config, err
}
func createTablesIfNotExist(db *sql.DB) error {
// Create boards table
_, err := db.Exec(`
// Create boards table
_, err := db.Exec(`
CREATE TABLE boards (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
@ -43,34 +43,15 @@ func createTablesIfNotExist(db *sql.DB) error {
public_visible BOOLEAN DEFAULT TRUE,
pinned_threads TEXT,
custom_landing_page TEXT,
color_scheme VARCHAR(255)
color_scheme VARCHAR(255),
type VARCHAR(20) DEFAULT 'classic' NOT NULL
)`)
if err != nil {
return fmt.Errorf("error creating boards table: %v", err)
}
if err != nil {
return fmt.Errorf("error creating boards table: %v", err)
}
// Create users table
_, err = db.Exec(`
CREATE TABLE users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(255) NOT NULL UNIQUE,
display_name VARCHAR(255),
pfp_url VARCHAR(255),
bio TEXT,
authentication_string VARCHAR(128) NOT NULL,
authentication_salt VARCHAR(255) NOT NULL,
authentication_algorithm VARCHAR(50) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
verified BOOLEAN DEFAULT FALSE,
permissions BIGINT DEFAULT 0
)`)
if err != nil {
return fmt.Errorf("error creating users table: %v", err)
}
// Create threads table (without type field)
_, err = db.Exec(`
// Create threads table
_, err = db.Exec(`
CREATE TABLE threads (
id INT AUTO_INCREMENT PRIMARY KEY,
board_id INT NOT NULL,
@ -81,12 +62,12 @@ func createTablesIfNotExist(db *sql.DB) error {
accepted_answer_post_id INT,
FOREIGN KEY (board_id) REFERENCES boards(id)
)`)
if err != nil {
return fmt.Errorf("error creating threads table: %v", err)
}
if err != nil {
return fmt.Errorf("error creating threads table: %v", err)
}
// Create posts table
_, err = db.Exec(`
// Create posts table
_, err = db.Exec(`
CREATE TABLE posts (
id INT AUTO_INCREMENT PRIMARY KEY,
thread_id INT NOT NULL,
@ -100,12 +81,12 @@ func createTablesIfNotExist(db *sql.DB) error {
reply_to INT DEFAULT -1,
FOREIGN KEY (thread_id) REFERENCES threads(id)
)`)
if err != nil {
return fmt.Errorf("error creating posts table: %v", err)
}
if err != nil {
return fmt.Errorf("error creating posts table: %v", err)
}
// Create likes table
_, err = db.Exec(`
// Create likes table
_, err = db.Exec(`
CREATE TABLE likes (
id INT AUTO_INCREMENT PRIMARY KEY,
post_id INT NOT NULL,
@ -114,12 +95,12 @@ func createTablesIfNotExist(db *sql.DB) error {
UNIQUE KEY unique_like (post_id, user_id),
FOREIGN KEY (post_id) REFERENCES posts(id)
)`)
if err != nil {
return fmt.Errorf("error creating likes table: %v", err)
}
if err != nil {
return fmt.Errorf("error creating likes table: %v", err)
}
// Create board_permissions table
_, err = db.Exec(`
// Create board_permissions table
_, err = db.Exec(`
CREATE TABLE board_permissions (
user_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),
FOREIGN KEY (board_id) REFERENCES boards(id)
)`)
if err != nil {
return fmt.Errorf("error creating board_permissions table: %v", err)
}
if err != nil {
return fmt.Errorf("error creating board_permissions table: %v", err)
}
// Create notifications table
_, err = db.Exec(`
// Create notifications table
_, err = db.Exec(`
CREATE TABLE notifications (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
@ -141,12 +122,12 @@ func createTablesIfNotExist(db *sql.DB) error {
is_read BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)`)
if err != nil {
return fmt.Errorf("error creating notifications table: %v", err)
}
if err != nil {
return fmt.Errorf("error creating notifications table: %v", err)
}
// Create reactions table
_, err = db.Exec(`
// Create reactions table
_, err = db.Exec(`
CREATE TABLE reactions (
id INT AUTO_INCREMENT PRIMARY KEY,
post_id INT NOT NULL,
@ -154,12 +135,12 @@ func createTablesIfNotExist(db *sql.DB) error {
emoji VARCHAR(10) NOT NULL,
FOREIGN KEY (post_id) REFERENCES posts(id)
)`)
if err != nil {
return fmt.Errorf("error creating reactions table: %v", err)
}
if err != nil {
return fmt.Errorf("error creating reactions table: %v", err)
}
// Create reposts table
_, err = db.Exec(`
// Create reposts table
_, err = db.Exec(`
CREATE TABLE reposts (
id INT AUTO_INCREMENT PRIMARY KEY,
thread_id INT NOT NULL,
@ -169,12 +150,12 @@ func createTablesIfNotExist(db *sql.DB) error {
FOREIGN KEY (thread_id) REFERENCES threads(id),
FOREIGN KEY (board_id) REFERENCES boards(id)
)`)
if err != nil {
return fmt.Errorf("error creating reposts table: %v", err)
}
if err != nil {
return fmt.Errorf("error creating reposts table: %v", err)
}
// Create news table
_, err = db.Exec(`
// Create news table
_, err = db.Exec(`
CREATE TABLE news (
id INT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(255) NOT NULL,
@ -182,25 +163,76 @@ func createTablesIfNotExist(db *sql.DB) error {
posted_by INT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)`)
if err != nil {
return fmt.Errorf("error creating news table: %v", err)
}
if err != nil {
return fmt.Errorf("error creating news table: %v", err)
}
// Create chat_messages table
_, err = db.Exec(`
// Create chat_messages table
_, err = db.Exec(`
CREATE TABLE chat_messages (
id INT AUTO_INCREMENT PRIMARY KEY,
board_id INT NOT NULL,
user_id INT NOT NULL,
content TEXT NOT NULL,
reply_to INT DEFAULT -1,
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (board_id) REFERENCES boards(id) ON DELETE CASCADE
)`)
if err != nil {
return fmt.Errorf("error creating chat_messages table: %v", err)
}
if err != nil {
return fmt.Errorf("error creating chat_messages table: %v", err)
}
log.Println("Database tables created.")
return nil
// Create files table (Hope this does not break anything)
_, err = db.Exec(`
CREATE TABLE files (
id INT AUTO_INCREMENT PRIMARY KEY,
original_name VARCHAR(255) NOT NULL,
hash VARCHAR(255) NOT NULL,
hash_algorithm VARCHAR(50) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)`)
if err != nil {
return fmt.Errorf("error creating files table: %v", err)
}
// Create users table (KEEP THIS HERE!)
// Otherwise SQL bitches about the foreign key.
_, err = db.Exec(`
CREATE TABLE users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(255) NOT NULL UNIQUE,
display_name VARCHAR(255),
pfp_file_id INT,
bio TEXT,
authentication_string VARCHAR(128) NOT NULL,
authentication_salt VARCHAR(255) NOT NULL,
authentication_algorithm VARCHAR(50) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
verified BOOLEAN DEFAULT FALSE,
permissions BIGINT DEFAULT 0,
FOREIGN KEY (pfp_file_id) REFERENCES files(id)
)`)
if err != nil {
return fmt.Errorf("error creating users table: %v", err)
}
// Create user_preferences table
_, err = db.Exec(`
CREATE TABLE user_preferences (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL UNIQUE,
auto_save_drafts BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`)
if err != nil {
return fmt.Errorf("error creating user_preferences table: %v", err)
}
log.Println("Database tables created.")
return nil
}
func ensureAdminUser(db *sql.DB) error {
@ -266,101 +298,130 @@ func ensureAdminUser(db *sql.DB) error {
}
func main() {
// Define command-line flag for initialization
initialize := flag.Bool("initialize", false, "Initialize database tables and admin user")
flag.BoolVar(initialize, "i", false, "Short for --initialize")
flag.Parse()
// Define command-line flag for initialization
initialize := flag.Bool("initialize", false, "Initialize database tables and admin user")
flag.BoolVar(initialize, "i", false, "Short for --initialize")
flag.Parse()
config, err := loadConfig("config/config.json")
if err != nil {
log.Fatal("Error loading config:", err)
}
config, err := loadConfig("config/config.json")
if err != nil {
log.Fatal("Error loading config:", err)
}
dsn := fmt.Sprintf("%s:%s@tcp(%s)/%s", config.DBUsername, config.DBPassword, config.DBServerHost, config.DBDatabase)
db, err := sql.Open("mysql", dsn)
if err != nil {
log.Fatal("Error connecting to database:", err)
}
defer db.Close()
// Allow environment variable override for the session secret to avoid hardcoding secrets in files.
if envSecret := os.Getenv("THREADR_SESSION_SECRET"); envSecret != "" {
config.SessionSecret = envSecret
}
if len(config.SessionSecret) < 32 {
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
if *initialize {
log.Println("Initializing database...")
err = createTablesIfNotExist(db)
if err != nil {
log.Fatal("Error creating database tables:", err)
}
dsn := fmt.Sprintf("%s:%s@tcp(%s)/%s", config.DBUsername, config.DBPassword, config.DBServerHost, config.DBDatabase)
db, err := sql.Open("mysql", dsn)
if err != nil {
log.Fatal("Error connecting to database:", err)
}
defer db.Close()
err = ensureAdminUser(db)
if err != nil {
log.Fatal("Error ensuring admin user:", err)
}
// Create the file directory
// TODO: Wouldn't this be better suited on the initialize function?
// Discussion pending.
err = os.MkdirAll(config.FileStorageDir, 0700)
if err != nil {
log.Fatal("Error creating file storage directory:", err)
}
log.Println("Initialization completed successfully. Exiting.")
return
}
// Perform initialization if the flag is set
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)
log.Println("Starting ThreadR server...")
err = ensureAdminUser(db)
if err != nil {
log.Fatal("Error ensuring admin user:", err)
}
dir, err := os.Getwd()
if err != nil {
log.Fatal("Error getting working directory:", err)
}
log.Println("Initialization completed successfully. Exiting.")
return
}
// Parse partial templates
tmpl := template.Must(template.ParseFiles(
filepath.Join(dir, "templates/partials/navbar.html"),
filepath.Join(dir, "templates/partials/cookie_banner.html"),
))
// Normal startup (without automatic table creation)
log.Println("Starting ThreadR server...")
// Parse page-specific templates with unique names
tmpl, err = tmpl.ParseFiles(
filepath.Join(dir, "templates/pages/about.html"),
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)
}
dir, err := os.Getwd()
if err != nil {
log.Fatal("Error getting working directory:", 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{
DB: db,
Store: store,
Config: config,
Tmpl: tmpl,
}
// Parse page-specific templates with unique names
tmpl, err = tmpl.ParseFiles(
filepath.Join(dir, "templates/pages/about.html"),
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"),
filepath.Join(dir, "templates/pages/preferences.html"),
)
if err != nil {
log.Fatal("Error parsing page templates:", err)
}
fs := http.FileServer(http.Dir("static"))
http.Handle(config.ThreadrDir+"/static/", http.StripPrefix(config.ThreadrDir+"/static/", fs))
store := sessions.NewCookieStore([]byte(config.SessionSecret))
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)))
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))))
app := &handlers.App{
DB: db,
Store: store,
Config: config,
Tmpl: tmpl,
}
log.Println("Server starting on :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
fs := http.FileServer(http.Dir("static"))
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+"/preferences/", app.SessionMW(app.RequireLoginMW(handlers.PreferencesHandler(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
CustomLandingPage string
ColorScheme string
Type string
}
func GetBoardByID(db *sql.DB, id int) (*Board, error) {
query := "SELECT id, name, description, private, public_visible, pinned_threads, custom_landing_page, color_scheme FROM boards WHERE id = ?"
query := "SELECT id, name, description, private, public_visible, pinned_threads, custom_landing_page, color_scheme, type FROM boards WHERE id = ?"
row := db.QueryRow(query, id)
board := &Board{}
var pinnedThreadsJSON sql.NullString
var customLandingPage sql.NullString
var colorScheme sql.NullString
var description sql.NullString
err := row.Scan(&board.ID, &board.Name, &description, &board.Private, &board.PublicVisible, &pinnedThreadsJSON, &customLandingPage, &colorScheme)
err := row.Scan(&board.ID, &board.Name, &description, &board.Private, &board.PublicVisible, &pinnedThreadsJSON, &customLandingPage, &colorScheme, &board.Type)
if err == sql.ErrNoRows {
return nil, nil
}
@ -56,7 +57,7 @@ func GetBoardByID(db *sql.DB, id int) (*Board, error) {
}
func GetAllBoards(db *sql.DB, private bool) ([]Board, error) {
query := "SELECT id, name, description, private, public_visible, pinned_threads, custom_landing_page, color_scheme FROM boards WHERE private = ? ORDER BY id ASC"
query := "SELECT id, name, description, private, public_visible, pinned_threads, custom_landing_page, color_scheme, type FROM boards WHERE private = ? ORDER BY id ASC"
rows, err := db.Query(query, private)
if err != nil {
return nil, err
@ -70,7 +71,7 @@ func GetAllBoards(db *sql.DB, private bool) ([]Board, error) {
var customLandingPage sql.NullString
var colorScheme sql.NullString
var description sql.NullString
err := rows.Scan(&board.ID, &board.Name, &description, &board.Private, &board.PublicVisible, &pinnedThreadsJSON, &customLandingPage, &colorScheme)
err := rows.Scan(&board.ID, &board.Name, &description, &board.Private, &board.PublicVisible, &pinnedThreadsJSON, &customLandingPage, &colorScheme, &board.Type)
if err != nil {
return nil, err
}

View File

@ -2,34 +2,41 @@ package models
import (
"database/sql"
"fmt"
"html"
"html/template"
"regexp"
"strings"
"time"
)
type ChatMessage struct {
ID int
UserID int
Content string
ReplyTo int // -1 if not a reply
Timestamp time.Time
Username string // For display, fetched from user
PfpURL string // For display, fetched from user
Mentions []string // List of mentioned usernames
ID int `json:"id"`
BoardID int `json:"boardId"`
UserID int `json:"userId"`
Content template.HTML `json:"content"`
ReplyTo int `json:"replyTo"`
Timestamp time.Time `json:"timestamp"`
Username string `json:"username"`
PfpFileID sql.NullInt64 `json:"pfpFileId"`
Mentions []string `json:"mentions"`
}
func CreateChatMessage(db *sql.DB, msg ChatMessage) error {
query := "INSERT INTO chat_messages (user_id, content, reply_to, timestamp) VALUES (?, ?, ?, NOW())"
_, err := db.Exec(query, msg.UserID, msg.Content, msg.ReplyTo)
func CreateChatMessage(db *sql.DB, boardID, userID int, rawContent string, replyTo int) error {
query := "INSERT INTO chat_messages (board_id, user_id, content, reply_to, timestamp) VALUES (?, ?, ?, ?, NOW())"
_, err := db.Exec(query, boardID, userID, rawContent, replyTo)
return err
}
func GetRecentChatMessages(db *sql.DB, limit int) ([]ChatMessage, error) {
func GetRecentChatMessages(db *sql.DB, boardID int, limit int) ([]ChatMessage, error) {
query := `
SELECT cm.id, cm.user_id, cm.content, cm.reply_to, cm.timestamp, u.username, u.pfp_url
SELECT cm.id, cm.user_id, cm.content, cm.reply_to, cm.timestamp, u.username, u.pfp_file_id
FROM chat_messages cm
JOIN users u ON cm.user_id = u.id
WHERE cm.board_id = ?
ORDER BY cm.timestamp DESC
LIMIT ?`
rows, err := db.Query(query, limit)
rows, err := db.Query(query, boardID, limit)
if err != nil {
return nil, err
}
@ -39,20 +46,18 @@ func GetRecentChatMessages(db *sql.DB, limit int) ([]ChatMessage, error) {
for rows.Next() {
var msg ChatMessage
var timestampStr string
var pfpURL sql.NullString
err := rows.Scan(&msg.ID, &msg.UserID, &msg.Content, &msg.ReplyTo, &timestampStr, &msg.Username, &pfpURL)
var rawContent string
err := rows.Scan(&msg.ID, &msg.UserID, &rawContent, &msg.ReplyTo, &timestampStr, &msg.Username, &msg.PfpFileID)
if err != nil {
return nil, err
}
msg.BoardID = boardID
msg.Timestamp, err = time.Parse("2006-01-02 15:04:05", timestampStr)
if err != nil {
msg.Timestamp = time.Time{}
}
if pfpURL.Valid {
msg.PfpURL = pfpURL.String
}
// Parse mentions from content (simple @username detection)
msg.Mentions = extractMentions(msg.Content)
msg.Content = renderMarkdown(rawContent)
msg.Mentions = extractMentions(rawContent)
messages = append(messages, msg)
}
return messages, nil
@ -60,15 +65,15 @@ func GetRecentChatMessages(db *sql.DB, limit int) ([]ChatMessage, error) {
func GetChatMessageByID(db *sql.DB, id int) (*ChatMessage, error) {
query := `
SELECT cm.id, cm.user_id, cm.content, cm.reply_to, cm.timestamp, u.username, u.pfp_url
SELECT cm.id, cm.board_id, cm.user_id, cm.content, cm.reply_to, cm.timestamp, u.username, u.pfp_file_id
FROM chat_messages cm
JOIN users u ON cm.user_id = u.id
WHERE cm.id = ?`
row := db.QueryRow(query, id)
var msg ChatMessage
var timestampStr string
var pfpURL sql.NullString
err := row.Scan(&msg.ID, &msg.UserID, &msg.Content, &msg.ReplyTo, &timestampStr, &msg.Username, &pfpURL)
var rawContent string
err := row.Scan(&msg.ID, &msg.BoardID, &msg.UserID, &rawContent, &msg.ReplyTo, &timestampStr, &msg.Username, &msg.PfpFileID)
if err == sql.ErrNoRows {
return nil, nil
}
@ -79,54 +84,122 @@ func GetChatMessageByID(db *sql.DB, id int) (*ChatMessage, error) {
if err != nil {
msg.Timestamp = time.Time{}
}
if pfpURL.Valid {
msg.PfpURL = pfpURL.String
}
msg.Mentions = extractMentions(msg.Content)
msg.Content = renderMarkdown(rawContent)
msg.Mentions = extractMentions(rawContent)
return &msg, nil
}
func GetUsernamesMatching(db *sql.DB, prefix string) ([]string, error) {
query := "SELECT username FROM users WHERE username LIKE ? LIMIT 10"
rows, err := db.Query(query, prefix+"%")
if err != nil {
return nil, err
}
defer rows.Close()
var usernames []string
for rows.Next() {
var username string
if err := rows.Scan(&username); err != nil {
return nil, err
}
usernames = append(usernames, username)
}
return usernames, nil
}
// Simple utility to extract mentions from content
func extractMentions(content string) []string {
var mentions []string
var currentMention string
inMention := false
for _, char := range content {
if char == '@' {
inMention = true
currentMention = "@"
} else if inMention && (char == ' ' || char == '\n' || char == '\t') {
if len(currentMention) > 1 {
mentions = append(mentions, currentMention)
}
inMention = false
currentMention = ""
} else if inMention {
currentMention += string(char)
}
}
if inMention && len(currentMention) > 1 {
mentions = append(mentions, currentMention)
re := regexp.MustCompile(`@(\w+)`)
matches := re.FindAllStringSubmatch(content, -1)
mentions := make([]string, len(matches))
for i, match := range matches {
mentions[i] = match[1]
}
return mentions
}
func processInlineMarkdown(line string) string {
line = regexp.MustCompile("`([^`]+)`").ReplaceAllString(line, "<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 {
query := "INSERT INTO threads (board_id, title, created_by_user_id, created_at, updated_at, type) VALUES (?, ?, ?, NOW(), NOW(), 'classic')"
query := "INSERT INTO threads (board_id, title, created_by_user_id, created_at, updated_at) VALUES (?, ?, ?, NOW(), NOW())"
_, err := db.Exec(query, thread.BoardID, thread.Title, thread.CreatedByUserID)
return err
}

View File

@ -1,161 +1,202 @@
package models
import (
"crypto/sha256"
"database/sql"
"fmt"
"time"
"crypto/rand"
"crypto/sha256"
"database/sql"
"fmt"
"time"
"golang.org/x/crypto/bcrypt"
)
type User struct {
ID int
Username string
DisplayName string
PfpURL string
Bio string
AuthenticationString string
AuthenticationSalt string
AuthenticationAlgorithm string
CreatedAt time.Time
UpdatedAt time.Time
Verified bool
Permissions int64
}
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
ID int
Username string
DisplayName string
PfpFileID sql.NullInt64
Bio string
AuthenticationString string
AuthenticationSalt string
AuthenticationAlgorithm string
CreatedAt time.Time
UpdatedAt time.Time
Verified bool
Permissions int64
}
const (
PermCreateBoard int64 = 1 << 0
PermManageUsers int64 = 1 << 1
// bcryptCost defines the work factor for bcrypt password hashing.
// 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 {
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

@ -0,0 +1,67 @@
package models
import (
"database/sql"
)
type UserPreferences struct {
ID int
UserID int
AutoSaveDrafts bool
}
// GetUserPreferences retrieves preferences for a user, creating defaults if none exist
func GetUserPreferences(db *sql.DB, userID int) (*UserPreferences, error) {
query := `SELECT id, user_id, auto_save_drafts
FROM user_preferences WHERE user_id = ?`
prefs := &UserPreferences{}
err := db.QueryRow(query, userID).Scan(
&prefs.ID,
&prefs.UserID,
&prefs.AutoSaveDrafts,
)
if err == sql.ErrNoRows {
// No preferences exist, create defaults
return CreateDefaultPreferences(db, userID)
}
if err != nil {
return nil, err
}
return prefs, nil
}
// CreateDefaultPreferences creates default preferences for a new user
func CreateDefaultPreferences(db *sql.DB, userID int) (*UserPreferences, error) {
query := `INSERT INTO user_preferences (user_id, auto_save_drafts)
VALUES (?, TRUE)`
result, err := db.Exec(query, userID)
if err != nil {
return nil, err
}
id, err := result.LastInsertId()
if err != nil {
return nil, err
}
return &UserPreferences{
ID: int(id),
UserID: userID,
AutoSaveDrafts: true,
}, nil
}
// UpdateUserPreferences updates user preferences
func UpdateUserPreferences(db *sql.DB, prefs *UserPreferences) error {
query := `UPDATE user_preferences
SET auto_save_drafts = ?, updated_at = NOW()
WHERE user_id = ?`
_, err := db.Exec(query, prefs.AutoSaveDrafts, prefs.UserID)
return err
}

25
static/app.js Normal file
View File

@ -0,0 +1,25 @@
// ThreadR UI Enhancement JavaScript
function initRelativeTimestamps() {
// This function can be used to show relative timestamps
// For now, it's just a placeholder
}
function initApp() {
initRelativeTimestamps();
if (typeof initKeyboardShortcuts === 'function') {
initKeyboardShortcuts();
}
if (typeof initFormHandling === 'function') {
initFormHandling();
}
if (typeof initLikeButtons === 'function') {
initLikeButtons();
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initApp);
} else {
initApp();
}

564
static/chat.js Normal file
View File

@ -0,0 +1,564 @@
(() => {
'use strict';
const chatContainer = document.querySelector('.chat-container');
if (!chatContainer) {
return;
}
const boardId = chatContainer.dataset.boardId;
const basePath = chatContainer.dataset.basePath || '';
const currentUsername = chatContainer.dataset.currentUsername || '';
const usernamesScript = document.getElementById('chat-usernames');
let allUsernames = [];
if (usernamesScript) {
try {
allUsernames = JSON.parse(usernamesScript.textContent || '[]');
} catch (err) {
console.error('Failed to parse chat usernames:', err);
allUsernames = [];
}
}
let ws;
let autocompleteActive = false;
let replyToId = -1;
let reconnectAttempts = 0;
let reconnectTimeout;
let isUserAtBottom = true;
let unreadCount = 0;
const maxReconnectDelay = 30000;
const typingUsers = new Set();
function updateConnectionStatus(status) {
const dot = document.getElementById('connection-dot');
const text = document.getElementById('connection-text');
if (!dot || !text) {
return;
}
dot.className = 'connection-dot ' + status;
if (status === 'connected') {
text.textContent = 'Connected';
reconnectAttempts = 0;
} else if (status === 'connecting') {
text.textContent = 'Connecting...';
} else if (status === 'disconnected') {
text.textContent = 'Disconnected';
}
}
function connectWebSocket() {
if (!boardId) {
return;
}
updateConnectionStatus('connecting');
ws = new WebSocket('ws://' + window.location.host + basePath + '/chat/?ws=true&id=' + boardId);
ws.onopen = function() {
updateConnectionStatus('connected');
reconnectAttempts = 0;
};
ws.onmessage = function(event) {
const msg = JSON.parse(event.data);
if (msg.type === 'typing') {
showTypingIndicator(msg.username);
return;
}
appendMessage(msg);
};
ws.onclose = function() {
updateConnectionStatus('disconnected');
console.log('WebSocket closed, reconnecting...');
scheduleReconnect();
};
ws.onerror = function(error) {
console.error('WebSocket error:', error);
updateConnectionStatus('disconnected');
};
}
function scheduleReconnect() {
if (reconnectTimeout) {
clearTimeout(reconnectTimeout);
}
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), maxReconnectDelay);
reconnectAttempts++;
console.log(`Reconnecting in ${delay}ms (attempt ${reconnectAttempts})...`);
reconnectTimeout = setTimeout(connectWebSocket, delay);
}
function sendMessage() {
const input = document.getElementById('chat-input-text');
if (!input) {
return;
}
const content = input.value.trim();
if (content === '') {
return;
}
const msg = {
type: 'message',
content: content,
replyTo: replyToId
};
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(msg));
input.value = '';
cancelReply();
} else {
console.error('WebSocket is not open. Current state:', ws ? ws.readyState : 'undefined');
alert('Cannot send message: Not connected to chat server');
}
}
function sendTypingIndicator() {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'typing', username: currentUsername }));
}
}
function showTypingIndicator(username) {
if (username === currentUsername) {
return;
}
typingUsers.add(username);
updateTypingDisplay();
setTimeout(() => {
typingUsers.delete(username);
updateTypingDisplay();
}, 3000);
}
function updateTypingDisplay() {
const indicator = document.getElementById('typing-indicator');
const usersSpan = document.getElementById('typing-users');
if (!indicator || !usersSpan) {
return;
}
if (typingUsers.size === 0) {
indicator.classList.remove('visible');
} else {
const users = Array.from(typingUsers);
if (users.length === 1) {
usersSpan.textContent = `${users[0]} is typing`;
} else if (users.length === 2) {
usersSpan.textContent = `${users[0]} and ${users[1]} are typing`;
} else {
usersSpan.textContent = `${users[0]}, ${users[1]}, and ${users.length - 2} other${users.length - 2 > 1 ? 's' : ''} are typing`;
}
indicator.classList.add('visible');
}
}
function appendMessage(msg) {
const messages = document.getElementById('chat-messages');
if (!messages) {
return;
}
if (document.getElementById('msg-' + msg.id)) {
return;
}
const wasAtBottom = isUserAtBottom;
const msgDiv = document.createElement('div');
let highlightClass = '';
if (msg.mentions && msg.mentions.includes(currentUsername)) {
highlightClass = ' chat-message-highlighted';
}
msgDiv.className = 'chat-message' + highlightClass;
msgDiv.id = 'msg-' + msg.id;
msgDiv.dataset.user = msg.username;
msgDiv.dataset.reply = msg.replyTo || 0;
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">`;
}
const replyHTML = msg.replyTo > 0 ? `<div class="chat-message-reply" onclick="scrollToMessage(${msg.replyTo})">Replying to message...</div>` : '';
msgDiv.innerHTML = `
<div class="chat-message-header">
${pfpHTML}
<span class="chat-message-username">${msg.username}</span>
<span class="chat-message-timestamp">${new Date(msg.timestamp).toLocaleString()}</span>
</div>
${replyHTML}
<div class="chat-message-content">${msg.content}</div>
<div class="post-actions">
<a href="javascript:void(0)" onclick="replyToMessage(${msg.id}, '${msg.username}')">Reply</a>
</div>
`;
messages.appendChild(msgDiv);
applyGrouping();
if (wasAtBottom) {
messages.scrollTop = messages.scrollHeight;
unreadCount = 0;
updateUnreadBadge();
} else if (msg.username !== currentUsername) {
unreadCount++;
updateUnreadBadge();
}
}
function updateUnreadBadge() {
const badge = document.getElementById('unread-badge');
if (!badge) {
return;
}
if (unreadCount > 0) {
badge.textContent = unreadCount > 99 ? '99+' : unreadCount;
badge.style.display = 'flex';
} else {
badge.style.display = 'none';
}
}
function jumpToBottom() {
const messages = document.getElementById('chat-messages');
if (!messages) {
return;
}
messages.scrollTo({
top: messages.scrollHeight,
behavior: 'smooth'
});
unreadCount = 0;
updateUnreadBadge();
}
function checkScrollPosition() {
const messages = document.getElementById('chat-messages');
const jumpButton = document.getElementById('jump-to-bottom');
if (!messages || !jumpButton) {
return;
}
const threshold = 100;
isUserAtBottom = messages.scrollHeight - messages.scrollTop - messages.clientHeight < threshold;
if (isUserAtBottom) {
jumpButton.classList.remove('visible');
unreadCount = 0;
updateUnreadBadge();
} else {
jumpButton.classList.add('visible');
}
}
function replyToMessage(id, username) {
replyToId = id;
const replyIndicator = document.getElementById('reply-indicator');
const replyUsernameSpan = document.getElementById('reply-username');
if (!replyIndicator || !replyUsernameSpan) {
return;
}
replyUsernameSpan.textContent = `Replying to ${username}`;
replyIndicator.style.display = 'flex';
const input = document.getElementById('chat-input-text');
if (input) {
input.focus();
}
}
function cancelReply() {
replyToId = -1;
const replyIndicator = document.getElementById('reply-indicator');
if (replyIndicator) {
replyIndicator.style.display = 'none';
}
}
function scrollToMessage(id) {
const msgElement = document.getElementById('msg-' + id);
if (msgElement) {
msgElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
msgElement.style.transition = 'background-color 0.5s';
msgElement.style.backgroundColor = '#f582ae';
setTimeout(() => {
msgElement.style.backgroundColor = '';
}, 1000);
}
}
function showAutocompletePopup(suggestions, x, y) {
const popup = document.getElementById('autocomplete-popup');
if (!popup) {
return;
}
popup.innerHTML = '';
popup.style.position = 'fixed';
popup.style.left = x + 'px';
popup.style.top = y + 'px';
popup.style.display = 'block';
autocompleteActive = true;
suggestions.forEach(username => {
const item = document.createElement('div');
item.className = 'autocomplete-item';
item.textContent = username;
item.onmousedown = (e) => {
e.preventDefault();
completeMention(username);
popup.style.display = 'none';
autocompleteActive = false;
};
popup.appendChild(item);
});
}
function completeMention(username) {
const input = document.getElementById('chat-input-text');
if (!input) {
return;
}
const text = input.value;
const caretPos = input.selectionStart;
const textBeforeCaret = text.substring(0, caretPos);
const atIndex = textBeforeCaret.lastIndexOf('@');
if (atIndex !== -1) {
const prefix = text.substring(0, atIndex);
const suffix = text.substring(caretPos);
input.value = prefix + '@' + username + ' ' + suffix;
const newCaretPos = prefix.length + 1 + username.length + 1;
input.focus();
input.setSelectionRange(newCaretPos, newCaretPos);
}
}
function getCaretCoordinates(element, position) {
const mirrorDivId = 'input-mirror-div';
const div = document.createElement('div');
div.id = mirrorDivId;
document.body.appendChild(div);
const style = window.getComputedStyle(element);
const properties = ['border', 'boxSizing', 'fontFamily', 'fontSize', 'fontWeight', 'letterSpacing', 'lineHeight', 'padding', 'textDecoration', 'textIndent', 'textTransform', 'whiteSpace', 'wordSpacing', 'wordWrap', 'width'];
properties.forEach(prop => {
div.style[prop] = style[prop];
});
div.style.position = 'absolute';
div.style.top = '-9999px';
div.style.left = '0px';
div.textContent = element.value.substring(0, position);
const span = document.createElement('span');
span.textContent = element.value.substring(position) || '.';
div.appendChild(span);
const coords = { top: span.offsetTop, left: span.offsetLeft };
document.body.removeChild(div);
return coords;
}
function applyGrouping() {
const container = document.getElementById('chat-messages');
if (!container) {
return;
}
const msgs = Array.from(container.querySelectorAll('.chat-message')).filter(el => el.id.startsWith('msg-'));
for (let i = 0; i < msgs.length; i++) {
const curr = msgs[i];
curr.classList.remove('grouped');
if (i === 0) {
continue;
}
const prev = msgs[i - 1];
const currUser = curr.dataset.user;
const prevUser = prev.dataset.user;
const currReply = parseInt(curr.dataset.reply, 10) || -1;
if (currUser === prevUser && (currReply <= 0)) {
curr.classList.add('grouped');
}
}
}
function handleAutocompleteInput(e) {
const input = e.target;
const text = input.value;
const caretPos = input.selectionStart;
const popup = document.getElementById('autocomplete-popup');
if (!popup) {
return;
}
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;
}
}
popup.style.display = 'none';
autocompleteActive = false;
}
function handleAutocompleteBlur() {
setTimeout(() => {
if (!document.querySelector('.autocomplete-popup:hover')) {
const popup = document.getElementById('autocomplete-popup');
if (popup) {
popup.style.display = 'none';
}
autocompleteActive = false;
}
}, 150);
}
function handleMessageKeydown(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
}
function setupDraftAutoSave(chatInput) {
if (!chatInput) {
return;
}
const draftKey = `draft_chat_${boardId}`;
let draftSaveTimeout;
const existingDraft = loadDraft(draftKey);
const draftTimestamp = getDraftTimestamp(draftKey);
if (existingDraft && draftTimestamp) {
const sevenDaysAgo = Date.now() - (7 * 24 * 60 * 60 * 1000);
if (draftTimestamp > sevenDaysAgo) {
const indicator = showDraftIndicator(
existingDraft,
draftTimestamp,
(content) => {
chatInput.value = content;
chatInput.focus();
},
() => {
clearDraft(draftKey);
}
);
const chatInputContainer = document.querySelector('.chat-input');
if (chatInputContainer && chatInputContainer.parentNode) {
chatInputContainer.parentNode.insertBefore(indicator, chatInputContainer);
}
} else {
clearDraft(draftKey);
}
}
chatInput.addEventListener('input', () => {
clearTimeout(draftSaveTimeout);
draftSaveTimeout = setTimeout(() => {
const content = chatInput.value.trim();
if (content) {
saveDraft(draftKey, content);
} else {
clearDraft(draftKey);
}
}, 2000);
});
}
function setupTypingIndicator(chatInput) {
if (!chatInput) {
return;
}
let lastTypingTime = 0;
chatInput.addEventListener('input', () => {
const now = Date.now();
if (now - lastTypingTime > 2000) {
sendTypingIndicator();
lastTypingTime = now;
}
});
}
function setupSendMessageDraftClear(chatInput) {
if (!chatInput) {
return;
}
const draftKey = `draft_chat_${boardId}`;
const originalSendMessage = window.sendMessage;
window.sendMessage = function() {
const content = chatInput.value.trim();
if (content !== '') {
clearDraft(draftKey);
}
if (typeof originalSendMessage === 'function') {
originalSendMessage();
} else {
sendMessage();
}
};
}
function initChat() {
connectWebSocket();
const messagesContainer = document.getElementById('chat-messages');
if (messagesContainer) {
messagesContainer.scrollTop = messagesContainer.scrollHeight;
applyGrouping();
messagesContainer.addEventListener('scroll', checkScrollPosition);
}
const jumpButton = document.getElementById('jump-to-bottom');
if (jumpButton) {
jumpButton.addEventListener('click', jumpToBottom);
}
const chatInput = document.getElementById('chat-input-text');
if (chatInput) {
chatInput.addEventListener('input', handleAutocompleteInput);
chatInput.addEventListener('blur', handleAutocompleteBlur);
chatInput.addEventListener('keydown', handleMessageKeydown);
}
setupTypingIndicator(chatInput);
setupDraftAutoSave(chatInput);
setupSendMessageDraftClear(chatInput);
checkScrollPosition();
}
window.sendMessage = sendMessage;
window.replyToMessage = replyToMessage;
window.cancelReply = cancelReply;
window.scrollToMessage = scrollToMessage;
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initChat);
} else {
initChat();
}
})();

78
static/drafts.js Normal file
View File

@ -0,0 +1,78 @@
function saveDraft(key, content) {
try {
localStorage.setItem(key, content);
localStorage.setItem(key + '_timestamp', Date.now().toString());
} catch (e) {
console.error('Failed to save draft:', e);
}
}
function loadDraft(key) {
try {
return localStorage.getItem(key);
} catch (e) {
console.error('Failed to load draft:', e);
return null;
}
}
function clearDraft(key) {
try {
localStorage.removeItem(key);
localStorage.removeItem(key + '_timestamp');
} catch (e) {
console.error('Failed to clear draft:', e);
}
}
function getDraftTimestamp(key) {
try {
const timestamp = localStorage.getItem(key + '_timestamp');
return timestamp ? parseInt(timestamp, 10) : null;
} catch (e) {
console.error('Failed to get draft timestamp:', e);
return null;
}
}
function formatTimeAgo(timestamp) {
const now = Date.now();
const diff = now - timestamp;
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) return `${days} day${days > 1 ? 's' : ''} ago`;
if (hours > 0) return `${hours} hour${hours > 1 ? 's' : ''} ago`;
if (minutes > 0) return `${minutes} minute${minutes > 1 ? 's' : ''} ago`;
return 'just now';
}
function showDraftIndicator(content, timestamp, onRestore, onDiscard) {
const indicator = document.createElement('div');
indicator.className = 'draft-indicator';
indicator.innerHTML = `
<div class="draft-indicator-content">
<span class="draft-indicator-icon">📝</span>
<span class="draft-indicator-text">Draft from ${formatTimeAgo(timestamp)}</span>
<button type="button" class="draft-indicator-button restore">Restore</button>
<button type="button" class="draft-indicator-button discard">Discard</button>
</div>
`;
const restoreBtn = indicator.querySelector('.restore');
const discardBtn = indicator.querySelector('.discard');
restoreBtn.addEventListener('click', () => {
onRestore(content);
indicator.remove();
});
discardBtn.addEventListener('click', () => {
onDiscard();
indicator.remove();
});
return indicator;
}

212
static/forms.js Normal file
View File

@ -0,0 +1,212 @@
function showNotification(message, type = 'info', duration = 3000) {
const notification = document.createElement('div');
notification.className = `notification ${type}`;
notification.textContent = message;
document.body.appendChild(notification);
setTimeout(() => {
notification.classList.add('hiding');
setTimeout(() => {
document.body.removeChild(notification);
}, 300);
}, duration);
}
function handleFormSubmit(form, button) {
if (button) {
button.disabled = true;
button.classList.add('loading');
}
const submitButtons = form.querySelectorAll('input[type="submit"], button[type="submit"]');
submitButtons.forEach(btn => {
btn.disabled = true;
btn.classList.add('loading');
});
}
function removeLoadingState(form) {
const submitButtons = form.querySelectorAll('input[type="submit"], button[type="submit"]');
submitButtons.forEach(btn => {
btn.disabled = false;
btn.classList.remove('loading');
});
}
function enableEnterToSubmit(input, form) {
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
form.requestSubmit();
}
});
}
function autoResizeTextarea(textarea) {
textarea.style.height = 'auto';
textarea.style.height = textarea.scrollHeight + 'px';
}
function addCharacterCounter(textarea, maxLength) {
const counter = document.createElement('div');
counter.className = 'char-counter';
textarea.parentNode.insertBefore(counter, textarea.nextSibling);
const updateCounter = () => {
const length = textarea.value.length;
counter.textContent = `${length}/${maxLength}`;
if (length > maxLength * 0.9) {
counter.classList.add('warning');
} else {
counter.classList.remove('warning');
}
};
textarea.addEventListener('input', updateCounter);
updateCounter();
}
function initFormHandling() {
document.querySelectorAll('form').forEach(form => {
form.addEventListener('submit', () => {
const submitButton = form.querySelector('input[type="submit"], button[type="submit"]');
handleFormSubmit(form, submitButton);
});
});
document.querySelectorAll('textarea').forEach(textarea => {
textarea.addEventListener('input', () => autoResizeTextarea(textarea));
if (textarea.id === 'content' || textarea.name === 'content') {
addCharacterCounter(textarea, 10000);
}
if (textarea.id === 'bio' || textarea.name === 'bio') {
addCharacterCounter(textarea, 500);
}
});
const loginForm = document.querySelector('form[action*="login"]');
if (loginForm) {
const passwordInput = loginForm.querySelector('input[type="password"]');
if (passwordInput) {
enableEnterToSubmit(passwordInput, loginForm);
}
}
const loginUsername = document.querySelector('input[name="username"]');
const loginPassword = document.querySelector('input[name="password"]');
if (loginUsername && loginPassword) {
loginUsername.addEventListener('blur', () => {
const error = validateRequired(loginUsername.value, 'Username');
if (error) {
showFieldError(loginUsername, error);
} else {
clearFieldError(loginUsername);
}
});
loginPassword.addEventListener('blur', () => {
const error = validateRequired(loginPassword.value, 'Password');
if (error) {
showFieldError(loginPassword, error);
} else {
clearFieldError(loginPassword);
}
});
}
const signupForm = document.querySelector('form[action*="signup"]');
if (signupForm) {
const usernameInput = signupForm.querySelector('input[name="username"]');
const passwordInput = signupForm.querySelector('input[name="password"]');
const confirmInput = signupForm.querySelector('input[name="password_confirm"]');
if (usernameInput) {
usernameInput.addEventListener('blur', () => {
const error = validateUsername(usernameInput.value);
if (error) {
showFieldError(usernameInput, error);
} else {
clearFieldError(usernameInput);
}
});
}
if (passwordInput) {
passwordInput.addEventListener('blur', () => {
const error = validatePassword(passwordInput.value);
if (error) {
showFieldError(passwordInput, error);
} else {
clearFieldError(passwordInput);
}
});
}
if (confirmInput && passwordInput) {
confirmInput.addEventListener('blur', () => {
if (confirmInput.value !== passwordInput.value) {
showFieldError(confirmInput, 'Passwords do not match');
} else {
clearFieldError(confirmInput);
}
});
}
signupForm.addEventListener('submit', (e) => {
let hasError = false;
if (usernameInput) {
const error = validateUsername(usernameInput.value);
if (error) {
showFieldError(usernameInput, error);
hasError = true;
}
}
if (passwordInput) {
const error = validatePassword(passwordInput.value);
if (error) {
showFieldError(passwordInput, error);
hasError = true;
}
}
if (confirmInput && passwordInput && confirmInput.value !== passwordInput.value) {
showFieldError(confirmInput, 'Passwords do not match');
hasError = true;
}
if (hasError) {
e.preventDefault();
removeLoadingState(signupForm);
showNotification('Please fix the errors before submitting', 'error');
}
});
}
document.querySelectorAll('input[name="title"]').forEach(input => {
input.addEventListener('blur', () => {
const error = validateRequired(input.value, 'Title');
if (error) {
showFieldError(input, error);
} else if (input.value.length > 255) {
showFieldError(input, 'Title is too long (max 255 characters)');
} else {
clearFieldError(input);
}
});
});
document.querySelectorAll('textarea[name="content"]').forEach(textarea => {
textarea.addEventListener('blur', () => {
const error = validateRequired(textarea.value, 'Content');
if (error) {
showFieldError(textarea, error);
} else {
clearFieldError(textarea);
}
});
});
}

11
static/likes.js Normal file
View File

@ -0,0 +1,11 @@
function initLikeButtons() {
document.querySelectorAll('form[action*="/like/"]').forEach(form => {
form.addEventListener('submit', () => {
const button = form.querySelector('button[type="submit"]');
if (button) {
button.style.opacity = '0.5';
button.textContent = button.textContent + ' ✓';
}
});
});
}

25
static/shortcuts.js Normal file
View File

@ -0,0 +1,25 @@
function initKeyboardShortcuts() {
document.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
const activeElement = document.activeElement;
if (activeElement && activeElement.tagName === 'TEXTAREA') {
const form = activeElement.closest('form');
if (form) {
e.preventDefault();
form.requestSubmit();
}
}
}
if (e.key === 'Escape') {
if (document.activeElement && document.activeElement.tagName === 'TEXTAREA') {
document.activeElement.blur();
}
document.querySelectorAll('.notification').forEach(notif => {
notif.classList.add('hiding');
setTimeout(() => notif.remove(), 300);
});
}
});
}

File diff suppressed because it is too large Load Diff

51
static/validation.js Normal file
View File

@ -0,0 +1,51 @@
function validateRequired(value, fieldName) {
if (!value || value.trim() === '') {
return `${fieldName} is required`;
}
return null;
}
function validateUsername(username) {
if (!username || username.trim() === '') {
return 'Username is required';
}
if (username.length < 3) {
return 'Username must be at least 3 characters';
}
if (username.length > 50) {
return 'Username must be less than 50 characters';
}
if (!/^[a-zA-Z0-9_]+$/.test(username)) {
return 'Username can only contain letters, numbers, and underscores';
}
return null;
}
function validatePassword(password) {
if (!password || password.trim() === '') {
return 'Password is required';
}
if (password.length < 6) {
return 'Password must be at least 6 characters';
}
return null;
}
function showFieldError(field, error) {
field.classList.add('error');
let errorDiv = field.nextElementSibling;
if (!errorDiv || !errorDiv.classList.contains('field-error')) {
errorDiv = document.createElement('div');
errorDiv.className = 'field-error';
field.parentNode.insertBefore(errorDiv, field.nextSibling);
}
errorDiv.textContent = error;
}
function clearFieldError(field) {
field.classList.remove('error');
const errorDiv = field.nextElementSibling;
if (errorDiv && errorDiv.classList.contains('field-error')) {
errorDiv.remove();
}
}

View File

@ -4,11 +4,18 @@
<head>
<title>{{.Title}}</title>
<link rel="stylesheet" href="{{.StaticPath}}/style.css">
<script src="{{.StaticPath}}/validation.js" defer></script>
<script src="{{.StaticPath}}/drafts.js" defer></script>
<script src="{{.StaticPath}}/forms.js" defer></script>
<script src="{{.StaticPath}}/shortcuts.js" defer></script>
<script src="{{.StaticPath}}/likes.js" defer></script>
<script src="{{.StaticPath}}/app.js" defer></script>
<script src="{{.StaticPath}}/chat.js" defer></script>
</head>
<body>
<body{{if .BodyClass}} class="{{.BodyClass}}"{{end}}>
{{template "navbar" .}}
<main>
{{block "content" .}}{{end}} <!-- Define a block for content -->
{{template .ContentTemplate .}}
</main>
{{template "cookie_banner" .}}
</body>

View File

@ -4,6 +4,7 @@
<head>
<title>{{.Title}}</title>
<link rel="stylesheet" href="{{.StaticPath}}/style.css">
<script src="{{.StaticPath}}/app.js" defer></script>
</head>
<body>
{{template "navbar" .}}

View File

@ -4,10 +4,19 @@
<head>
<title>{{.Title}}</title>
<link rel="stylesheet" href="{{.StaticPath}}/style.css">
<script src="{{.StaticPath}}/app.js" defer></script>
</head>
<body>
{{template "navbar" .}}
<main>
<div class="breadcrumb">
<a href="{{.BasePath}}/">Home</a>
<span class="breadcrumb-separator"></span>
<a href="{{.BasePath}}/boards/">Boards</a>
<span class="breadcrumb-separator"></span>
<span class="breadcrumb-current">{{.Board.Name}}</span>
</div>
<a href="{{.BasePath}}/boards/" class="back-button">Back to Boards</a>
<header>
<h2>{{.Board.Name}}</h2>
<p>{{.Board.Description}}</p>
@ -15,9 +24,12 @@
<section>
<h3>Threads</h3>
{{if .Threads}}
<ul>
<ul class="thread-list">
{{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" data-timestamp="{{.UpdatedAt.Format "2006-01-02T15:04:05Z07:00"}}">Updated on {{.UpdatedAt.Format "02/01/2006 - 15:04"}}</p>
</li>
{{end}}
</ul>
{{else}}
@ -29,7 +41,7 @@
<h3>Create New Thread</h3>
<form method="post" action="{{.BasePath}}/board/?id={{.Board.ID}}&action=create_thread">
<label for="title">Thread Title:</label>
<input type="text" id="title" name="title" required><br>
<input type="text" id="title" name="title" required maxlength="255"><br>
<input type="submit" value="Create Thread">
</form>
</section>

View File

@ -4,6 +4,7 @@
<head>
<title>{{.Title}}</title>
<link rel="stylesheet" href="{{.StaticPath}}/style.css">
<script src="{{.StaticPath}}/app.js" defer></script>
</head>
<body>
{{template "navbar" .}}
@ -17,7 +18,11 @@
<ul class="board-list">
{{range .PublicBoards}}
<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>
{{end}}
<p class="board-desc">{{.Description}}</p>
</li>
{{end}}
@ -33,7 +38,11 @@
<ul class="board-list">
{{range .PrivateBoards}}
<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>
{{end}}
<p class="board-desc">{{.Description}}</p>
</li>
{{end}}
@ -48,9 +57,14 @@
<h3>Create New Public Board</h3>
<form method="post" action="{{.BasePath}}/boards/">
<label for="name">Board Name:</label>
<input type="text" id="name" name="name" required><br>
<input type="text" id="name" name="name" required maxlength="255"><br>
<label for="description">Description:</label>
<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">
</form>
</section>

View File

@ -1,435 +1,60 @@
{{define "chat"}}
<!DOCTYPE html>
<html>
<head>
<title>{{.Title}}</title>
<link rel="stylesheet" href="{{.StaticPath}}/style.css">
<style>
body {
margin: 0;
padding: 0;
height: 100vh;
overflow: hidden;
}
main {
padding: 0;
margin-top: 3em; /* Space for navbar */
height: calc(100vh - 3em);
display: flex;
flex-direction: column;
align-items: center;
}
.chat-container {
width: 100%;
height: calc(100% - 2em); /* Adjust for header */
display: flex;
flex-direction: column;
border: none;
border-radius: 0;
background-color: #fef6e4;
box-shadow: none;
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 8px;
display: flex;
flex-direction: column;
}
.chat-message {
margin-bottom: 8px;
max-width: 90%;
position: relative;
}
.chat-message-header {
display: flex;
align-items: center;
margin-bottom: 3px;
}
.chat-message-pfp {
width: 30px;
height: 30px;
border-radius: 50%;
margin-right: 8px;
}
.chat-message-username {
font-weight: bold;
color: #001858;
font-size: 0.9em;
}
.chat-message-timestamp {
font-size: 0.7em;
color: #666;
margin-left: 8px;
}
.chat-message-content {
background-color: #f3d2c1;
padding: 6px 10px;
border-radius: 5px;
line-height: 1.3;
font-size: 0.9em;
}
.chat-message-reply {
background-color: rgba(0,0,0,0.1);
padding: 4px 8px;
border-radius: 5px;
margin-bottom: 3px;
font-size: 0.8em;
cursor: pointer;
}
.chat-message-mention {
color: #f582ae;
font-weight: bold;
}
.chat-input {
padding: 8px;
border-top: 1px solid #001858;
display: flex;
flex-direction: column;
}
.chat-input textarea {
resize: none;
height: 50px;
margin-bottom: 8px;
font-size: 0.9em;
}
.chat-input button {
align-self: flex-end;
width: auto;
padding: 6px 12px;
font-size: 0.9em;
}
.post-actions {
position: absolute;
top: 5px;
right: 5px;
opacity: 0;
transition: opacity 0.2s ease;
}
.chat-message:hover .post-actions {
opacity: 1;
}
.post-actions a {
color: #001858;
text-decoration: none;
font-size: 0.8em;
padding: 2px 5px;
border: 1px solid #001858;
border-radius: 3px;
}
.post-actions a:hover {
background-color: #8bd3dd;
color: #fef6e4;
}
.autocomplete-popup {
position: absolute;
background-color: #fff;
border: 1px solid #001858;
border-radius: 5px;
max-height: 200px;
overflow-y: auto;
box-shadow: 0px 4px 8px rgba(0,0,0,0.2);
z-index: 1000;
display: none;
}
.autocomplete-item {
padding: 6px 10px;
cursor: pointer;
font-size: 0.9em;
}
.autocomplete-item:hover {
background-color: #f3d2c1;
}
.reply-indicator {
background-color: #001858;
color: #fef6e4;
padding: 5px 10px;
border-radius: 5px;
margin-bottom: 8px;
display: none;
align-items: center;
justify-content: space-between;
}
.reply-indicator span {
font-size: 0.9em;
}
.reply-indicator button {
background: none;
border: none;
color: #fef6e4;
cursor: pointer;
font-size: 0.9em;
padding: 0 5px;
margin: 0;
width: auto;
}
.reply-indicator button:hover {
background: none;
color: #f582ae;
}
@media (prefers-color-scheme: dark) {
.chat-container {
background-color: #444;
border-color: #fef6e4;
}
.chat-message-username {
color: #fef6e4;
}
.chat-message-timestamp {
color: #aaa;
}
.chat-message-content {
background-color: #555;
}
.chat-input {
border-color: #fef6e4;
}
.autocomplete-popup {
background-color: #444;
border-color: #fef6e4;
color: #fef6e4;
}
.autocomplete-item:hover {
background-color: #555;
}
.post-actions a {
color: #fef6e4;
border-color: #fef6e4;
}
.post-actions a:hover {
background-color: #8bd3dd;
color: #001858;
}
.reply-indicator {
background-color: #222;
color: #fef6e4;
}
.reply-indicator button {
color: #fef6e4;
}
.reply-indicator button:hover {
color: #f582ae;
}
}
</style>
</head>
<body>
{{template "navbar" .}}
<main>
<header style="display: none;">
<h2>General Chat</h2>
</header>
<div class="chat-container">
<div class="chat-messages" id="chat-messages">
{{range .Messages}}
<div class="chat-message" id="msg-{{.ID}}">
<div class="chat-message-header">
{{if .PfpURL}}
<img src="{{.PfpURL}}" alt="PFP" class="chat-message-pfp">
{{else}}
<div class="chat-message-pfp" style="background-color: #001858;"></div>
{{end}}
<span class="chat-message-username">{{.Username}}</span>
<span class="chat-message-timestamp">{{.Timestamp.Format "02/01/2006 15:04"}}</span>
</div>
{{if gt .ReplyTo 0}}
<div class="chat-message-reply" onclick="scrollToMessage({{.ReplyTo}})">Replying to {{.Username}}</div>
{{end}}
<div class="chat-message-content">{{.Content | html}}</div>
<div class="post-actions">
<a href="javascript:void(0)" onclick="replyToMessage({{.ID}}, '{{.Username}}')">Reply</a>
</div>
</div>
{{define "chat"}}{{template "base" .}}{{end}}
{{define "chat-content"}}
<div class="chat-container" data-board-id="{{.Board.ID}}" data-base-path="{{.BasePath}}" data-current-username="{{.CurrentUsername}}">
<div class="chat-breadcrumb">
<a href="{{.BasePath}}/">Home</a>
<span class="chat-breadcrumb-separator"></span>
<a href="{{.BasePath}}/boards/">Boards</a>
<span class="chat-breadcrumb-separator"></span>
<span>{{.Board.Name}}</span>
</div>
<header class="chat-header">
<div class="connection-status">
<div class="connection-dot connecting" id="connection-dot"></div>
<span id="connection-text">Connecting...</span>
</div>
<h2>{{.Board.Name}}</h2>
<p>{{.Board.Description}}</p>
</header>
<div class="chat-messages" id="chat-messages">
<div class="typing-indicator" id="typing-indicator">
<span id="typing-users"></span><span class="typing-dots"><span>.</span><span>.</span><span>.</span></span>
</div>
{{range .Messages}}
<div class="chat-message{{if .Mentions}}{{range .Mentions}}{{if eq . $.CurrentUsername}} chat-message-highlighted{{end}}{{end}}{{end}}" id="msg-{{.ID}}" data-user="{{.Username}}" data-reply="{{.ReplyTo}}">
<div class="chat-message-header">
{{if .PfpFileID.Valid}}
<img src="{{$.BasePath}}/file?id={{.PfpFileID.Int64}}" alt="PFP" class="chat-message-pfp">
{{else}}
<div class="chat-message-pfp" style="background-color: #001858;"></div>
{{end}}
<span class="chat-message-username">{{.Username}}</span>
<span class="chat-message-timestamp">{{.Timestamp.Format "02/01/2006 15:04"}}</span>
</div>
<div class="chat-input">
<div id="reply-indicator" class="reply-indicator">
<span id="reply-username">Replying to </span>
<button onclick="cancelReply()">X</button>
</div>
<textarea id="chat-input-text" placeholder="Type a message..."></textarea>
<button onclick="sendMessage()">Send</button>
{{if gt .ReplyTo 0}}
<div class="chat-message-reply" onclick="scrollToMessage({{.ReplyTo}})">Replying to message...</div>
{{end}}
<div class="chat-message-content">{{.Content}}</div>
<div class="post-actions">
<a href="javascript:void(0)" onclick="replyToMessage({{printf "%v" .ID}}, '{{.Username}}')">Reply</a>
</div>
</div>
<div id="autocomplete-popup" class="autocomplete-popup"></div>
</main>
{{template "cookie_banner" .}}
<script>
let ws;
let autocompleteActive = false;
let autocompletePrefix = '';
let replyToId = -1;
let replyUsername = '';
function connectWebSocket() {
ws = new WebSocket('ws://' + window.location.host + '{{.BasePath}}/chat/?ws=true', [], { credentials: 'include' });
ws.onmessage = function(event) {
const msg = JSON.parse(event.data);
appendMessage(msg);
};
ws.onclose = function() {
console.log("WebSocket closed, reconnecting...");
setTimeout(connectWebSocket, 5000); // Reconnect after 5s
};
ws.onerror = function(error) {
console.error("WebSocket error:", error);
};
}
function sendMessage() {
const input = document.getElementById('chat-input-text');
const content = input.value.trim();
if (content === '') return;
const msg = {
type: 'message',
content: content,
replyTo: replyToId
};
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(msg));
input.value = '';
cancelReply(); // Reset reply state after sending
} else {
console.error("WebSocket is not open. Current state:", ws ? ws.readyState : 'undefined');
}
}
function appendMessage(msg) {
const messages = document.getElementById('chat-messages');
const msgDiv = document.createElement('div');
msgDiv.className = 'chat-message';
msgDiv.id = 'msg-' + msg.ID;
let pfpHTML = msg.PfpURL ? `<img src="${msg.PfpURL}" alt="PFP" class="chat-message-pfp">` : `<div class="chat-message-pfp" style="background-color: #001858;"></div>`;
let replyHTML = msg.ReplyTo > 0 ? `<div class="chat-message-reply" onclick="scrollToMessage(${msg.ReplyTo})">Replying to ${msg.Username}</div>` : '';
// Process content for mentions
let content = msg.Content.replace(/@[\w]+/g, match => `<span class="chat-message-mention">${match}</span>`);
msgDiv.innerHTML = `
<div class="chat-message-header">
${pfpHTML}
<span class="chat-message-username">${msg.Username}</span>
<span class="chat-message-timestamp">${new Date(msg.Timestamp).toLocaleString()}</span>
</div>
${replyHTML}
<div class="chat-message-content">${content}</div>
<div class="post-actions">
<a href="javascript:void(0)" onclick="replyToMessage(${msg.ID}, '${msg.Username}')">Reply</a>
</div>
`;
messages.appendChild(msgDiv);
messages.scrollTop = messages.scrollHeight;
}
function replyToMessage(id, username) {
replyToId = id;
replyUsername = username;
const replyIndicator = document.getElementById('reply-indicator');
const replyUsernameSpan = document.getElementById('reply-username');
replyUsernameSpan.textContent = `Replying to ${username}`;
replyIndicator.style.display = 'flex';
document.getElementById('chat-input-text').focus();
}
function cancelReply() {
replyToId = -1;
replyUsername = '';
const replyIndicator = document.getElementById('reply-indicator');
replyIndicator.style.display = 'none';
}
function scrollToMessage(id) {
const msgElement = document.getElementById('msg-' + id);
if (msgElement) {
msgElement.scrollIntoView({ behavior: 'smooth' });
}
}
function showAutocompletePopup(usernames, x, y) {
const popup = document.getElementById('autocomplete-popup');
popup.innerHTML = '';
popup.style.left = x + 'px';
popup.style.top = y + 'px';
popup.style.display = 'block';
autocompleteActive = true;
usernames.forEach(username => {
const item = document.createElement('div');
item.className = 'autocomplete-item';
item.textContent = username;
item.onclick = () => {
completeMention(username);
popup.style.display = 'none';
autocompleteActive = false;
};
popup.appendChild(item);
});
}
function completeMention(username) {
const input = document.getElementById('chat-input-text');
const text = input.value;
const atIndex = text.lastIndexOf('@', input.selectionStart - 1);
if (atIndex !== -1) {
const before = text.substring(0, atIndex);
const after = text.substring(input.selectionStart);
input.value = before + username + (after.startsWith(' ') ? '' : ' ') + after;
input.focus();
}
}
document.getElementById('chat-input-text').addEventListener('input', async (e) => {
const text = e.target.value;
const caretPos = e.target.selectionStart;
const atIndex = text.lastIndexOf('@', caretPos - 1);
if (atIndex !== -1 && (caretPos === text.length || text[caretPos] === ' ')) {
const prefix = text.substring(atIndex + 1, caretPos);
autocompletePrefix = prefix;
const response = await fetch('{{.BasePath}}/chat/?autocomplete=true&prefix=' + encodeURIComponent(prefix));
const usernames = await response.json();
if (usernames.length > 0) {
const rect = e.target.getBoundingClientRect();
// Approximate caret position (this is a rough estimate)
const charWidth = 8; // Rough estimate of character width in pixels
const caretX = rect.left + (caretPos - text.lastIndexOf('\n', caretPos - 1) - 1) * charWidth;
showAutocompletePopup(usernames, caretX, rect.top - 10);
} else {
document.getElementById('autocomplete-popup').style.display = 'none';
autocompleteActive = false;
}
} else {
document.getElementById('autocomplete-popup').style.display = 'none';
autocompleteActive = false;
}
});
document.getElementById('chat-input-text').addEventListener('keydown', (e) => {
if (autocompleteActive) {
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();
}
});
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() {
connectWebSocket();
document.getElementById('chat-messages').scrollTop = document.getElementById('chat-messages').scrollHeight;
};
</script>
</body>
</html>
{{end}}
</div>
<button class="jump-to-bottom" id="jump-to-bottom" title="Jump to bottom" type="button">
<span class="unread-badge" id="unread-badge" style="display: none;">0</span>
</button>
<div class="chat-input">
<div id="reply-indicator" class="reply-indicator">
<span id="reply-username">Replying to </span>
<button onclick="cancelReply()" type="button">X</button>
</div>
<textarea id="chat-input-text" placeholder="Type a message..."></textarea>
<button onclick="sendMessage()" type="button">Send</button>
</div>
</div>
<div id="autocomplete-popup" class="autocomplete-popup"></div>
<script type="application/json" id="chat-usernames">{{.AllUsernames}}</script>
{{end}}

View File

@ -4,6 +4,7 @@
<head>
<title>{{.Title}}</title>
<link rel="stylesheet" href="{{.StaticPath}}/style.css">
<script src="{{.StaticPath}}/app.js" defer></script>
</head>
<body>
{{template "navbar" .}}

View File

@ -4,6 +4,7 @@
<head>
<title>{{.Title}}</title>
<link rel="stylesheet" href="{{.StaticPath}}/style.css">
<script src="{{.StaticPath}}/app.js" defer></script>
</head>
<body>
{{template "navbar" .}}
@ -13,13 +14,13 @@
</header>
<section>
{{if .Error}}
<p style="color: red;">{{.Error}}</p>
<p class="field-error" style="text-align: center; font-size: 1em;">{{.Error}}</p>
{{end}}
<form method="post" action="{{.BasePath}}/login/">
<label for="username">Username:</label>
<input type="text" id="username" name="username" required><br>
<input type="text" id="username" name="username" required autocomplete="username"><br>
<label for="password">Password:</label>
<input type="password" id="password" name="password" required><br>
<input type="password" id="password" name="password" required autocomplete="current-password"><br>
<input type="submit" value="Login">
</form>
</section>

View File

@ -4,6 +4,7 @@
<head>
<title>{{.Title}}</title>
<link rel="stylesheet" href="{{.StaticPath}}/style.css">
<script src="{{.StaticPath}}/app.js" defer></script>
</head>
<body>
{{template "navbar" .}}
@ -15,7 +16,7 @@
{{if .News}}
<ul>
{{range .News}}
<li><strong>{{.Title}}</strong> - Posted on {{.CreatedAt.Format "02/01/2006 - 15:04"}}
<li><strong>{{.Title}}</strong> - <span data-timestamp="{{.CreatedAt.Format "2006-01-02T15:04:05Z07:00"}}">Posted on {{.CreatedAt.Format "02/01/2006 - 15:04"}}</span>
<p>{{.Content}}</p>
{{if $.IsAdmin}}
<form method="post" action="{{$.BasePath}}/news/?action=delete&id={{.ID}}" style="display:inline;">
@ -34,7 +35,7 @@
<h3>Post New Announcement</h3>
<form method="post" action="{{.BasePath}}/news/">
<label for="title">Title:</label>
<input type="text" id="title" name="title" required><br>
<input type="text" id="title" name="title" required maxlength="255"><br>
<label for="content">Content:</label>
<textarea id="content" name="content" required></textarea><br>
<input type="submit" value="Post News">

View File

@ -0,0 +1,26 @@
{{define "preferences"}}{{template "base" .}}{{end}}
{{define "preferences-content"}}
<header>
<h2>Preferences</h2>
</header>
{{if .ShowSuccess}}
<div class="notification success" style="position: static; margin-bottom: 1em; animation: none;">
Preferences saved successfully!
</div>
{{end}}
<section>
<form method="post" action="{{.BasePath}}/preferences/">
<h3>Draft Auto-Save</h3>
<label for="auto_save_drafts" style="display: flex; align-items: center; gap: 0.5em; cursor: pointer;">
<input type="checkbox" id="auto_save_drafts" name="auto_save_drafts" {{if .Preferences.AutoSaveDrafts}}checked{{end}}>
<span>Automatically save drafts while typing in chat</span>
</label>
<p style="margin-left: 1.5em; margin-top: 0.25em; font-size: 0.9em; opacity: 0.8;">
Drafts are saved to your browser's local storage and restored when you return to chat.
</p>
<input type="submit" value="Save Preferences" style="margin-top: 2em;">
</form>
</section>
{{end}}

View File

@ -4,6 +4,7 @@
<head>
<title>{{.Title}}</title>
<link rel="stylesheet" href="{{.StaticPath}}/style.css">
<script src="{{.StaticPath}}/app.js" defer></script>
</head>
<body>
{{template "navbar" .}}
@ -14,12 +15,12 @@
<section>
<p>Username: {{.User.Username}}</p>
<p>Display Name: {{.DisplayName}}</p>
{{if .User.PfpURL}}
<img src="{{.User.PfpURL}}" alt="Profile Picture">
{{if .User.PfpFileID.Valid}}
<img src="{{.BasePath}}/file?id={{.User.PfpFileID.Int64}}" alt="Profile Picture">
{{end}}
<p>Bio: {{.User.Bio}}</p>
<p>Joined: {{.User.CreatedAt}}</p>
<p>Last Updated: {{.User.UpdatedAt}}</p>
<p data-timestamp="{{.User.CreatedAt.Format "2006-01-02T15:04:05Z07:00"}}">Joined: {{.User.CreatedAt}}</p>
<p data-timestamp="{{.User.UpdatedAt.Format "2006-01-02T15:04:05Z07:00"}}">Last Updated: {{.User.UpdatedAt}}</p>
<p>Verified: {{.User.Verified}}</p>
<a href="{{.BasePath}}/profile/edit/">Edit Profile</a>
</section>

View File

@ -4,6 +4,7 @@
<head>
<title>{{.Title}}</title>
<link rel="stylesheet" href="{{.StaticPath}}/style.css">
<script src="{{.StaticPath}}/app.js" defer></script>
</head>
<body>
{{template "navbar" .}}
@ -12,13 +13,13 @@
<h2>Edit Profile</h2>
</header>
<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>
<input type="text" id="display_name" name="display_name" value="{{.User.DisplayName}}"><br>
<label for="pfp_url">Profile Picture URL:</label>
<input type="text" id="pfp_url" name="pfp_url" value="{{.User.PfpURL}}"><br>
<input type="text" id="display_name" name="display_name" value="{{.User.DisplayName}}" maxlength="255"><br>
<label for="pfp">Profile Picture:</label>
<input type="file" id="pfp" name="pfp" accept="image/*"><br>
<label for="bio">Bio:</label>
<textarea id="bio" name="bio">{{.User.Bio}}</textarea><br>
<textarea id="bio" name="bio" maxlength="500">{{.User.Bio}}</textarea><br>
<input type="submit" value="Save">
</form>
</section>

View File

@ -4,6 +4,7 @@
<head>
<title>{{.Title}}</title>
<link rel="stylesheet" href="{{.StaticPath}}/style.css">
<script src="{{.StaticPath}}/app.js" defer></script>
</head>
<body>
{{template "navbar" .}}
@ -13,13 +14,15 @@
</header>
<section>
{{if .Error}}
<p style="color: red;">{{.Error}}</p>
<p class="field-error" style="text-align: center; font-size: 1em;">{{.Error}}</p>
{{end}}
<form method="post" action="{{.BasePath}}/signup/">
<label for="username">Username:</label>
<input type="text" id="username" name="username" required><br>
<input type="text" id="username" name="username" required autocomplete="username" minlength="3" maxlength="30"><br>
<label for="password">Password:</label>
<input type="password" id="password" name="password" required><br>
<input type="password" id="password" name="password" required autocomplete="new-password" minlength="8" maxlength="128"><br>
<label for="password_confirm">Confirm Password:</label>
<input type="password" id="password_confirm" name="password_confirm" required autocomplete="new-password" minlength="8" maxlength="128"><br>
<input type="submit" value="Sign Up">
</form>
</section>

View File

@ -4,10 +4,21 @@
<head>
<title>{{.Title}}</title>
<link rel="stylesheet" href="{{.StaticPath}}/style.css">
<script src="{{.StaticPath}}/app.js" defer></script>
</head>
<body>
{{template "navbar" .}}
<main>
<div class="breadcrumb">
<a href="{{.BasePath}}/">Home</a>
<span class="breadcrumb-separator"></span>
<a href="{{.BasePath}}/boards/">Boards</a>
<span class="breadcrumb-separator"></span>
<a href="{{.BasePath}}/board/?id={{.Board.ID}}">{{.Board.Name}}</a>
<span class="breadcrumb-separator"></span>
<span class="breadcrumb-current">{{.Thread.Title}}</span>
</div>
<a href="{{.BasePath}}/board/?id={{.Board.ID}}" class="back-button">Back to {{.Board.Name}}</a>
<header>
<h2>{{.Thread.Title}}</h2>
</header>
@ -16,7 +27,7 @@
<article id="{{.ID}}" class="post-item" style="margin-left: {{if gt .ReplyTo 0}}20px{{else}}0px{{end}};">
<header>
<h3>{{if .Title}}{{.Title}}{{else}}Post #{{.ID}}{{end}}</h3>
<p>Posted on {{.PostTime.Format "02/01/2006 - 15:04"}}</p>
<p data-timestamp="{{.PostTime.Format "2006-01-02T15:04:05Z07:00"}}">Posted on {{.PostTime.Format "02/01/2006 - 15:04"}}</p>
{{if gt .ReplyTo 0}}
<p>Reply to post <a href="#{{.ReplyTo}}">{{.ReplyTo}}</a></p>
{{end}}

View File

@ -4,6 +4,7 @@
<head>
<title>{{.Title}}</title>
<link rel="stylesheet" href="{{.StaticPath}}/style.css">
<script src="{{.StaticPath}}/app.js" defer></script>
</head>
<body>
{{template "navbar" .}}

View File

@ -4,7 +4,7 @@
{{if .LoggedIn}}
<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 "chat"}}class="active"{{end}} href="{{.BasePath}}/chat/">Chat</a></li>
<li><a {{if eq .Navbar "preferences"}}class="active"{{end}} href="{{.BasePath}}/preferences/">Preferences</a></li>
<li><a href="{{.BasePath}}/logout/">Logout</a></li>
{{else}}
<li><a {{if eq .Navbar "login"}}class="active"{{end}} href="{{.BasePath}}/login/">Login</a></li>