All: Enhance Chat Mentions, Fix Threads CSS, Migrate Boards, and Add File-Based Avatar System #69
|
|
@ -1,5 +1,10 @@
|
||||||
config/config.json
|
config/config.json
|
||||||
config/about_page.htmlbody
|
config/about_page.htmlbody
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
files/
|
||||||
|
|
||||||
# nano
|
# nano
|
||||||
.swp
|
.swp
|
||||||
|
threadr
|
||||||
|
*.todo
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -4,5 +4,8 @@
|
||||||
"db_username": "threadr_user",
|
"db_username": "threadr_user",
|
||||||
"db_password": "threadr_password",
|
"db_password": "threadr_password",
|
||||||
"db_database": "threadr_db",
|
"db_database": "threadr_db",
|
||||||
"db_svr_host": "localhost:3306"
|
"db_svr_host": "localhost:3306",
|
||||||
|
"file_storage_dir": "files",
|
||||||
|
"session_secret": "change-me-to-32-byte-random",
|
||||||
|
"session_secure": false
|
||||||
}
|
}
|
||||||
|
|
|
||||||
5
go.mod
5
go.mod
|
|
@ -6,11 +6,12 @@ require (
|
||||||
github.com/go-sql-driver/mysql v1.9.0
|
github.com/go-sql-driver/mysql v1.9.0
|
||||||
github.com/gorilla/sessions v1.4.0
|
github.com/gorilla/sessions v1.4.0
|
||||||
github.com/gorilla/websocket v1.5.0
|
github.com/gorilla/websocket v1.5.0
|
||||||
|
golang.org/x/crypto v0.45.0
|
||||||
|
golang.org/x/term v0.37.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
filippo.io/edwards25519 v1.1.0 // indirect
|
filippo.io/edwards25519 v1.1.0 // indirect
|
||||||
github.com/gorilla/securecookie v1.1.2 // indirect
|
github.com/gorilla/securecookie v1.1.2 // indirect
|
||||||
golang.org/x/sys v0.33.0 // indirect
|
golang.org/x/sys v0.38.0 // indirect
|
||||||
golang.org/x/term v0.32.0 // indirect
|
|
||||||
)
|
)
|
||||||
|
|
|
||||||
10
go.sum
10
go.sum
|
|
@ -10,7 +10,9 @@ github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzq
|
||||||
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
|
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
|
||||||
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
|
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
|
||||||
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
||||||
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
|
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||||
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
|
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
|
||||||
|
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
|
||||||
|
|
|
||||||
138
handlers/app.go
138
handlers/app.go
|
|
@ -1,78 +1,98 @@
|
||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"html/template"
|
"html/template"
|
||||||
"net/http"
|
"log"
|
||||||
"github.com/gorilla/sessions"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gorilla/sessions"
|
||||||
)
|
)
|
||||||
|
|
||||||
type PageData struct {
|
type PageData struct {
|
||||||
Title string
|
Title string
|
||||||
Navbar string
|
Navbar string
|
||||||
LoggedIn bool
|
LoggedIn bool
|
||||||
ShowCookieBanner bool
|
ShowCookieBanner bool
|
||||||
BasePath string
|
BasePath string
|
||||||
StaticPath string
|
StaticPath string
|
||||||
CurrentURL string
|
CurrentURL string
|
||||||
|
ContentTemplate string
|
||||||
|
BodyClass string
|
||||||
}
|
}
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
DomainName string `json:"domain_name"`
|
DomainName string `json:"domain_name"`
|
||||||
ThreadrDir string `json:"threadr_dir"`
|
ThreadrDir string `json:"threadr_dir"`
|
||||||
DBUsername string `json:"db_username"`
|
DBUsername string `json:"db_username"`
|
||||||
DBPassword string `json:"db_password"`
|
DBPassword string `json:"db_password"`
|
||||||
DBDatabase string `json:"db_database"`
|
DBDatabase string `json:"db_database"`
|
||||||
DBServerHost string `json:"db_svr_host"`
|
DBServerHost string `json:"db_svr_host"`
|
||||||
|
FileStorageDir string `json:"file_storage_dir"`
|
||||||
|
SessionSecret string `json:"session_secret"`
|
||||||
|
SessionSecure bool `json:"session_secure"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type App struct {
|
type App struct {
|
||||||
DB *sql.DB
|
DB *sql.DB
|
||||||
Store *sessions.CookieStore
|
Store *sessions.CookieStore
|
||||||
Config *Config
|
Config *Config
|
||||||
Tmpl *template.Template
|
Tmpl *template.Template
|
||||||
}
|
}
|
||||||
|
|
||||||
func (app *App) SessionMW(next http.HandlerFunc) http.HandlerFunc {
|
func (app *App) SessionMW(next http.HandlerFunc) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
session, err := app.Store.Get(r, "session-name")
|
session, err := app.Store.Get(r, "session-name")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
session = sessions.NewSession(app.Store, "session-name")
|
session = sessions.NewSession(app.Store, "session-name")
|
||||||
session.Options = &sessions.Options{
|
}
|
||||||
Path: "/",
|
|
||||||
MaxAge: 86400 * 30, // 30 days
|
// Enforce secure cookie options on every request.
|
||||||
HttpOnly: true,
|
session.Options = app.cookieOptions(r)
|
||||||
}
|
|
||||||
}
|
ctx := context.WithValue(r.Context(), "session", session)
|
||||||
if _, ok := session.Values["user_id"].(int); ok {
|
r = r.WithContext(ctx)
|
||||||
// Skip IP and User-Agent check for WebSocket connections
|
|
||||||
if r.URL.Query().Get("ws") != "true" {
|
next(w, r)
|
||||||
if session.Values["user_ip"] != r.RemoteAddr || session.Values["user_agent"] != r.UserAgent() {
|
|
||||||
session.Values = make(map[interface{}]interface{})
|
if err := session.Save(r, w); err != nil {
|
||||||
session.Options.MaxAge = -1
|
/*
|
||||||
session.Save(r, w)
|
Ok, so here's the thing
|
||||||
http.Redirect(w, r, app.Config.ThreadrDir+"/login/?error=session", http.StatusFound)
|
Errors coming from this function here "can" be ignored.
|
||||||
return
|
They mostly come from errors while setting cookies, so in some
|
||||||
}
|
environments this will trigger a lot, but they are harmless.
|
||||||
}
|
*/
|
||||||
ctx := context.WithValue(r.Context(), "session", session)
|
log.Printf("Error saving session in SessionMW: %v", err)
|
||||||
r = r.WithContext(ctx)
|
}
|
||||||
} else {
|
}
|
||||||
ctx := context.WithValue(r.Context(), "session", session)
|
|
||||||
r = r.WithContext(ctx)
|
|
||||||
}
|
|
||||||
next(w, r)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (app *App) RequireLoginMW(next http.HandlerFunc) http.HandlerFunc {
|
func (app *App) RequireLoginMW(next http.HandlerFunc) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
session := r.Context().Value("session").(*sessions.Session)
|
session := r.Context().Value("session").(*sessions.Session)
|
||||||
if _, ok := session.Values["user_id"].(int); !ok {
|
if _, ok := session.Values["user_id"].(int); !ok {
|
||||||
http.Redirect(w, r, app.Config.ThreadrDir+"/login/?error=session", http.StatusFound)
|
http.Redirect(w, r, app.Config.ThreadrDir+"/login/?error=session", http.StatusFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
next(w, r)
|
next(w, r)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) cookieOptions(r *http.Request) *sessions.Options {
|
||||||
|
secure := app.Config.SessionSecure
|
||||||
|
if r.TLS != nil {
|
||||||
|
secure = true
|
||||||
|
} // I dunno what I am doing honestly
|
||||||
|
options := &sessions.Options{
|
||||||
|
Path: app.Config.ThreadrDir + "/",
|
||||||
|
MaxAge: 86400 * 30,
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: secure,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
}
|
||||||
|
if app.Config.DomainName != "" {
|
||||||
|
options.Domain = app.Config.DomainName
|
||||||
|
}
|
||||||
|
return options
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,11 @@ func BoardHandler(app *App) http.HandlerFunc {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if board.Type == "chat" {
|
||||||
|
http.Redirect(w, r, app.Config.ThreadrDir+"/chat/?id="+boardIDStr, http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if board.Private {
|
if board.Private {
|
||||||
if !loggedIn {
|
if !loggedIn {
|
||||||
http.Redirect(w, r, app.Config.ThreadrDir+"/login/", http.StatusFound)
|
http.Redirect(w, r, app.Config.ThreadrDir+"/login/", http.StatusFound)
|
||||||
|
|
|
||||||
|
|
@ -28,25 +28,39 @@ func BoardsHandler(app *App) http.HandlerFunc {
|
||||||
if r.Method == http.MethodPost && loggedIn && isAdmin {
|
if r.Method == http.MethodPost && loggedIn && isAdmin {
|
||||||
name := r.FormValue("name")
|
name := r.FormValue("name")
|
||||||
description := r.FormValue("description")
|
description := r.FormValue("description")
|
||||||
|
boardType := r.FormValue("type")
|
||||||
|
|
||||||
if name == "" {
|
if name == "" {
|
||||||
http.Error(w, "Board name is required", http.StatusBadRequest)
|
http.Error(w, "Board name is required", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if boardType != "classic" && boardType != "chat" {
|
||||||
|
boardType = "classic"
|
||||||
|
}
|
||||||
|
|
||||||
board := models.Board{
|
board := models.Board{
|
||||||
Name: name,
|
Name: name,
|
||||||
Description: description,
|
Description: description,
|
||||||
Private: false,
|
Private: false,
|
||||||
PublicVisible: true,
|
PublicVisible: true,
|
||||||
|
Type: boardType,
|
||||||
}
|
}
|
||||||
query := "INSERT INTO boards (name, description, private, public_visible) VALUES (?, ?, ?, ?)"
|
query := "INSERT INTO boards (name, description, private, public_visible, type) VALUES (?, ?, ?, ?, ?)"
|
||||||
result, err := app.DB.Exec(query, board.Name, board.Description, board.Private, board.PublicVisible)
|
result, err := app.DB.Exec(query, board.Name, board.Description, board.Private, board.PublicVisible, board.Type)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Error creating board: %v", err)
|
log.Printf("Error creating board: %v", err)
|
||||||
http.Error(w, "Failed to create board", http.StatusInternalServerError)
|
http.Error(w, "Failed to create board", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
boardID, _ := result.LastInsertId()
|
boardID, _ := result.LastInsertId()
|
||||||
http.Redirect(w, r, app.Config.ThreadrDir+"/board/?id="+strconv.FormatInt(boardID, 10), http.StatusFound)
|
|
||||||
|
var redirectURL string
|
||||||
|
if boardType == "chat" {
|
||||||
|
redirectURL = app.Config.ThreadrDir + "/chat/?id=" + strconv.FormatInt(boardID, 10)
|
||||||
|
} else {
|
||||||
|
redirectURL = app.Config.ThreadrDir + "/board/?id=" + strconv.FormatInt(boardID, 10)
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, redirectURL, http.StatusFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
193
handlers/chat.go
193
handlers/chat.go
|
|
@ -2,10 +2,13 @@ package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"html/template"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"sync"
|
"sync"
|
||||||
"threadr/models"
|
"threadr/models"
|
||||||
|
|
||||||
"github.com/gorilla/sessions"
|
"github.com/gorilla/sessions"
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
)
|
)
|
||||||
|
|
@ -18,21 +21,26 @@ var upgrader = websocket.Upgrader{
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// ChatHub manages WebSocket connections and broadcasts messages
|
type Client struct {
|
||||||
|
conn *websocket.Conn
|
||||||
|
userID int
|
||||||
|
boardID int
|
||||||
|
}
|
||||||
|
|
||||||
type ChatHub struct {
|
type ChatHub struct {
|
||||||
clients map[*websocket.Conn]int // Map of connections to user IDs
|
clients map[*Client]bool
|
||||||
broadcast chan []byte
|
broadcast chan models.ChatMessage
|
||||||
register chan *websocket.Conn
|
register chan *Client
|
||||||
unregister chan *websocket.Conn
|
unregister chan *Client
|
||||||
mutex sync.Mutex
|
mutex sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewChatHub() *ChatHub {
|
func NewChatHub() *ChatHub {
|
||||||
return &ChatHub{
|
return &ChatHub{
|
||||||
clients: make(map[*websocket.Conn]int),
|
clients: make(map[*Client]bool),
|
||||||
broadcast: make(chan []byte),
|
broadcast: make(chan models.ChatMessage),
|
||||||
register: make(chan *websocket.Conn),
|
register: make(chan *Client),
|
||||||
unregister: make(chan *websocket.Conn),
|
unregister: make(chan *Client),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -41,21 +49,26 @@ func (h *ChatHub) Run() {
|
||||||
select {
|
select {
|
||||||
case client := <-h.register:
|
case client := <-h.register:
|
||||||
h.mutex.Lock()
|
h.mutex.Lock()
|
||||||
h.clients[client] = 0 // UserID set later
|
h.clients[client] = true
|
||||||
h.mutex.Unlock()
|
h.mutex.Unlock()
|
||||||
case client := <-h.unregister:
|
case client := <-h.unregister:
|
||||||
h.mutex.Lock()
|
h.mutex.Lock()
|
||||||
delete(h.clients, client)
|
if _, ok := h.clients[client]; ok {
|
||||||
|
delete(h.clients, client)
|
||||||
|
client.conn.Close()
|
||||||
|
}
|
||||||
h.mutex.Unlock()
|
h.mutex.Unlock()
|
||||||
client.Close()
|
|
||||||
case message := <-h.broadcast:
|
case message := <-h.broadcast:
|
||||||
h.mutex.Lock()
|
h.mutex.Lock()
|
||||||
for client := range h.clients {
|
for client := range h.clients {
|
||||||
err := client.WriteMessage(websocket.TextMessage, message)
|
if client.boardID == message.BoardID {
|
||||||
if err != nil {
|
response, _ := json.Marshal(message)
|
||||||
log.Printf("Error broadcasting message: %v", err)
|
err := client.conn.WriteMessage(websocket.TextMessage, response)
|
||||||
client.Close()
|
if err != nil {
|
||||||
delete(h.clients, client)
|
log.Printf("Error broadcasting message: %v", err)
|
||||||
|
client.conn.Close()
|
||||||
|
delete(h.clients, client)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
h.mutex.Unlock()
|
h.mutex.Unlock()
|
||||||
|
|
@ -69,6 +82,13 @@ func init() {
|
||||||
go hub.Run()
|
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 {
|
func ChatHandler(app *App) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
session := r.Context().Value("session").(*sessions.Session)
|
session := r.Context().Value("session").(*sessions.Session)
|
||||||
|
|
@ -79,20 +99,64 @@ func ChatHandler(app *App) http.HandlerFunc {
|
||||||
}
|
}
|
||||||
cookie, _ := r.Cookie("threadr_cookie_banner")
|
cookie, _ := r.Cookie("threadr_cookie_banner")
|
||||||
|
|
||||||
|
boardIDStr := r.URL.Query().Get("id")
|
||||||
|
boardID, err := strconv.Atoi(boardIDStr)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid board ID", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
board, err := models.GetBoardByID(app.DB, boardID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error fetching board: %v", err)
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if board == nil {
|
||||||
|
http.Error(w, "Chat board not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if board.Type != "chat" {
|
||||||
|
http.Error(w, "This is not a chat board", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if board.Private {
|
||||||
|
hasPerm, err := models.HasBoardPermission(app.DB, userID, boardID, models.PermViewBoard)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error checking permission: %v", err)
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !hasPerm {
|
||||||
|
http.Error(w, "You do not have permission to view this chat", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
currentUser, err := models.GetUserByID(app.DB, userID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error fetching current user: %v", err)
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if currentUser == nil {
|
||||||
|
http.Error(w, "User not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
currentUsername := currentUser.Username
|
||||||
|
|
||||||
if r.URL.Query().Get("ws") == "true" {
|
if r.URL.Query().Get("ws") == "true" {
|
||||||
// Handle WebSocket connection
|
|
||||||
ws, err := upgrader.Upgrade(w, r, nil)
|
ws, err := upgrader.Upgrade(w, r, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Error upgrading to WebSocket: %v", err)
|
log.Printf("Error upgrading to WebSocket: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
hub.register <- ws
|
client := &Client{conn: ws, userID: userID, boardID: boardID}
|
||||||
hub.mutex.Lock()
|
hub.register <- client
|
||||||
hub.clients[ws] = userID
|
|
||||||
hub.mutex.Unlock()
|
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
hub.unregister <- ws
|
hub.unregister <- client
|
||||||
}()
|
}()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
|
|
@ -101,83 +165,86 @@ func ChatHandler(app *App) http.HandlerFunc {
|
||||||
log.Printf("Error reading WebSocket message: %v", err)
|
log.Printf("Error reading WebSocket message: %v", err)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
var chatMsg struct {
|
var chatMsg IncomingChatMessage
|
||||||
Type string `json:"type"`
|
|
||||||
Content string `json:"content"`
|
|
||||||
ReplyTo int `json:"replyTo"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(msg, &chatMsg); err != nil {
|
if err := json.Unmarshal(msg, &chatMsg); err != nil {
|
||||||
log.Printf("Error unmarshaling message: %v", err)
|
log.Printf("Error unmarshaling message: %v", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if chatMsg.Type == "message" {
|
if chatMsg.Type == "typing" {
|
||||||
msgObj := models.ChatMessage{
|
// Broadcast typing indicator to other clients
|
||||||
UserID: userID,
|
typingMsg := map[string]interface{}{
|
||||||
Content: chatMsg.Content,
|
"type": "typing",
|
||||||
ReplyTo: chatMsg.ReplyTo,
|
"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)
|
log.Printf("Error saving chat message: %v", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// Fetch the saved message with timestamp and user details
|
|
||||||
var msgID int
|
var msgID int
|
||||||
app.DB.QueryRow("SELECT LAST_INSERT_ID()").Scan(&msgID)
|
err = app.DB.QueryRow("SELECT LAST_INSERT_ID()").Scan(&msgID)
|
||||||
savedMsg, err := models.GetChatMessageByID(app.DB, msgID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Error fetching saved message: %v", err)
|
log.Printf("Error getting last insert id: %v", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
response, _ := json.Marshal(savedMsg)
|
savedMsg, err := models.GetChatMessageByID(app.DB, msgID)
|
||||||
hub.broadcast <- response
|
if err != nil {
|
||||||
|
log.Printf("Error fetching saved message for broadcast: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
hub.broadcast <- *savedMsg
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if r.URL.Query().Get("autocomplete") == "true" {
|
messages, err := models.GetRecentChatMessages(app.DB, boardID, 50)
|
||||||
// Handle autocomplete for mentions
|
|
||||||
prefix := r.URL.Query().Get("prefix")
|
|
||||||
usernames, err := models.GetUsernamesMatching(app.DB, prefix)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Error fetching usernames for autocomplete: %v", err)
|
|
||||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response, _ := json.Marshal(usernames)
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.Write(response)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render chat page
|
|
||||||
messages, err := models.GetRecentChatMessages(app.DB, 50)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Error fetching chat messages: %v", err)
|
log.Printf("Error fetching chat messages: %v", err)
|
||||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reverse messages to show oldest first
|
|
||||||
for i, j := 0, len(messages)-1; i < j; i, j = i+1, j-1 {
|
for i, j := 0, len(messages)-1; i < j; i, j = i+1, j-1 {
|
||||||
messages[i], messages[j] = messages[j], messages[i]
|
messages[i], messages[j] = messages[j], messages[i]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
allUsernames, err := models.GetUsernamesInBoard(app.DB, boardID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error fetching usernames for board: %v", err)
|
||||||
|
allUsernames = []string{}
|
||||||
|
}
|
||||||
|
allUsernamesJSON, _ := json.Marshal(allUsernames)
|
||||||
|
|
||||||
data := struct {
|
data := struct {
|
||||||
PageData
|
PageData
|
||||||
Messages []models.ChatMessage
|
Board models.Board
|
||||||
|
Messages []models.ChatMessage
|
||||||
|
AllUsernames template.JS
|
||||||
|
CurrentUsername string
|
||||||
}{
|
}{
|
||||||
PageData: PageData{
|
PageData: PageData{
|
||||||
Title: "ThreadR - Chat",
|
Title: "ThreadR Chat - " + board.Name,
|
||||||
Navbar: "chat",
|
Navbar: "boards",
|
||||||
LoggedIn: true,
|
LoggedIn: true,
|
||||||
ShowCookieBanner: cookie == nil || cookie.Value != "accepted",
|
ShowCookieBanner: cookie == nil || cookie.Value != "accepted",
|
||||||
BasePath: app.Config.ThreadrDir,
|
BasePath: app.Config.ThreadrDir,
|
||||||
StaticPath: app.Config.ThreadrDir + "/static",
|
StaticPath: app.Config.ThreadrDir + "/static",
|
||||||
CurrentURL: r.URL.Path,
|
CurrentURL: r.URL.Path,
|
||||||
|
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 {
|
if err := app.Tmpl.ExecuteTemplate(w, "chat", data); err != nil {
|
||||||
log.Printf("Error executing template in ChatHandler: %v", err)
|
log.Printf("Error executing template in ChatHandler: %v", err)
|
||||||
|
|
@ -185,4 +252,4 @@ func ChatHandler(app *App) http.HandlerFunc {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,68 +1,69 @@
|
||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"threadr/models"
|
"threadr/models"
|
||||||
"github.com/gorilla/sessions"
|
|
||||||
|
"github.com/gorilla/sessions"
|
||||||
)
|
)
|
||||||
|
|
||||||
func LoginHandler(app *App) http.HandlerFunc {
|
func LoginHandler(app *App) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
session := r.Context().Value("session").(*sessions.Session)
|
session := r.Context().Value("session").(*sessions.Session)
|
||||||
if r.Method == http.MethodPost {
|
if r.Method == http.MethodPost {
|
||||||
username := r.FormValue("username")
|
username := r.FormValue("username")
|
||||||
password := r.FormValue("password")
|
password := r.FormValue("password")
|
||||||
user, err := models.GetUserByUsername(app.DB, username)
|
user, err := models.GetUserByUsername(app.DB, username)
|
||||||
if err != nil && err != sql.ErrNoRows {
|
if err != nil && err != sql.ErrNoRows {
|
||||||
log.Printf("Error fetching user in LoginHandler: %v", err)
|
log.Printf("Error fetching user in LoginHandler: %v", err)
|
||||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if user == nil || !models.CheckPassword(password, user.AuthenticationSalt, user.AuthenticationAlgorithm, user.AuthenticationString) {
|
if user == nil || !models.CheckPassword(password, user.AuthenticationSalt, user.AuthenticationAlgorithm, user.AuthenticationString) {
|
||||||
http.Redirect(w, r, app.Config.ThreadrDir+"/login/?error=invalid", http.StatusFound)
|
http.Redirect(w, r, app.Config.ThreadrDir+"/login/?error=invalid", http.StatusFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
session.Values["user_id"] = user.ID
|
// Regenerate session to avoid fixation
|
||||||
session.Values["user_ip"] = r.RemoteAddr
|
session.Options.MaxAge = -1
|
||||||
session.Values["user_agent"] = r.UserAgent()
|
_ = session.Save(r, w)
|
||||||
session.Options = &sessions.Options{
|
session = sessions.NewSession(app.Store, "session-name")
|
||||||
Path: "/",
|
session.Options = app.cookieOptions(r)
|
||||||
MaxAge: 86400 * 30, // 30 days
|
session.Values["user_id"] = user.ID
|
||||||
HttpOnly: true,
|
session.Values["user_ip"] = r.RemoteAddr
|
||||||
}
|
session.Values["user_agent"] = r.UserAgent()
|
||||||
if err := session.Save(r, w); err != nil {
|
if err := session.Save(r, w); err != nil {
|
||||||
log.Printf("Error saving session: %v", err)
|
log.Printf("Error saving session: %v", err)
|
||||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
http.Redirect(w, r, app.Config.ThreadrDir+"/userhome/", http.StatusFound)
|
http.Redirect(w, r, app.Config.ThreadrDir+"/userhome/", http.StatusFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
data := struct {
|
data := struct {
|
||||||
PageData
|
PageData
|
||||||
Error string
|
Error string
|
||||||
}{
|
}{
|
||||||
PageData: PageData{
|
PageData: PageData{
|
||||||
Title: "ThreadR - Login",
|
Title: "ThreadR - Login",
|
||||||
Navbar: "login",
|
Navbar: "login",
|
||||||
LoggedIn: false,
|
LoggedIn: false,
|
||||||
BasePath: app.Config.ThreadrDir,
|
BasePath: app.Config.ThreadrDir,
|
||||||
StaticPath: app.Config.ThreadrDir + "/static",
|
StaticPath: app.Config.ThreadrDir + "/static",
|
||||||
CurrentURL: r.URL.Path,
|
CurrentURL: r.URL.Path,
|
||||||
},
|
},
|
||||||
Error: "",
|
Error: "",
|
||||||
}
|
}
|
||||||
if r.URL.Query().Get("error") == "invalid" {
|
if r.URL.Query().Get("error") == "invalid" {
|
||||||
data.Error = "Invalid username or password"
|
data.Error = "Invalid username or password"
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := app.Tmpl.ExecuteTemplate(w, "login", data); err != nil {
|
if err := app.Tmpl.ExecuteTemplate(w, "login", data); err != nil {
|
||||||
log.Printf("Error executing template in LoginHandler: %v", err)
|
log.Printf("Error executing template in LoginHandler: %v", err)
|
||||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,34 +1,99 @@
|
||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
"crypto/sha256"
|
||||||
"net/http"
|
"fmt"
|
||||||
"threadr/models"
|
"io"
|
||||||
"github.com/gorilla/sessions"
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"threadr/models"
|
||||||
|
|
||||||
|
"github.com/gorilla/sessions"
|
||||||
)
|
)
|
||||||
|
|
||||||
func ProfileEditHandler(app *App) http.HandlerFunc {
|
func ProfileEditHandler(app *App) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
session := r.Context().Value("session").(*sessions.Session)
|
session := r.Context().Value("session").(*sessions.Session)
|
||||||
userID, ok := session.Values["user_id"].(int)
|
userID, ok := session.Values["user_id"].(int)
|
||||||
if !ok {
|
if !ok {
|
||||||
http.Redirect(w, r, app.Config.ThreadrDir+"/login/", http.StatusFound)
|
http.Redirect(w, r, app.Config.ThreadrDir+"/login/", http.StatusFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if r.Method == http.MethodPost {
|
if r.Method == http.MethodPost {
|
||||||
displayName := r.FormValue("display_name")
|
// Handle file upload
|
||||||
pfpURL := r.FormValue("pfp_url")
|
file, handler, err := r.FormFile("pfp")
|
||||||
bio := r.FormValue("bio")
|
if err == nil {
|
||||||
err := models.UpdateUserProfile(app.DB, userID, displayName, pfpURL, bio)
|
defer file.Close()
|
||||||
if err != nil {
|
|
||||||
log.Printf("Error updating profile: %v", err)
|
// Create a hash of the file
|
||||||
http.Error(w, "Failed to update profile", http.StatusInternalServerError)
|
h := sha256.New()
|
||||||
return
|
if _, err := io.Copy(h, file); err != nil {
|
||||||
}
|
log.Printf("Error hashing file: %v", err)
|
||||||
http.Redirect(w, r, app.Config.ThreadrDir+"/profile/", http.StatusFound)
|
http.Error(w, "Failed to process file", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
fileHash := fmt.Sprintf("%x", h.Sum(nil))
|
||||||
|
|
||||||
|
// Create file record in the database
|
||||||
|
fileRecord := models.File{
|
||||||
|
OriginalName: handler.Filename,
|
||||||
|
Hash: fileHash,
|
||||||
|
HashAlgorithm: "sha256",
|
||||||
|
}
|
||||||
|
fileID, err := models.CreateFile(app.DB, fileRecord)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error creating file record: %v", err)
|
||||||
|
http.Error(w, "Failed to save file information", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save the file to disk
|
||||||
|
fileExt := filepath.Ext(handler.Filename)
|
||||||
|
newFileName := fmt.Sprintf("%d%s", fileID, fileExt)
|
||||||
|
filePath := filepath.Join(app.Config.FileStorageDir, newFileName)
|
||||||
|
|
||||||
|
// Reset file pointer
|
||||||
|
file.Seek(0, 0)
|
||||||
|
|
||||||
|
dst, err := os.Create(filePath)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error creating file on disk: %v", err)
|
||||||
|
http.Error(w, "Failed to save file", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer dst.Close()
|
||||||
|
|
||||||
|
if _, err := io.Copy(dst, file); err != nil {
|
||||||
|
log.Printf("Error saving file to disk: %v", err)
|
||||||
|
http.Error(w, "Failed to save file", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update user's pfp_file_id
|
||||||
|
err = models.UpdateUserPfp(app.DB, userID, fileID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error updating user pfp: %v", err)
|
||||||
|
http.Error(w, "Failed to update profile", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update other profile fields
|
||||||
|
displayName := r.FormValue("display_name")
|
||||||
|
bio := r.FormValue("bio")
|
||||||
|
err = models.UpdateUserProfile(app.DB, userID, displayName, bio)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error updating profile: %v", err)
|
||||||
|
http.Error(w, "Failed to update profile", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(w, r, app.Config.ThreadrDir+"/profile/", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
user, err := models.GetUserByID(app.DB, userID)
|
user, err := models.GetUserByID(app.DB, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -62,4 +127,4 @@ func ProfileEditHandler(app *App) http.HandlerFunc {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,66 +1,94 @@
|
||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
"github.com/gorilla/sessions"
|
||||||
"net/http"
|
"log"
|
||||||
"threadr/models"
|
"net/http"
|
||||||
"github.com/gorilla/sessions"
|
"threadr/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
func SignupHandler(app *App) http.HandlerFunc {
|
func SignupHandler(app *App) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
session := r.Context().Value("session").(*sessions.Session)
|
session := r.Context().Value("session").(*sessions.Session)
|
||||||
cookie, _ := r.Cookie("threadr_cookie_banner")
|
cookie, _ := r.Cookie("threadr_cookie_banner")
|
||||||
if r.Method == http.MethodPost {
|
if r.Method == http.MethodPost {
|
||||||
username := r.FormValue("username")
|
username := r.FormValue("username")
|
||||||
password := r.FormValue("password")
|
password := r.FormValue("password")
|
||||||
err := models.CreateUser(app.DB, username, password)
|
passwordConfirm := r.FormValue("password_confirm")
|
||||||
if err != nil {
|
|
||||||
log.Printf("Error creating user: %v", err)
|
// Server-side validation for password confirmation
|
||||||
data := struct {
|
if password != passwordConfirm {
|
||||||
PageData
|
log.Printf("Password confirmation mismatch for user: %s", username)
|
||||||
Error string
|
data := struct {
|
||||||
}{
|
PageData
|
||||||
PageData: PageData{
|
Error string
|
||||||
Title: "ThreadR - Sign Up",
|
}{
|
||||||
Navbar: "signup",
|
PageData: PageData{
|
||||||
LoggedIn: false,
|
Title: "ThreadR - Sign Up",
|
||||||
ShowCookieBanner: cookie == nil || cookie.Value != "accepted",
|
Navbar: "signup",
|
||||||
BasePath: app.Config.ThreadrDir,
|
LoggedIn: false,
|
||||||
StaticPath: app.Config.ThreadrDir + "/static",
|
ShowCookieBanner: cookie == nil || cookie.Value != "accepted",
|
||||||
CurrentURL: r.URL.Path,
|
BasePath: app.Config.ThreadrDir,
|
||||||
},
|
StaticPath: app.Config.ThreadrDir + "/static",
|
||||||
Error: "An error occurred during sign up. Please try again.",
|
CurrentURL: r.URL.Path,
|
||||||
}
|
},
|
||||||
if err := app.Tmpl.ExecuteTemplate(w, "signup", data); err != nil {
|
Error: "Passwords do not match. Please try again.",
|
||||||
log.Printf("Error executing template in SignupHandler: %v", err)
|
}
|
||||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
if err := app.Tmpl.ExecuteTemplate(w, "signup", data); err != nil {
|
||||||
return
|
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
|
||||||
return
|
}
|
||||||
}
|
|
||||||
data := struct {
|
err := models.CreateUser(app.DB, username, password)
|
||||||
PageData
|
if err != nil {
|
||||||
Error string
|
log.Printf("Error creating user: %v", err)
|
||||||
}{
|
data := struct {
|
||||||
PageData: PageData{
|
PageData
|
||||||
Title: "ThreadR - Sign Up",
|
Error string
|
||||||
Navbar: "signup",
|
}{
|
||||||
LoggedIn: session.Values["user_id"] != nil,
|
PageData: PageData{
|
||||||
ShowCookieBanner: cookie == nil || cookie.Value != "accepted",
|
Title: "ThreadR - Sign Up",
|
||||||
BasePath: app.Config.ThreadrDir,
|
Navbar: "signup",
|
||||||
StaticPath: app.Config.ThreadrDir + "/static",
|
LoggedIn: false,
|
||||||
CurrentURL: r.URL.Path,
|
ShowCookieBanner: cookie == nil || cookie.Value != "accepted",
|
||||||
},
|
BasePath: app.Config.ThreadrDir,
|
||||||
Error: "",
|
StaticPath: app.Config.ThreadrDir + "/static",
|
||||||
}
|
CurrentURL: r.URL.Path,
|
||||||
if err := app.Tmpl.ExecuteTemplate(w, "signup", data); err != nil {
|
},
|
||||||
log.Printf("Error executing template in SignupHandler: %v", err)
|
Error: "An error occurred during sign up. Please try again.",
|
||||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
}
|
||||||
return
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,135 +1,137 @@
|
||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
"github.com/gorilla/sessions"
|
||||||
"net/http"
|
"log"
|
||||||
"strconv"
|
"net/http"
|
||||||
"threadr/models"
|
"strconv"
|
||||||
"github.com/gorilla/sessions"
|
"threadr/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
func ThreadHandler(app *App) http.HandlerFunc {
|
func ThreadHandler(app *App) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
session := r.Context().Value("session").(*sessions.Session)
|
session := r.Context().Value("session").(*sessions.Session)
|
||||||
loggedIn := session.Values["user_id"] != nil
|
loggedIn := session.Values["user_id"] != nil
|
||||||
userID, _ := session.Values["user_id"].(int)
|
userID, _ := session.Values["user_id"].(int)
|
||||||
cookie, _ := r.Cookie("threadr_cookie_banner")
|
cookie, _ := r.Cookie("threadr_cookie_banner")
|
||||||
|
|
||||||
threadIDStr := r.URL.Query().Get("id")
|
threadIDStr := r.URL.Query().Get("id")
|
||||||
threadID, err := strconv.Atoi(threadIDStr)
|
threadID, err := strconv.Atoi(threadIDStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Invalid thread ID", http.StatusBadRequest)
|
http.Error(w, "Invalid thread ID", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
thread, err := models.GetThreadByID(app.DB, threadID)
|
thread, err := models.GetThreadByID(app.DB, threadID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Error fetching thread: %v", err)
|
log.Printf("Error fetching thread: %v", err)
|
||||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if thread == nil {
|
if thread == nil {
|
||||||
http.Error(w, "Thread not found", http.StatusNotFound)
|
http.Error(w, "Thread not found", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
board, err := models.GetBoardByID(app.DB, thread.BoardID)
|
board, err := models.GetBoardByID(app.DB, thread.BoardID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Error fetching board: %v", err)
|
log.Printf("Error fetching board: %v", err)
|
||||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if board.Private {
|
if board.Private {
|
||||||
if !loggedIn {
|
if !loggedIn {
|
||||||
http.Redirect(w, r, app.Config.ThreadrDir+"/login/", http.StatusFound)
|
http.Redirect(w, r, app.Config.ThreadrDir+"/login/", http.StatusFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
hasPerm, err := models.HasBoardPermission(app.DB, userID, board.ID, models.PermViewBoard)
|
hasPerm, err := models.HasBoardPermission(app.DB, userID, board.ID, models.PermViewBoard)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Error checking permission: %v", err)
|
log.Printf("Error checking permission: %v", err)
|
||||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !hasPerm {
|
if !hasPerm {
|
||||||
http.Error(w, "You do not have permission to view this board", http.StatusForbidden)
|
http.Error(w, "You do not have permission to view this board", http.StatusForbidden)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if r.Method == http.MethodPost && loggedIn {
|
if r.Method == http.MethodPost && loggedIn {
|
||||||
action := r.URL.Query().Get("action")
|
action := r.URL.Query().Get("action")
|
||||||
if action == "submit" {
|
if action == "submit" {
|
||||||
content := r.FormValue("content")
|
content := r.FormValue("content")
|
||||||
replyToStr := r.URL.Query().Get("to")
|
replyToStr := r.URL.Query().Get("to")
|
||||||
replyTo := -1
|
replyTo := -1
|
||||||
if replyToStr != "" {
|
if replyToStr != "" {
|
||||||
replyTo, err = strconv.Atoi(replyToStr)
|
replyTo, err = strconv.Atoi(replyToStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Invalid reply_to ID", http.StatusBadRequest)
|
http.Error(w, "Invalid reply_to ID", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if content == "" {
|
if content == "" {
|
||||||
http.Error(w, "Content cannot be empty", http.StatusBadRequest)
|
http.Error(w, "Content cannot be empty", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if board.Private {
|
if board.Private {
|
||||||
hasPerm, err := models.HasBoardPermission(app.DB, userID, board.ID, models.PermPostInBoard)
|
hasPerm, err := models.HasBoardPermission(app.DB, userID, board.ID, models.PermPostInBoard)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Error checking permission: %v", err)
|
log.Printf("Error checking permission: %v", err)
|
||||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !hasPerm {
|
if !hasPerm {
|
||||||
http.Error(w, "You do not have permission to post in this board", http.StatusForbidden)
|
http.Error(w, "You do not have permission to post in this board", http.StatusForbidden)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
post := models.Post{
|
post := models.Post{
|
||||||
ThreadID: threadID,
|
ThreadID: threadID,
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
Content: content,
|
Content: content,
|
||||||
ReplyTo: replyTo,
|
ReplyTo: replyTo,
|
||||||
}
|
}
|
||||||
err = models.CreatePost(app.DB, post)
|
err = models.CreatePost(app.DB, post)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Error creating post: %v", err)
|
log.Printf("Error creating post: %v", err)
|
||||||
http.Error(w, "Failed to create post", http.StatusInternalServerError)
|
http.Error(w, "Failed to create post", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
http.Redirect(w, r, app.Config.ThreadrDir+"/thread/?id="+threadIDStr, http.StatusFound)
|
http.Redirect(w, r, app.Config.ThreadrDir+"/thread/?id="+threadIDStr, http.StatusFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
posts, err := models.GetPostsByThreadID(app.DB, threadID)
|
posts, err := models.GetPostsByThreadID(app.DB, threadID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Error fetching posts: %v", err)
|
log.Printf("Error fetching posts: %v", err)
|
||||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
data := struct {
|
data := struct {
|
||||||
PageData
|
PageData
|
||||||
Thread models.Thread
|
Thread models.Thread
|
||||||
Posts []models.Post
|
Board models.Board
|
||||||
}{
|
Posts []models.Post
|
||||||
PageData: PageData{
|
}{
|
||||||
Title: "ThreadR - " + thread.Title,
|
PageData: PageData{
|
||||||
Navbar: "boards",
|
Title: "ThreadR - " + thread.Title,
|
||||||
LoggedIn: loggedIn,
|
Navbar: "boards",
|
||||||
ShowCookieBanner: cookie == nil || cookie.Value != "accepted",
|
LoggedIn: loggedIn,
|
||||||
BasePath: app.Config.ThreadrDir,
|
ShowCookieBanner: cookie == nil || cookie.Value != "accepted",
|
||||||
StaticPath: app.Config.ThreadrDir + "/static",
|
BasePath: app.Config.ThreadrDir,
|
||||||
CurrentURL: r.URL.Path,
|
StaticPath: app.Config.ThreadrDir + "/static",
|
||||||
},
|
CurrentURL: r.URL.Path,
|
||||||
Thread: *thread,
|
},
|
||||||
Posts: posts,
|
Thread: *thread,
|
||||||
}
|
Board: *board,
|
||||||
if err := app.Tmpl.ExecuteTemplate(w, "thread", data); err != nil {
|
Posts: posts,
|
||||||
log.Printf("Error executing template in ThreadHandler: %v", err)
|
}
|
||||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
if err := app.Tmpl.ExecuteTemplate(w, "thread", data); err != nil {
|
||||||
return
|
log.Printf("Error executing template in ThreadHandler: %v", err)
|
||||||
}
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
}
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
391
main.go
391
main.go
|
|
@ -22,19 +22,19 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func loadConfig(filename string) (*handlers.Config, error) {
|
func loadConfig(filename string) (*handlers.Config, error) {
|
||||||
file, err := os.Open(filename)
|
file, err := os.Open(filename)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
var config handlers.Config
|
var config handlers.Config
|
||||||
err = json.NewDecoder(file).Decode(&config)
|
err = json.NewDecoder(file).Decode(&config)
|
||||||
return &config, err
|
return &config, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func createTablesIfNotExist(db *sql.DB) error {
|
func createTablesIfNotExist(db *sql.DB) error {
|
||||||
// Create boards table
|
// Create boards table
|
||||||
_, err := db.Exec(`
|
_, err := db.Exec(`
|
||||||
CREATE TABLE boards (
|
CREATE TABLE boards (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
name VARCHAR(255) NOT NULL,
|
name VARCHAR(255) NOT NULL,
|
||||||
|
|
@ -43,34 +43,15 @@ func createTablesIfNotExist(db *sql.DB) error {
|
||||||
public_visible BOOLEAN DEFAULT TRUE,
|
public_visible BOOLEAN DEFAULT TRUE,
|
||||||
pinned_threads TEXT,
|
pinned_threads TEXT,
|
||||||
custom_landing_page TEXT,
|
custom_landing_page TEXT,
|
||||||
color_scheme VARCHAR(255)
|
color_scheme VARCHAR(255),
|
||||||
|
type VARCHAR(20) DEFAULT 'classic' NOT NULL
|
||||||
)`)
|
)`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("error creating boards table: %v", err)
|
return fmt.Errorf("error creating boards table: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create users table
|
// Create threads table
|
||||||
_, err = db.Exec(`
|
_, err = db.Exec(`
|
||||||
CREATE TABLE users (
|
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
username VARCHAR(255) NOT NULL UNIQUE,
|
|
||||||
display_name VARCHAR(255),
|
|
||||||
pfp_url VARCHAR(255),
|
|
||||||
bio TEXT,
|
|
||||||
authentication_string VARCHAR(128) NOT NULL,
|
|
||||||
authentication_salt VARCHAR(255) NOT NULL,
|
|
||||||
authentication_algorithm VARCHAR(50) NOT NULL,
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
verified BOOLEAN DEFAULT FALSE,
|
|
||||||
permissions BIGINT DEFAULT 0
|
|
||||||
)`)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("error creating users table: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create threads table (without type field)
|
|
||||||
_, err = db.Exec(`
|
|
||||||
CREATE TABLE threads (
|
CREATE TABLE threads (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
board_id INT NOT NULL,
|
board_id INT NOT NULL,
|
||||||
|
|
@ -81,12 +62,12 @@ func createTablesIfNotExist(db *sql.DB) error {
|
||||||
accepted_answer_post_id INT,
|
accepted_answer_post_id INT,
|
||||||
FOREIGN KEY (board_id) REFERENCES boards(id)
|
FOREIGN KEY (board_id) REFERENCES boards(id)
|
||||||
)`)
|
)`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("error creating threads table: %v", err)
|
return fmt.Errorf("error creating threads table: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create posts table
|
// Create posts table
|
||||||
_, err = db.Exec(`
|
_, err = db.Exec(`
|
||||||
CREATE TABLE posts (
|
CREATE TABLE posts (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
thread_id INT NOT NULL,
|
thread_id INT NOT NULL,
|
||||||
|
|
@ -100,12 +81,12 @@ func createTablesIfNotExist(db *sql.DB) error {
|
||||||
reply_to INT DEFAULT -1,
|
reply_to INT DEFAULT -1,
|
||||||
FOREIGN KEY (thread_id) REFERENCES threads(id)
|
FOREIGN KEY (thread_id) REFERENCES threads(id)
|
||||||
)`)
|
)`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("error creating posts table: %v", err)
|
return fmt.Errorf("error creating posts table: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create likes table
|
// Create likes table
|
||||||
_, err = db.Exec(`
|
_, err = db.Exec(`
|
||||||
CREATE TABLE likes (
|
CREATE TABLE likes (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
post_id INT NOT NULL,
|
post_id INT NOT NULL,
|
||||||
|
|
@ -114,12 +95,12 @@ func createTablesIfNotExist(db *sql.DB) error {
|
||||||
UNIQUE KEY unique_like (post_id, user_id),
|
UNIQUE KEY unique_like (post_id, user_id),
|
||||||
FOREIGN KEY (post_id) REFERENCES posts(id)
|
FOREIGN KEY (post_id) REFERENCES posts(id)
|
||||||
)`)
|
)`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("error creating likes table: %v", err)
|
return fmt.Errorf("error creating likes table: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create board_permissions table
|
// Create board_permissions table
|
||||||
_, err = db.Exec(`
|
_, err = db.Exec(`
|
||||||
CREATE TABLE board_permissions (
|
CREATE TABLE board_permissions (
|
||||||
user_id INT NOT NULL,
|
user_id INT NOT NULL,
|
||||||
board_id INT NOT NULL,
|
board_id INT NOT NULL,
|
||||||
|
|
@ -127,12 +108,12 @@ func createTablesIfNotExist(db *sql.DB) error {
|
||||||
PRIMARY KEY (user_id, board_id),
|
PRIMARY KEY (user_id, board_id),
|
||||||
FOREIGN KEY (board_id) REFERENCES boards(id)
|
FOREIGN KEY (board_id) REFERENCES boards(id)
|
||||||
)`)
|
)`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("error creating board_permissions table: %v", err)
|
return fmt.Errorf("error creating board_permissions table: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create notifications table
|
// Create notifications table
|
||||||
_, err = db.Exec(`
|
_, err = db.Exec(`
|
||||||
CREATE TABLE notifications (
|
CREATE TABLE notifications (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
user_id INT NOT NULL,
|
user_id INT NOT NULL,
|
||||||
|
|
@ -141,12 +122,12 @@ func createTablesIfNotExist(db *sql.DB) error {
|
||||||
is_read BOOLEAN DEFAULT FALSE,
|
is_read BOOLEAN DEFAULT FALSE,
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
)`)
|
)`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("error creating notifications table: %v", err)
|
return fmt.Errorf("error creating notifications table: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create reactions table
|
// Create reactions table
|
||||||
_, err = db.Exec(`
|
_, err = db.Exec(`
|
||||||
CREATE TABLE reactions (
|
CREATE TABLE reactions (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
post_id INT NOT NULL,
|
post_id INT NOT NULL,
|
||||||
|
|
@ -154,12 +135,12 @@ func createTablesIfNotExist(db *sql.DB) error {
|
||||||
emoji VARCHAR(10) NOT NULL,
|
emoji VARCHAR(10) NOT NULL,
|
||||||
FOREIGN KEY (post_id) REFERENCES posts(id)
|
FOREIGN KEY (post_id) REFERENCES posts(id)
|
||||||
)`)
|
)`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("error creating reactions table: %v", err)
|
return fmt.Errorf("error creating reactions table: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create reposts table
|
// Create reposts table
|
||||||
_, err = db.Exec(`
|
_, err = db.Exec(`
|
||||||
CREATE TABLE reposts (
|
CREATE TABLE reposts (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
thread_id INT NOT NULL,
|
thread_id INT NOT NULL,
|
||||||
|
|
@ -169,12 +150,12 @@ func createTablesIfNotExist(db *sql.DB) error {
|
||||||
FOREIGN KEY (thread_id) REFERENCES threads(id),
|
FOREIGN KEY (thread_id) REFERENCES threads(id),
|
||||||
FOREIGN KEY (board_id) REFERENCES boards(id)
|
FOREIGN KEY (board_id) REFERENCES boards(id)
|
||||||
)`)
|
)`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("error creating reposts table: %v", err)
|
return fmt.Errorf("error creating reposts table: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create news table
|
// Create news table
|
||||||
_, err = db.Exec(`
|
_, err = db.Exec(`
|
||||||
CREATE TABLE news (
|
CREATE TABLE news (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
title VARCHAR(255) NOT NULL,
|
title VARCHAR(255) NOT NULL,
|
||||||
|
|
@ -182,25 +163,76 @@ func createTablesIfNotExist(db *sql.DB) error {
|
||||||
posted_by INT NOT NULL,
|
posted_by INT NOT NULL,
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
)`)
|
)`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("error creating news table: %v", err)
|
return fmt.Errorf("error creating news table: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create chat_messages table
|
// Create chat_messages table
|
||||||
_, err = db.Exec(`
|
_, err = db.Exec(`
|
||||||
CREATE TABLE chat_messages (
|
CREATE TABLE chat_messages (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
board_id INT NOT NULL,
|
||||||
user_id INT NOT NULL,
|
user_id INT NOT NULL,
|
||||||
content TEXT NOT NULL,
|
content TEXT NOT NULL,
|
||||||
reply_to INT DEFAULT -1,
|
reply_to INT DEFAULT -1,
|
||||||
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (board_id) REFERENCES boards(id) ON DELETE CASCADE
|
||||||
)`)
|
)`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("error creating chat_messages table: %v", err)
|
return fmt.Errorf("error creating chat_messages table: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Println("Database tables created.")
|
// Create files table (Hope this does not break anything)
|
||||||
return nil
|
_, err = db.Exec(`
|
||||||
|
CREATE TABLE files (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
original_name VARCHAR(255) NOT NULL,
|
||||||
|
hash VARCHAR(255) NOT NULL,
|
||||||
|
hash_algorithm VARCHAR(50) NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)`)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error creating files table: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create users table (KEEP THIS HERE!)
|
||||||
|
// Otherwise SQL bitches about the foreign key.
|
||||||
|
_, err = db.Exec(`
|
||||||
|
CREATE TABLE users (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
username VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
display_name VARCHAR(255),
|
||||||
|
pfp_file_id INT,
|
||||||
|
bio TEXT,
|
||||||
|
authentication_string VARCHAR(128) NOT NULL,
|
||||||
|
authentication_salt VARCHAR(255) NOT NULL,
|
||||||
|
authentication_algorithm VARCHAR(50) NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
verified BOOLEAN DEFAULT FALSE,
|
||||||
|
permissions BIGINT DEFAULT 0,
|
||||||
|
FOREIGN KEY (pfp_file_id) REFERENCES files(id)
|
||||||
|
)`)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error creating users table: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
func ensureAdminUser(db *sql.DB) error {
|
||||||
|
|
@ -266,101 +298,130 @@ func ensureAdminUser(db *sql.DB) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// Define command-line flag for initialization
|
// Define command-line flag for initialization
|
||||||
initialize := flag.Bool("initialize", false, "Initialize database tables and admin user")
|
initialize := flag.Bool("initialize", false, "Initialize database tables and admin user")
|
||||||
flag.BoolVar(initialize, "i", false, "Short for --initialize")
|
flag.BoolVar(initialize, "i", false, "Short for --initialize")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
config, err := loadConfig("config/config.json")
|
config, err := loadConfig("config/config.json")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal("Error loading config:", err)
|
log.Fatal("Error loading config:", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
dsn := fmt.Sprintf("%s:%s@tcp(%s)/%s", config.DBUsername, config.DBPassword, config.DBServerHost, config.DBDatabase)
|
// Allow environment variable override for the session secret to avoid hardcoding secrets in files.
|
||||||
db, err := sql.Open("mysql", dsn)
|
if envSecret := os.Getenv("THREADR_SESSION_SECRET"); envSecret != "" {
|
||||||
if err != nil {
|
config.SessionSecret = envSecret
|
||||||
log.Fatal("Error connecting to database:", err)
|
}
|
||||||
}
|
if len(config.SessionSecret) < 32 {
|
||||||
defer db.Close()
|
log.Fatal("Session secret must be at least 32 bytes; set THREADR_SESSION_SECRET or session_secret in config")
|
||||||
|
}
|
||||||
|
|
||||||
// Perform initialization if the flag is set
|
dsn := fmt.Sprintf("%s:%s@tcp(%s)/%s", config.DBUsername, config.DBPassword, config.DBServerHost, config.DBDatabase)
|
||||||
if *initialize {
|
db, err := sql.Open("mysql", dsn)
|
||||||
log.Println("Initializing database...")
|
if err != nil {
|
||||||
err = createTablesIfNotExist(db)
|
log.Fatal("Error connecting to database:", err)
|
||||||
if err != nil {
|
}
|
||||||
log.Fatal("Error creating database tables:", err)
|
defer db.Close()
|
||||||
}
|
|
||||||
|
|
||||||
err = ensureAdminUser(db)
|
// Create the file directory
|
||||||
if err != nil {
|
// TODO: Wouldn't this be better suited on the initialize function?
|
||||||
log.Fatal("Error ensuring admin user:", err)
|
// Discussion pending.
|
||||||
}
|
err = os.MkdirAll(config.FileStorageDir, 0700)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("Error creating file storage directory:", err)
|
||||||
|
}
|
||||||
|
|
||||||
log.Println("Initialization completed successfully. Exiting.")
|
// Perform initialization if the flag is set
|
||||||
return
|
if *initialize {
|
||||||
}
|
log.Println("Initializing database...")
|
||||||
|
err = createTablesIfNotExist(db)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("Error creating database tables:", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Normal startup (without automatic table creation)
|
err = ensureAdminUser(db)
|
||||||
log.Println("Starting ThreadR server...")
|
if err != nil {
|
||||||
|
log.Fatal("Error ensuring admin user:", err)
|
||||||
|
}
|
||||||
|
|
||||||
dir, err := os.Getwd()
|
log.Println("Initialization completed successfully. Exiting.")
|
||||||
if err != nil {
|
return
|
||||||
log.Fatal("Error getting working directory:", err)
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Parse partial templates
|
// Normal startup (without automatic table creation)
|
||||||
tmpl := template.Must(template.ParseFiles(
|
log.Println("Starting ThreadR server...")
|
||||||
filepath.Join(dir, "templates/partials/navbar.html"),
|
|
||||||
filepath.Join(dir, "templates/partials/cookie_banner.html"),
|
|
||||||
))
|
|
||||||
|
|
||||||
// Parse page-specific templates with unique names
|
dir, err := os.Getwd()
|
||||||
tmpl, err = tmpl.ParseFiles(
|
if err != nil {
|
||||||
filepath.Join(dir, "templates/pages/about.html"),
|
log.Fatal("Error getting working directory:", err)
|
||||||
filepath.Join(dir, "templates/pages/board.html"),
|
}
|
||||||
filepath.Join(dir, "templates/pages/boards.html"),
|
|
||||||
filepath.Join(dir, "templates/pages/home.html"),
|
|
||||||
filepath.Join(dir, "templates/pages/login.html"),
|
|
||||||
filepath.Join(dir, "templates/pages/news.html"),
|
|
||||||
filepath.Join(dir, "templates/pages/profile.html"),
|
|
||||||
filepath.Join(dir, "templates/pages/profile_edit.html"),
|
|
||||||
filepath.Join(dir, "templates/pages/signup.html"),
|
|
||||||
filepath.Join(dir, "templates/pages/thread.html"),
|
|
||||||
filepath.Join(dir, "templates/pages/userhome.html"),
|
|
||||||
filepath.Join(dir, "templates/pages/chat.html"),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal("Error parsing page templates:", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
store := sessions.NewCookieStore([]byte("secret-key")) // Replace with secure key in production
|
// Parse partial templates
|
||||||
|
tmpl := template.Must(template.ParseFiles(
|
||||||
|
filepath.Join(dir, "templates/partials/navbar.html"),
|
||||||
|
filepath.Join(dir, "templates/partials/cookie_banner.html"),
|
||||||
|
))
|
||||||
|
|
||||||
app := &handlers.App{
|
// Parse page-specific templates with unique names
|
||||||
DB: db,
|
tmpl, err = tmpl.ParseFiles(
|
||||||
Store: store,
|
filepath.Join(dir, "templates/pages/about.html"),
|
||||||
Config: config,
|
filepath.Join(dir, "templates/pages/board.html"),
|
||||||
Tmpl: tmpl,
|
filepath.Join(dir, "templates/pages/boards.html"),
|
||||||
}
|
filepath.Join(dir, "templates/pages/home.html"),
|
||||||
|
filepath.Join(dir, "templates/pages/login.html"),
|
||||||
|
filepath.Join(dir, "templates/pages/news.html"),
|
||||||
|
filepath.Join(dir, "templates/pages/profile.html"),
|
||||||
|
filepath.Join(dir, "templates/pages/profile_edit.html"),
|
||||||
|
filepath.Join(dir, "templates/pages/signup.html"),
|
||||||
|
filepath.Join(dir, "templates/pages/thread.html"),
|
||||||
|
filepath.Join(dir, "templates/pages/userhome.html"),
|
||||||
|
filepath.Join(dir, "templates/pages/chat.html"),
|
||||||
|
filepath.Join(dir, "templates/pages/preferences.html"),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("Error parsing page templates:", err)
|
||||||
|
}
|
||||||
|
|
||||||
fs := http.FileServer(http.Dir("static"))
|
store := sessions.NewCookieStore([]byte(config.SessionSecret))
|
||||||
http.Handle(config.ThreadrDir+"/static/", http.StripPrefix(config.ThreadrDir+"/static/", fs))
|
store.Options = &sessions.Options{
|
||||||
|
Path: config.ThreadrDir + "/",
|
||||||
|
MaxAge: 86400 * 30,
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: config.SessionSecure,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
}
|
||||||
|
if config.DomainName != "" {
|
||||||
|
store.Options.Domain = config.DomainName
|
||||||
|
}
|
||||||
|
|
||||||
http.HandleFunc(config.ThreadrDir+"/", app.SessionMW(handlers.HomeHandler(app)))
|
app := &handlers.App{
|
||||||
http.HandleFunc(config.ThreadrDir+"/login/", app.SessionMW(handlers.LoginHandler(app)))
|
DB: db,
|
||||||
http.HandleFunc(config.ThreadrDir+"/logout/", app.SessionMW(handlers.LogoutHandler(app)))
|
Store: store,
|
||||||
http.HandleFunc(config.ThreadrDir+"/userhome/", app.SessionMW(app.RequireLoginMW(handlers.UserHomeHandler(app))))
|
Config: config,
|
||||||
http.HandleFunc(config.ThreadrDir+"/boards/", app.SessionMW(handlers.BoardsHandler(app)))
|
Tmpl: tmpl,
|
||||||
http.HandleFunc(config.ThreadrDir+"/board/", app.SessionMW(handlers.BoardHandler(app)))
|
}
|
||||||
http.HandleFunc(config.ThreadrDir+"/thread/", app.SessionMW(handlers.ThreadHandler(app)))
|
|
||||||
http.HandleFunc(config.ThreadrDir+"/about/", app.SessionMW(handlers.AboutHandler(app)))
|
|
||||||
http.HandleFunc(config.ThreadrDir+"/profile/", app.SessionMW(app.RequireLoginMW(handlers.ProfileHandler(app))))
|
|
||||||
http.HandleFunc(config.ThreadrDir+"/profile/edit/", app.SessionMW(app.RequireLoginMW(handlers.ProfileEditHandler(app))))
|
|
||||||
http.HandleFunc(config.ThreadrDir+"/like/", app.SessionMW(app.RequireLoginMW(handlers.LikeHandler(app))))
|
|
||||||
http.HandleFunc(config.ThreadrDir+"/news/", app.SessionMW(handlers.NewsHandler(app)))
|
|
||||||
http.HandleFunc(config.ThreadrDir+"/signup/", app.SessionMW(handlers.SignupHandler(app)))
|
|
||||||
http.HandleFunc(config.ThreadrDir+"/accept_cookie/", app.SessionMW(handlers.AcceptCookieHandler(app)))
|
|
||||||
http.HandleFunc(config.ThreadrDir+"/chat/", app.SessionMW(app.RequireLoginMW(handlers.ChatHandler(app))))
|
|
||||||
|
|
||||||
log.Println("Server starting on :8080")
|
fs := http.FileServer(http.Dir("static"))
|
||||||
log.Fatal(http.ListenAndServe(":8080", nil))
|
http.Handle(config.ThreadrDir+"/static/", http.StripPrefix(config.ThreadrDir+"/static/", fs))
|
||||||
|
|
||||||
|
http.HandleFunc(config.ThreadrDir+"/", app.SessionMW(handlers.HomeHandler(app)))
|
||||||
|
http.HandleFunc(config.ThreadrDir+"/login/", app.SessionMW(handlers.LoginHandler(app)))
|
||||||
|
http.HandleFunc(config.ThreadrDir+"/logout/", app.SessionMW(handlers.LogoutHandler(app)))
|
||||||
|
http.HandleFunc(config.ThreadrDir+"/userhome/", app.SessionMW(app.RequireLoginMW(handlers.UserHomeHandler(app))))
|
||||||
|
http.HandleFunc(config.ThreadrDir+"/boards/", app.SessionMW(handlers.BoardsHandler(app)))
|
||||||
|
http.HandleFunc(config.ThreadrDir+"/board/", app.SessionMW(handlers.BoardHandler(app)))
|
||||||
|
http.HandleFunc(config.ThreadrDir+"/thread/", app.SessionMW(handlers.ThreadHandler(app)))
|
||||||
|
http.HandleFunc(config.ThreadrDir+"/about/", app.SessionMW(handlers.AboutHandler(app)))
|
||||||
|
http.HandleFunc(config.ThreadrDir+"/profile/", app.SessionMW(app.RequireLoginMW(handlers.ProfileHandler(app))))
|
||||||
|
http.HandleFunc(config.ThreadrDir+"/profile/edit/", app.SessionMW(app.RequireLoginMW(handlers.ProfileEditHandler(app))))
|
||||||
|
http.HandleFunc(config.ThreadrDir+"/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))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,17 +14,18 @@ type Board struct {
|
||||||
PinnedThreads []int // Stored as JSON
|
PinnedThreads []int // Stored as JSON
|
||||||
CustomLandingPage string
|
CustomLandingPage string
|
||||||
ColorScheme string
|
ColorScheme string
|
||||||
|
Type string
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetBoardByID(db *sql.DB, id int) (*Board, error) {
|
func GetBoardByID(db *sql.DB, id int) (*Board, error) {
|
||||||
query := "SELECT id, name, description, private, public_visible, pinned_threads, custom_landing_page, color_scheme FROM boards WHERE id = ?"
|
query := "SELECT id, name, description, private, public_visible, pinned_threads, custom_landing_page, color_scheme, type FROM boards WHERE id = ?"
|
||||||
row := db.QueryRow(query, id)
|
row := db.QueryRow(query, id)
|
||||||
board := &Board{}
|
board := &Board{}
|
||||||
var pinnedThreadsJSON sql.NullString
|
var pinnedThreadsJSON sql.NullString
|
||||||
var customLandingPage sql.NullString
|
var customLandingPage sql.NullString
|
||||||
var colorScheme sql.NullString
|
var colorScheme sql.NullString
|
||||||
var description sql.NullString
|
var description sql.NullString
|
||||||
err := row.Scan(&board.ID, &board.Name, &description, &board.Private, &board.PublicVisible, &pinnedThreadsJSON, &customLandingPage, &colorScheme)
|
err := row.Scan(&board.ID, &board.Name, &description, &board.Private, &board.PublicVisible, &pinnedThreadsJSON, &customLandingPage, &colorScheme, &board.Type)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
@ -56,7 +57,7 @@ func GetBoardByID(db *sql.DB, id int) (*Board, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetAllBoards(db *sql.DB, private bool) ([]Board, error) {
|
func GetAllBoards(db *sql.DB, private bool) ([]Board, error) {
|
||||||
query := "SELECT id, name, description, private, public_visible, pinned_threads, custom_landing_page, color_scheme FROM boards WHERE private = ? ORDER BY id ASC"
|
query := "SELECT id, name, description, private, public_visible, pinned_threads, custom_landing_page, color_scheme, type FROM boards WHERE private = ? ORDER BY id ASC"
|
||||||
rows, err := db.Query(query, private)
|
rows, err := db.Query(query, private)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -70,7 +71,7 @@ func GetAllBoards(db *sql.DB, private bool) ([]Board, error) {
|
||||||
var customLandingPage sql.NullString
|
var customLandingPage sql.NullString
|
||||||
var colorScheme sql.NullString
|
var colorScheme sql.NullString
|
||||||
var description sql.NullString
|
var description sql.NullString
|
||||||
err := rows.Scan(&board.ID, &board.Name, &description, &board.Private, &board.PublicVisible, &pinnedThreadsJSON, &customLandingPage, &colorScheme)
|
err := rows.Scan(&board.ID, &board.Name, &description, &board.Private, &board.PublicVisible, &pinnedThreadsJSON, &customLandingPage, &colorScheme, &board.Type)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
209
models/chat.go
209
models/chat.go
|
|
@ -2,34 +2,41 @@ package models
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"html"
|
||||||
|
"html/template"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ChatMessage struct {
|
type ChatMessage struct {
|
||||||
ID int
|
ID int `json:"id"`
|
||||||
UserID int
|
BoardID int `json:"boardId"`
|
||||||
Content string
|
UserID int `json:"userId"`
|
||||||
ReplyTo int // -1 if not a reply
|
Content template.HTML `json:"content"`
|
||||||
Timestamp time.Time
|
ReplyTo int `json:"replyTo"`
|
||||||
Username string // For display, fetched from user
|
Timestamp time.Time `json:"timestamp"`
|
||||||
PfpURL string // For display, fetched from user
|
Username string `json:"username"`
|
||||||
Mentions []string // List of mentioned usernames
|
PfpFileID sql.NullInt64 `json:"pfpFileId"`
|
||||||
|
Mentions []string `json:"mentions"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func CreateChatMessage(db *sql.DB, msg ChatMessage) error {
|
func CreateChatMessage(db *sql.DB, boardID, userID int, rawContent string, replyTo int) error {
|
||||||
query := "INSERT INTO chat_messages (user_id, content, reply_to, timestamp) VALUES (?, ?, ?, NOW())"
|
query := "INSERT INTO chat_messages (board_id, user_id, content, reply_to, timestamp) VALUES (?, ?, ?, ?, NOW())"
|
||||||
_, err := db.Exec(query, msg.UserID, msg.Content, msg.ReplyTo)
|
_, err := db.Exec(query, boardID, userID, rawContent, replyTo)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetRecentChatMessages(db *sql.DB, limit int) ([]ChatMessage, error) {
|
func GetRecentChatMessages(db *sql.DB, boardID int, limit int) ([]ChatMessage, error) {
|
||||||
query := `
|
query := `
|
||||||
SELECT cm.id, cm.user_id, cm.content, cm.reply_to, cm.timestamp, u.username, u.pfp_url
|
SELECT cm.id, cm.user_id, cm.content, cm.reply_to, cm.timestamp, u.username, u.pfp_file_id
|
||||||
FROM chat_messages cm
|
FROM chat_messages cm
|
||||||
JOIN users u ON cm.user_id = u.id
|
JOIN users u ON cm.user_id = u.id
|
||||||
|
WHERE cm.board_id = ?
|
||||||
ORDER BY cm.timestamp DESC
|
ORDER BY cm.timestamp DESC
|
||||||
LIMIT ?`
|
LIMIT ?`
|
||||||
rows, err := db.Query(query, limit)
|
rows, err := db.Query(query, boardID, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -39,20 +46,18 @@ func GetRecentChatMessages(db *sql.DB, limit int) ([]ChatMessage, error) {
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var msg ChatMessage
|
var msg ChatMessage
|
||||||
var timestampStr string
|
var timestampStr string
|
||||||
var pfpURL sql.NullString
|
var rawContent string
|
||||||
err := rows.Scan(&msg.ID, &msg.UserID, &msg.Content, &msg.ReplyTo, ×tampStr, &msg.Username, &pfpURL)
|
err := rows.Scan(&msg.ID, &msg.UserID, &rawContent, &msg.ReplyTo, ×tampStr, &msg.Username, &msg.PfpFileID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
msg.BoardID = boardID
|
||||||
msg.Timestamp, err = time.Parse("2006-01-02 15:04:05", timestampStr)
|
msg.Timestamp, err = time.Parse("2006-01-02 15:04:05", timestampStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
msg.Timestamp = time.Time{}
|
msg.Timestamp = time.Time{}
|
||||||
}
|
}
|
||||||
if pfpURL.Valid {
|
msg.Content = renderMarkdown(rawContent)
|
||||||
msg.PfpURL = pfpURL.String
|
msg.Mentions = extractMentions(rawContent)
|
||||||
}
|
|
||||||
// Parse mentions from content (simple @username detection)
|
|
||||||
msg.Mentions = extractMentions(msg.Content)
|
|
||||||
messages = append(messages, msg)
|
messages = append(messages, msg)
|
||||||
}
|
}
|
||||||
return messages, nil
|
return messages, nil
|
||||||
|
|
@ -60,15 +65,15 @@ func GetRecentChatMessages(db *sql.DB, limit int) ([]ChatMessage, error) {
|
||||||
|
|
||||||
func GetChatMessageByID(db *sql.DB, id int) (*ChatMessage, error) {
|
func GetChatMessageByID(db *sql.DB, id int) (*ChatMessage, error) {
|
||||||
query := `
|
query := `
|
||||||
SELECT cm.id, cm.user_id, cm.content, cm.reply_to, cm.timestamp, u.username, u.pfp_url
|
SELECT cm.id, cm.board_id, cm.user_id, cm.content, cm.reply_to, cm.timestamp, u.username, u.pfp_file_id
|
||||||
FROM chat_messages cm
|
FROM chat_messages cm
|
||||||
JOIN users u ON cm.user_id = u.id
|
JOIN users u ON cm.user_id = u.id
|
||||||
WHERE cm.id = ?`
|
WHERE cm.id = ?`
|
||||||
row := db.QueryRow(query, id)
|
row := db.QueryRow(query, id)
|
||||||
var msg ChatMessage
|
var msg ChatMessage
|
||||||
var timestampStr string
|
var timestampStr string
|
||||||
var pfpURL sql.NullString
|
var rawContent string
|
||||||
err := row.Scan(&msg.ID, &msg.UserID, &msg.Content, &msg.ReplyTo, ×tampStr, &msg.Username, &pfpURL)
|
err := row.Scan(&msg.ID, &msg.BoardID, &msg.UserID, &rawContent, &msg.ReplyTo, ×tampStr, &msg.Username, &msg.PfpFileID)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
@ -79,54 +84,122 @@ func GetChatMessageByID(db *sql.DB, id int) (*ChatMessage, error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
msg.Timestamp = time.Time{}
|
msg.Timestamp = time.Time{}
|
||||||
}
|
}
|
||||||
if pfpURL.Valid {
|
msg.Content = renderMarkdown(rawContent)
|
||||||
msg.PfpURL = pfpURL.String
|
msg.Mentions = extractMentions(rawContent)
|
||||||
}
|
|
||||||
msg.Mentions = extractMentions(msg.Content)
|
|
||||||
return &msg, nil
|
return &msg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetUsernamesMatching(db *sql.DB, prefix string) ([]string, error) {
|
|
||||||
query := "SELECT username FROM users WHERE username LIKE ? LIMIT 10"
|
|
||||||
rows, err := db.Query(query, prefix+"%")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var usernames []string
|
|
||||||
for rows.Next() {
|
|
||||||
var username string
|
|
||||||
if err := rows.Scan(&username); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
usernames = append(usernames, username)
|
|
||||||
}
|
|
||||||
return usernames, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Simple utility to extract mentions from content
|
|
||||||
func extractMentions(content string) []string {
|
func extractMentions(content string) []string {
|
||||||
var mentions []string
|
re := regexp.MustCompile(`@(\w+)`)
|
||||||
var currentMention string
|
matches := re.FindAllStringSubmatch(content, -1)
|
||||||
inMention := false
|
mentions := make([]string, len(matches))
|
||||||
|
for i, match := range matches {
|
||||||
for _, char := range content {
|
mentions[i] = match[1]
|
||||||
if char == '@' {
|
|
||||||
inMention = true
|
|
||||||
currentMention = "@"
|
|
||||||
} else if inMention && (char == ' ' || char == '\n' || char == '\t') {
|
|
||||||
if len(currentMention) > 1 {
|
|
||||||
mentions = append(mentions, currentMention)
|
|
||||||
}
|
|
||||||
inMention = false
|
|
||||||
currentMention = ""
|
|
||||||
} else if inMention {
|
|
||||||
currentMention += string(char)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if inMention && len(currentMention) > 1 {
|
|
||||||
mentions = append(mentions, currentMention)
|
|
||||||
}
|
}
|
||||||
return mentions
|
return mentions
|
||||||
|
}
|
||||||
|
|
||||||
|
func processInlineMarkdown(line string) string {
|
||||||
|
line = regexp.MustCompile("`([^`]+)`").ReplaceAllString(line, "<code>$1</code>")
|
||||||
|
line = regexp.MustCompile(`\*\*([^\*]+)\*\*`).ReplaceAllString(line, "<strong>$1</strong>")
|
||||||
|
line = regexp.MustCompile(`__([^_]+)__`).ReplaceAllString(line, "<strong>$1</strong>")
|
||||||
|
line = regexp.MustCompile(`\*([^\*]+)\*`).ReplaceAllString(line, "<em>$1</em>")
|
||||||
|
line = regexp.MustCompile(`_([^_]+)_`).ReplaceAllString(line, "<em>$1</em>")
|
||||||
|
line = regexp.MustCompile(`@(\w+)`).ReplaceAllString(line, `<span class="chat-message-mention">@$1</span>`)
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderMarkdown(content string) template.HTML {
|
||||||
|
var sb strings.Builder
|
||||||
|
|
||||||
|
codeBlockRegex := regexp.MustCompile("(?s)```(\\w*)\n(.*?)\n```")
|
||||||
|
codeBlocks := make(map[string]string)
|
||||||
|
codeBlockCounter := 0
|
||||||
|
|
||||||
|
contentWithoutCodeBlocks := codeBlockRegex.ReplaceAllStringFunc(content, func(match string) string {
|
||||||
|
submatches := codeBlockRegex.FindStringSubmatch(match)
|
||||||
|
|
||||||
|
lang := submatches[1]
|
||||||
|
codeContent := submatches[2]
|
||||||
|
|
||||||
|
escapedCode := html.EscapeString(codeContent)
|
||||||
|
var renderedCodeBlock string
|
||||||
|
if lang != "" {
|
||||||
|
renderedCodeBlock = fmt.Sprintf("<pre><code class=\"language-%s\">%s</code></pre>", lang, escapedCode)
|
||||||
|
} else {
|
||||||
|
renderedCodeBlock = fmt.Sprintf("<pre><code>%s</code></pre>", escapedCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
placeholder := fmt.Sprintf("<!--CODEBLOCK_%d-->", codeBlockCounter)
|
||||||
|
codeBlocks[placeholder] = renderedCodeBlock
|
||||||
|
codeBlockCounter++
|
||||||
|
return placeholder
|
||||||
|
})
|
||||||
|
|
||||||
|
lines := strings.Split(contentWithoutCodeBlocks, "\n")
|
||||||
|
|
||||||
|
inList := false
|
||||||
|
|
||||||
|
for _, line := range lines {
|
||||||
|
trimmedLine := strings.TrimSpace(line)
|
||||||
|
|
||||||
|
if strings.HasPrefix(trimmedLine, "### ") {
|
||||||
|
if inList { sb.WriteString("</ul>\n"); inList = false }
|
||||||
|
sb.WriteString("<h3>")
|
||||||
|
sb.WriteString(processInlineMarkdown(strings.TrimPrefix(trimmedLine, "### ")))
|
||||||
|
sb.WriteString("</h3>\n")
|
||||||
|
continue
|
||||||
|
} else if strings.HasPrefix(trimmedLine, "## ") {
|
||||||
|
if inList { sb.WriteString("</ul>\n"); inList = false }
|
||||||
|
sb.WriteString("<h2>")
|
||||||
|
sb.WriteString(processInlineMarkdown(strings.TrimPrefix(trimmedLine, "## ")))
|
||||||
|
sb.WriteString("</h2>\n")
|
||||||
|
continue
|
||||||
|
} else if strings.HasPrefix(trimmedLine, "# ") {
|
||||||
|
if inList { sb.WriteString("</ul>\n"); inList = false }
|
||||||
|
sb.WriteString("<h1>")
|
||||||
|
sb.WriteString(processInlineMarkdown(strings.TrimPrefix(trimmedLine, "# ")))
|
||||||
|
sb.WriteString("</h1>\n")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(trimmedLine, "* ") || strings.HasPrefix(trimmedLine, "- ") {
|
||||||
|
if !inList {
|
||||||
|
sb.WriteString("<ul>\n")
|
||||||
|
inList = true
|
||||||
|
}
|
||||||
|
listItemContent := strings.TrimPrefix(strings.TrimPrefix(trimmedLine, "* "), "- ")
|
||||||
|
sb.WriteString("<li>")
|
||||||
|
sb.WriteString(processInlineMarkdown(listItemContent))
|
||||||
|
sb.WriteString("</li>\n")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if inList {
|
||||||
|
sb.WriteString("</ul>\n")
|
||||||
|
inList = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if trimmedLine != "" {
|
||||||
|
sb.WriteString("<p>")
|
||||||
|
sb.WriteString(processInlineMarkdown(trimmedLine))
|
||||||
|
sb.WriteString("</p>\n")
|
||||||
|
} else {
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if inList {
|
||||||
|
sb.WriteString("</ul>\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
finalContent := sb.String()
|
||||||
|
|
||||||
|
for placeholder, blockHTML := range codeBlocks {
|
||||||
|
finalContent = strings.ReplaceAll(finalContent, placeholder, blockHTML)
|
||||||
|
}
|
||||||
|
|
||||||
|
finalContent = regexp.MustCompile(`\n{3,}`).ReplaceAllString(finalContent, "\n\n")
|
||||||
|
|
||||||
|
return template.HTML(finalContent)
|
||||||
}
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -70,7 +70,7 @@ func GetThreadsByBoardID(db *sql.DB, boardID int) ([]Thread, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func CreateThread(db *sql.DB, thread Thread) error {
|
func CreateThread(db *sql.DB, thread Thread) error {
|
||||||
query := "INSERT INTO threads (board_id, title, created_by_user_id, created_at, updated_at, type) VALUES (?, ?, ?, NOW(), NOW(), 'classic')"
|
query := "INSERT INTO threads (board_id, title, created_by_user_id, created_at, updated_at) VALUES (?, ?, ?, NOW(), NOW())"
|
||||||
_, err := db.Exec(query, thread.BoardID, thread.Title, thread.CreatedByUserID)
|
_, err := db.Exec(query, thread.BoardID, thread.Title, thread.CreatedByUserID)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
339
models/user.go
339
models/user.go
|
|
@ -1,161 +1,202 @@
|
||||||
package models
|
package models
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/sha256"
|
"crypto/rand"
|
||||||
"database/sql"
|
"crypto/sha256"
|
||||||
"fmt"
|
"database/sql"
|
||||||
"time"
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
ID int
|
ID int
|
||||||
Username string
|
Username string
|
||||||
DisplayName string
|
DisplayName string
|
||||||
PfpURL string
|
PfpFileID sql.NullInt64
|
||||||
Bio string
|
Bio string
|
||||||
AuthenticationString string
|
AuthenticationString string
|
||||||
AuthenticationSalt string
|
AuthenticationSalt string
|
||||||
AuthenticationAlgorithm string
|
AuthenticationAlgorithm string
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
Verified bool
|
Verified bool
|
||||||
Permissions int64
|
Permissions int64
|
||||||
}
|
|
||||||
|
|
||||||
func GetUserByID(db *sql.DB, id int) (*User, error) {
|
|
||||||
query := "SELECT id, username, display_name, pfp_url, bio, authentication_string, authentication_salt, authentication_algorithm, created_at, updated_at, verified, permissions FROM users WHERE id = ?"
|
|
||||||
row := db.QueryRow(query, id)
|
|
||||||
user := &User{}
|
|
||||||
var displayName sql.NullString
|
|
||||||
var pfpURL sql.NullString
|
|
||||||
var bio sql.NullString
|
|
||||||
var createdAtString sql.NullString
|
|
||||||
var updatedAtString sql.NullString
|
|
||||||
err := row.Scan(&user.ID, &user.Username, &displayName, &pfpURL, &bio, &user.AuthenticationString, &user.AuthenticationSalt, &user.AuthenticationAlgorithm, &createdAtString, &updatedAtString, &user.Verified, &user.Permissions)
|
|
||||||
if err == sql.ErrNoRows {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if displayName.Valid {
|
|
||||||
user.DisplayName = displayName.String
|
|
||||||
} else {
|
|
||||||
user.DisplayName = ""
|
|
||||||
}
|
|
||||||
if pfpURL.Valid {
|
|
||||||
user.PfpURL = pfpURL.String
|
|
||||||
} else {
|
|
||||||
user.PfpURL = ""
|
|
||||||
}
|
|
||||||
if bio.Valid {
|
|
||||||
user.Bio = bio.String
|
|
||||||
} else {
|
|
||||||
user.Bio = ""
|
|
||||||
}
|
|
||||||
if createdAtString.Valid {
|
|
||||||
user.CreatedAt, err = time.Parse("2006-01-02 15:04:05", createdAtString.String)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("error parsing created_at: %v", err)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
user.CreatedAt = time.Time{}
|
|
||||||
}
|
|
||||||
if updatedAtString.Valid {
|
|
||||||
user.UpdatedAt, err = time.Parse("2006-01-02 15:04:05", updatedAtString.String)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("error parsing updated_at: %v", err)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
user.UpdatedAt = time.Time{}
|
|
||||||
}
|
|
||||||
return user, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetUserByUsername(db *sql.DB, username string) (*User, error) {
|
|
||||||
query := "SELECT id, username, display_name, pfp_url, bio, authentication_string, authentication_salt, authentication_algorithm, created_at, updated_at, verified, permissions FROM users WHERE username = ?"
|
|
||||||
row := db.QueryRow(query, username)
|
|
||||||
user := &User{}
|
|
||||||
var displayName sql.NullString
|
|
||||||
var pfpURL sql.NullString
|
|
||||||
var bio sql.NullString
|
|
||||||
var createdAtString sql.NullString
|
|
||||||
var updatedAtString sql.NullString
|
|
||||||
err := row.Scan(&user.ID, &user.Username, &displayName, &pfpURL, &bio, &user.AuthenticationString, &user.AuthenticationSalt, &user.AuthenticationAlgorithm, &createdAtString, &updatedAtString, &user.Verified, &user.Permissions)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if displayName.Valid {
|
|
||||||
user.DisplayName = displayName.String
|
|
||||||
} else {
|
|
||||||
user.DisplayName = ""
|
|
||||||
}
|
|
||||||
if pfpURL.Valid {
|
|
||||||
user.PfpURL = pfpURL.String
|
|
||||||
} else {
|
|
||||||
user.PfpURL = ""
|
|
||||||
}
|
|
||||||
if bio.Valid {
|
|
||||||
user.Bio = bio.String
|
|
||||||
} else {
|
|
||||||
user.Bio = ""
|
|
||||||
}
|
|
||||||
if createdAtString.Valid {
|
|
||||||
user.CreatedAt, err = time.Parse("2006-01-02 15:04:05", createdAtString.String)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("error parsing created_at: %v", err)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
user.CreatedAt = time.Time{}
|
|
||||||
}
|
|
||||||
if updatedAtString.Valid {
|
|
||||||
user.UpdatedAt, err = time.Parse("2006-01-02 15:04:05", updatedAtString.String)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("error parsing updated_at: %v", err)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
user.UpdatedAt = time.Time{}
|
|
||||||
}
|
|
||||||
return user, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func CheckPassword(password, salt, algorithm, hash string) bool {
|
|
||||||
if algorithm != "sha256" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
computedHash := HashPassword(password, salt, algorithm)
|
|
||||||
return computedHash == hash
|
|
||||||
}
|
|
||||||
|
|
||||||
func HashPassword(password, salt, algorithm string) string {
|
|
||||||
if algorithm != "sha256" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
data := password + salt
|
|
||||||
hash := sha256.Sum256([]byte(data))
|
|
||||||
return fmt.Sprintf("%x", hash)
|
|
||||||
}
|
|
||||||
|
|
||||||
func CreateUser(db *sql.DB, username, password string) error {
|
|
||||||
salt := "random-salt" // Replace with secure random generation
|
|
||||||
algorithm := "sha256"
|
|
||||||
hash := HashPassword(password, salt, algorithm)
|
|
||||||
query := "INSERT INTO users (username, authentication_string, authentication_salt, authentication_algorithm, created_at, updated_at, verified, permissions) VALUES (?, ?, ?, ?, NOW(), NOW(), ?, 0)"
|
|
||||||
_, err := db.Exec(query, username, hash, salt, algorithm, false)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func UpdateUserProfile(db *sql.DB, userID int, displayName, pfpURL, bio string) error {
|
|
||||||
query := "UPDATE users SET display_name = ?, pfp_url = ?, bio = ?, updated_at = NOW() WHERE id = ?"
|
|
||||||
_, err := db.Exec(query, displayName, pfpURL, bio, userID)
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
PermCreateBoard int64 = 1 << 0
|
// bcryptCost defines the work factor for bcrypt password hashing.
|
||||||
PermManageUsers int64 = 1 << 1
|
// 12 is a reasonable default; increase for stronger machines.
|
||||||
|
bcryptCost = 12
|
||||||
|
)
|
||||||
|
|
||||||
|
func GetUserByID(db *sql.DB, id int) (*User, error) {
|
||||||
|
query := "SELECT id, username, display_name, pfp_file_id, bio, authentication_string, authentication_salt, authentication_algorithm, created_at, updated_at, verified, permissions FROM users WHERE id = ?"
|
||||||
|
row := db.QueryRow(query, id)
|
||||||
|
user := &User{}
|
||||||
|
var displayName sql.NullString
|
||||||
|
var bio sql.NullString
|
||||||
|
var createdAtString sql.NullString
|
||||||
|
var updatedAtString sql.NullString
|
||||||
|
err := row.Scan(&user.ID, &user.Username, &displayName, &user.PfpFileID, &bio, &user.AuthenticationString, &user.AuthenticationSalt, &user.AuthenticationAlgorithm, &createdAtString, &updatedAtString, &user.Verified, &user.Permissions)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if displayName.Valid {
|
||||||
|
user.DisplayName = displayName.String
|
||||||
|
} else {
|
||||||
|
user.DisplayName = ""
|
||||||
|
}
|
||||||
|
if bio.Valid {
|
||||||
|
user.Bio = bio.String
|
||||||
|
} else {
|
||||||
|
user.Bio = ""
|
||||||
|
}
|
||||||
|
if createdAtString.Valid {
|
||||||
|
user.CreatedAt, err = time.Parse("2006-01-02 15:04:05", createdAtString.String)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error parsing created_at: %v", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
user.CreatedAt = time.Time{}
|
||||||
|
}
|
||||||
|
if updatedAtString.Valid {
|
||||||
|
user.UpdatedAt, err = time.Parse("2006-01-02 15:04:05", updatedAtString.String)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error parsing updated_at: %v", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
user.UpdatedAt = time.Time{}
|
||||||
|
}
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetUserByUsername(db *sql.DB, username string) (*User, error) {
|
||||||
|
query := "SELECT id, username, display_name, pfp_file_id, bio, authentication_string, authentication_salt, authentication_algorithm, created_at, updated_at, verified, permissions FROM users WHERE username = ?"
|
||||||
|
row := db.QueryRow(query, username)
|
||||||
|
user := &User{}
|
||||||
|
var displayName sql.NullString
|
||||||
|
var bio sql.NullString
|
||||||
|
var createdAtString sql.NullString
|
||||||
|
var updatedAtString sql.NullString
|
||||||
|
err := row.Scan(&user.ID, &user.Username, &displayName, &user.PfpFileID, &bio, &user.AuthenticationString, &user.AuthenticationSalt, &user.AuthenticationAlgorithm, &createdAtString, &updatedAtString, &user.Verified, &user.Permissions)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if displayName.Valid {
|
||||||
|
user.DisplayName = displayName.String
|
||||||
|
} else {
|
||||||
|
user.DisplayName = ""
|
||||||
|
}
|
||||||
|
if bio.Valid {
|
||||||
|
user.Bio = bio.String
|
||||||
|
} else {
|
||||||
|
user.Bio = ""
|
||||||
|
}
|
||||||
|
if createdAtString.Valid {
|
||||||
|
user.CreatedAt, err = time.Parse("2006-01-02 15:04:05", createdAtString.String)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error parsing created_at: %v", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
user.CreatedAt = time.Time{}
|
||||||
|
}
|
||||||
|
if updatedAtString.Valid {
|
||||||
|
user.UpdatedAt, err = time.Parse("2006-01-02 15:04:05", updatedAtString.String)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error parsing updated_at: %v", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
user.UpdatedAt = time.Time{}
|
||||||
|
}
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func CheckPassword(password, salt, algorithm, hash string) bool {
|
||||||
|
switch algorithm {
|
||||||
|
case "bcrypt":
|
||||||
|
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
|
||||||
|
case "sha256":
|
||||||
|
computedHash := HashPassword(password, salt, algorithm)
|
||||||
|
return computedHash == hash
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func HashPassword(password, salt, algorithm string) string {
|
||||||
|
switch algorithm {
|
||||||
|
case "sha256":
|
||||||
|
data := password + salt
|
||||||
|
hash := sha256.Sum256([]byte(data))
|
||||||
|
return fmt.Sprintf("%x", hash)
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreateUser(db *sql.DB, username, password string) error {
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Keep salt column for backward compatibility, or else this apparently shits itself
|
||||||
|
var saltBytes [16]byte
|
||||||
|
if _, err := rand.Read(saltBytes[:]); err != nil {
|
||||||
|
return fmt.Errorf("failed to generate salt: %w", err)
|
||||||
|
}
|
||||||
|
salt := fmt.Sprintf("%x", saltBytes)
|
||||||
|
|
||||||
|
query := "INSERT INTO users (username, authentication_string, authentication_salt, authentication_algorithm, created_at, updated_at, verified, permissions) VALUES (?, ?, ?, ?, NOW(), NOW(), ?, 0)"
|
||||||
|
_, err = db.Exec(query, username, string(hash), salt, "bcrypt", false)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func UpdateUserProfile(db *sql.DB, userID int, displayName, bio string) error {
|
||||||
|
query := "UPDATE users SET display_name = ?, bio = ?, updated_at = NOW() WHERE id = ?"
|
||||||
|
_, err := db.Exec(query, displayName, bio, userID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func UpdateUserPfp(db *sql.DB, userID int, pfpFileID int64) error {
|
||||||
|
query := "UPDATE users SET pfp_file_id = ? WHERE id = ?"
|
||||||
|
_, err := db.Exec(query, pfpFileID, userID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
PermCreateBoard int64 = 1 << 0
|
||||||
|
PermManageUsers int64 = 1 << 1
|
||||||
)
|
)
|
||||||
|
|
||||||
func HasGlobalPermission(user *User, perm int64) bool {
|
func HasGlobalPermission(user *User, perm int64) bool {
|
||||||
return user.Permissions&perm != 0
|
return user.Permissions&perm != 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func GetUsernamesInBoard(db *sql.DB, boardID int) ([]string, error) {
|
||||||
|
query := `
|
||||||
|
SELECT DISTINCT u.username
|
||||||
|
FROM users u
|
||||||
|
JOIN chat_messages cm ON u.id = cm.user_id
|
||||||
|
WHERE cm.board_id = ?
|
||||||
|
ORDER BY u.username ASC`
|
||||||
|
rows, err := db.Query(query, boardID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var usernames []string
|
||||||
|
for rows.Next() {
|
||||||
|
var username string
|
||||||
|
if err := rows.Scan(&username); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
usernames = append(usernames, username)
|
||||||
|
}
|
||||||
|
return usernames, nil
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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 + ' ✓';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
910
static/style.css
910
static/style.css
File diff suppressed because it is too large
Load Diff
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,13 +4,20 @@
|
||||||
<head>
|
<head>
|
||||||
<title>{{.Title}}</title>
|
<title>{{.Title}}</title>
|
||||||
<link rel="stylesheet" href="{{.StaticPath}}/style.css">
|
<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>
|
</head>
|
||||||
<body>
|
<body{{if .BodyClass}} class="{{.BodyClass}}"{{end}}>
|
||||||
{{template "navbar" .}}
|
{{template "navbar" .}}
|
||||||
<main>
|
<main>
|
||||||
{{block "content" .}}{{end}} <!-- Define a block for content -->
|
{{template .ContentTemplate .}}
|
||||||
</main>
|
</main>
|
||||||
{{template "cookie_banner" .}}
|
{{template "cookie_banner" .}}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
<head>
|
<head>
|
||||||
<title>{{.Title}}</title>
|
<title>{{.Title}}</title>
|
||||||
<link rel="stylesheet" href="{{.StaticPath}}/style.css">
|
<link rel="stylesheet" href="{{.StaticPath}}/style.css">
|
||||||
|
<script src="{{.StaticPath}}/app.js" defer></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
{{template "navbar" .}}
|
{{template "navbar" .}}
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,19 @@
|
||||||
<head>
|
<head>
|
||||||
<title>{{.Title}}</title>
|
<title>{{.Title}}</title>
|
||||||
<link rel="stylesheet" href="{{.StaticPath}}/style.css">
|
<link rel="stylesheet" href="{{.StaticPath}}/style.css">
|
||||||
|
<script src="{{.StaticPath}}/app.js" defer></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
{{template "navbar" .}}
|
{{template "navbar" .}}
|
||||||
<main>
|
<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>
|
<header>
|
||||||
<h2>{{.Board.Name}}</h2>
|
<h2>{{.Board.Name}}</h2>
|
||||||
<p>{{.Board.Description}}</p>
|
<p>{{.Board.Description}}</p>
|
||||||
|
|
@ -15,9 +24,12 @@
|
||||||
<section>
|
<section>
|
||||||
<h3>Threads</h3>
|
<h3>Threads</h3>
|
||||||
{{if .Threads}}
|
{{if .Threads}}
|
||||||
<ul>
|
<ul class="thread-list">
|
||||||
{{range .Threads}}
|
{{range .Threads}}
|
||||||
<li><a href="{{$.BasePath}}/thread/?id={{.ID}}">{{.Title}}</a> - Updated on {{.UpdatedAt.Format "02/01/2006 - 15:04"}}</li>
|
<li class="thread-item">
|
||||||
|
<a href="{{$.BasePath}}/thread/?id={{.ID}}">{{.Title}}</a>
|
||||||
|
<p class="thread-info" data-timestamp="{{.UpdatedAt.Format "2006-01-02T15:04:05Z07:00"}}">Updated on {{.UpdatedAt.Format "02/01/2006 - 15:04"}}</p>
|
||||||
|
</li>
|
||||||
{{end}}
|
{{end}}
|
||||||
</ul>
|
</ul>
|
||||||
{{else}}
|
{{else}}
|
||||||
|
|
@ -29,7 +41,7 @@
|
||||||
<h3>Create New Thread</h3>
|
<h3>Create New Thread</h3>
|
||||||
<form method="post" action="{{.BasePath}}/board/?id={{.Board.ID}}&action=create_thread">
|
<form method="post" action="{{.BasePath}}/board/?id={{.Board.ID}}&action=create_thread">
|
||||||
<label for="title">Thread Title:</label>
|
<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">
|
<input type="submit" value="Create Thread">
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
<head>
|
<head>
|
||||||
<title>{{.Title}}</title>
|
<title>{{.Title}}</title>
|
||||||
<link rel="stylesheet" href="{{.StaticPath}}/style.css">
|
<link rel="stylesheet" href="{{.StaticPath}}/style.css">
|
||||||
|
<script src="{{.StaticPath}}/app.js" defer></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
{{template "navbar" .}}
|
{{template "navbar" .}}
|
||||||
|
|
@ -17,7 +18,11 @@
|
||||||
<ul class="board-list">
|
<ul class="board-list">
|
||||||
{{range .PublicBoards}}
|
{{range .PublicBoards}}
|
||||||
<li class="board-item">
|
<li class="board-item">
|
||||||
|
{{if eq .Type "chat"}}
|
||||||
|
<a href="{{$.BasePath}}/chat/?id={{.ID}}">{{.Name}} (Chat)</a>
|
||||||
|
{{else}}
|
||||||
<a href="{{$.BasePath}}/board/?id={{.ID}}">{{.Name}}</a>
|
<a href="{{$.BasePath}}/board/?id={{.ID}}">{{.Name}}</a>
|
||||||
|
{{end}}
|
||||||
<p class="board-desc">{{.Description}}</p>
|
<p class="board-desc">{{.Description}}</p>
|
||||||
</li>
|
</li>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
@ -33,7 +38,11 @@
|
||||||
<ul class="board-list">
|
<ul class="board-list">
|
||||||
{{range .PrivateBoards}}
|
{{range .PrivateBoards}}
|
||||||
<li class="board-item">
|
<li class="board-item">
|
||||||
|
{{if eq .Type "chat"}}
|
||||||
|
<a href="{{$.BasePath}}/chat/?id={{.ID}}">{{.Name}} (Chat)</a>
|
||||||
|
{{else}}
|
||||||
<a href="{{$.BasePath}}/board/?id={{.ID}}">{{.Name}}</a>
|
<a href="{{$.BasePath}}/board/?id={{.ID}}">{{.Name}}</a>
|
||||||
|
{{end}}
|
||||||
<p class="board-desc">{{.Description}}</p>
|
<p class="board-desc">{{.Description}}</p>
|
||||||
</li>
|
</li>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
@ -48,9 +57,14 @@
|
||||||
<h3>Create New Public Board</h3>
|
<h3>Create New Public Board</h3>
|
||||||
<form method="post" action="{{.BasePath}}/boards/">
|
<form method="post" action="{{.BasePath}}/boards/">
|
||||||
<label for="name">Board Name:</label>
|
<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>
|
<label for="description">Description:</label>
|
||||||
<textarea id="description" name="description"></textarea><br>
|
<textarea id="description" name="description"></textarea><br>
|
||||||
|
<label for="type">Board Type:</label>
|
||||||
|
<select id="type" name="type">
|
||||||
|
<option value="classic">Classic Board</option>
|
||||||
|
<option value="chat">Chat Board</option>
|
||||||
|
</select><br>
|
||||||
<input type="submit" value="Create Board">
|
<input type="submit" value="Create Board">
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -1,435 +1,60 @@
|
||||||
{{define "chat"}}
|
{{define "chat"}}{{template "base" .}}{{end}}
|
||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
{{define "chat-content"}}
|
||||||
<head>
|
<div class="chat-container" data-board-id="{{.Board.ID}}" data-base-path="{{.BasePath}}" data-current-username="{{.CurrentUsername}}">
|
||||||
<title>{{.Title}}</title>
|
<div class="chat-breadcrumb">
|
||||||
<link rel="stylesheet" href="{{.StaticPath}}/style.css">
|
<a href="{{.BasePath}}/">Home</a>
|
||||||
<style>
|
<span class="chat-breadcrumb-separator">›</span>
|
||||||
body {
|
<a href="{{.BasePath}}/boards/">Boards</a>
|
||||||
margin: 0;
|
<span class="chat-breadcrumb-separator">›</span>
|
||||||
padding: 0;
|
<span>{{.Board.Name}}</span>
|
||||||
height: 100vh;
|
</div>
|
||||||
overflow: hidden;
|
<header class="chat-header">
|
||||||
}
|
<div class="connection-status">
|
||||||
main {
|
<div class="connection-dot connecting" id="connection-dot"></div>
|
||||||
padding: 0;
|
<span id="connection-text">Connecting...</span>
|
||||||
margin-top: 3em; /* Space for navbar */
|
</div>
|
||||||
height: calc(100vh - 3em);
|
<h2>{{.Board.Name}}</h2>
|
||||||
display: flex;
|
<p>{{.Board.Description}}</p>
|
||||||
flex-direction: column;
|
</header>
|
||||||
align-items: center;
|
<div class="chat-messages" id="chat-messages">
|
||||||
}
|
<div class="typing-indicator" id="typing-indicator">
|
||||||
.chat-container {
|
<span id="typing-users"></span><span class="typing-dots"><span>.</span><span>.</span><span>.</span></span>
|
||||||
width: 100%;
|
</div>
|
||||||
height: calc(100% - 2em); /* Adjust for header */
|
{{range .Messages}}
|
||||||
display: flex;
|
<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}}">
|
||||||
flex-direction: column;
|
<div class="chat-message-header">
|
||||||
border: none;
|
{{if .PfpFileID.Valid}}
|
||||||
border-radius: 0;
|
<img src="{{$.BasePath}}/file?id={{.PfpFileID.Int64}}" alt="PFP" class="chat-message-pfp">
|
||||||
background-color: #fef6e4;
|
{{else}}
|
||||||
box-shadow: none;
|
<div class="chat-message-pfp" style="background-color: #001858;"></div>
|
||||||
}
|
|
||||||
.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>
|
|
||||||
{{end}}
|
{{end}}
|
||||||
|
<span class="chat-message-username">{{.Username}}</span>
|
||||||
|
<span class="chat-message-timestamp">{{.Timestamp.Format "02/01/2006 15:04"}}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="chat-input">
|
{{if gt .ReplyTo 0}}
|
||||||
<div id="reply-indicator" class="reply-indicator">
|
<div class="chat-message-reply" onclick="scrollToMessage({{.ReplyTo}})">Replying to message...</div>
|
||||||
<span id="reply-username">Replying to </span>
|
{{end}}
|
||||||
<button onclick="cancelReply()">X</button>
|
<div class="chat-message-content">{{.Content}}</div>
|
||||||
</div>
|
<div class="post-actions">
|
||||||
<textarea id="chat-input-text" placeholder="Type a message..."></textarea>
|
<a href="javascript:void(0)" onclick="replyToMessage({{printf "%v" .ID}}, '{{.Username}}')">Reply</a>
|
||||||
<button onclick="sendMessage()">Send</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="autocomplete-popup" class="autocomplete-popup"></div>
|
{{end}}
|
||||||
</main>
|
</div>
|
||||||
{{template "cookie_banner" .}}
|
<button class="jump-to-bottom" id="jump-to-bottom" title="Jump to bottom" type="button">
|
||||||
<script>
|
↓
|
||||||
let ws;
|
<span class="unread-badge" id="unread-badge" style="display: none;">0</span>
|
||||||
let autocompleteActive = false;
|
</button>
|
||||||
let autocompletePrefix = '';
|
<div class="chat-input">
|
||||||
let replyToId = -1;
|
<div id="reply-indicator" class="reply-indicator">
|
||||||
let replyUsername = '';
|
<span id="reply-username">Replying to </span>
|
||||||
|
<button onclick="cancelReply()" type="button">X</button>
|
||||||
function connectWebSocket() {
|
</div>
|
||||||
ws = new WebSocket('ws://' + window.location.host + '{{.BasePath}}/chat/?ws=true', [], { credentials: 'include' });
|
<textarea id="chat-input-text" placeholder="Type a message..."></textarea>
|
||||||
ws.onmessage = function(event) {
|
<button onclick="sendMessage()" type="button">Send</button>
|
||||||
const msg = JSON.parse(event.data);
|
</div>
|
||||||
appendMessage(msg);
|
</div>
|
||||||
};
|
<div id="autocomplete-popup" class="autocomplete-popup"></div>
|
||||||
ws.onclose = function() {
|
<script type="application/json" id="chat-usernames">{{.AllUsernames}}</script>
|
||||||
console.log("WebSocket closed, reconnecting...");
|
{{end}}
|
||||||
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}}
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
<head>
|
<head>
|
||||||
<title>{{.Title}}</title>
|
<title>{{.Title}}</title>
|
||||||
<link rel="stylesheet" href="{{.StaticPath}}/style.css">
|
<link rel="stylesheet" href="{{.StaticPath}}/style.css">
|
||||||
|
<script src="{{.StaticPath}}/app.js" defer></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
{{template "navbar" .}}
|
{{template "navbar" .}}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
<head>
|
<head>
|
||||||
<title>{{.Title}}</title>
|
<title>{{.Title}}</title>
|
||||||
<link rel="stylesheet" href="{{.StaticPath}}/style.css">
|
<link rel="stylesheet" href="{{.StaticPath}}/style.css">
|
||||||
|
<script src="{{.StaticPath}}/app.js" defer></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
{{template "navbar" .}}
|
{{template "navbar" .}}
|
||||||
|
|
@ -13,13 +14,13 @@
|
||||||
</header>
|
</header>
|
||||||
<section>
|
<section>
|
||||||
{{if .Error}}
|
{{if .Error}}
|
||||||
<p style="color: red;">{{.Error}}</p>
|
<p class="field-error" style="text-align: center; font-size: 1em;">{{.Error}}</p>
|
||||||
{{end}}
|
{{end}}
|
||||||
<form method="post" action="{{.BasePath}}/login/">
|
<form method="post" action="{{.BasePath}}/login/">
|
||||||
<label for="username">Username:</label>
|
<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>
|
<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">
|
<input type="submit" value="Login">
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
<head>
|
<head>
|
||||||
<title>{{.Title}}</title>
|
<title>{{.Title}}</title>
|
||||||
<link rel="stylesheet" href="{{.StaticPath}}/style.css">
|
<link rel="stylesheet" href="{{.StaticPath}}/style.css">
|
||||||
|
<script src="{{.StaticPath}}/app.js" defer></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
{{template "navbar" .}}
|
{{template "navbar" .}}
|
||||||
|
|
@ -15,7 +16,7 @@
|
||||||
{{if .News}}
|
{{if .News}}
|
||||||
<ul>
|
<ul>
|
||||||
{{range .News}}
|
{{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>
|
<p>{{.Content}}</p>
|
||||||
{{if $.IsAdmin}}
|
{{if $.IsAdmin}}
|
||||||
<form method="post" action="{{$.BasePath}}/news/?action=delete&id={{.ID}}" style="display:inline;">
|
<form method="post" action="{{$.BasePath}}/news/?action=delete&id={{.ID}}" style="display:inline;">
|
||||||
|
|
@ -34,7 +35,7 @@
|
||||||
<h3>Post New Announcement</h3>
|
<h3>Post New Announcement</h3>
|
||||||
<form method="post" action="{{.BasePath}}/news/">
|
<form method="post" action="{{.BasePath}}/news/">
|
||||||
<label for="title">Title:</label>
|
<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>
|
<label for="content">Content:</label>
|
||||||
<textarea id="content" name="content" required></textarea><br>
|
<textarea id="content" name="content" required></textarea><br>
|
||||||
<input type="submit" value="Post News">
|
<input type="submit" value="Post News">
|
||||||
|
|
|
||||||
|
|
@ -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}}
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
<head>
|
<head>
|
||||||
<title>{{.Title}}</title>
|
<title>{{.Title}}</title>
|
||||||
<link rel="stylesheet" href="{{.StaticPath}}/style.css">
|
<link rel="stylesheet" href="{{.StaticPath}}/style.css">
|
||||||
|
<script src="{{.StaticPath}}/app.js" defer></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
{{template "navbar" .}}
|
{{template "navbar" .}}
|
||||||
|
|
@ -14,12 +15,12 @@
|
||||||
<section>
|
<section>
|
||||||
<p>Username: {{.User.Username}}</p>
|
<p>Username: {{.User.Username}}</p>
|
||||||
<p>Display Name: {{.DisplayName}}</p>
|
<p>Display Name: {{.DisplayName}}</p>
|
||||||
{{if .User.PfpURL}}
|
{{if .User.PfpFileID.Valid}}
|
||||||
<img src="{{.User.PfpURL}}" alt="Profile Picture">
|
<img src="{{.BasePath}}/file?id={{.User.PfpFileID.Int64}}" alt="Profile Picture">
|
||||||
{{end}}
|
{{end}}
|
||||||
<p>Bio: {{.User.Bio}}</p>
|
<p>Bio: {{.User.Bio}}</p>
|
||||||
<p>Joined: {{.User.CreatedAt}}</p>
|
<p data-timestamp="{{.User.CreatedAt.Format "2006-01-02T15:04:05Z07:00"}}">Joined: {{.User.CreatedAt}}</p>
|
||||||
<p>Last Updated: {{.User.UpdatedAt}}</p>
|
<p data-timestamp="{{.User.UpdatedAt.Format "2006-01-02T15:04:05Z07:00"}}">Last Updated: {{.User.UpdatedAt}}</p>
|
||||||
<p>Verified: {{.User.Verified}}</p>
|
<p>Verified: {{.User.Verified}}</p>
|
||||||
<a href="{{.BasePath}}/profile/edit/">Edit Profile</a>
|
<a href="{{.BasePath}}/profile/edit/">Edit Profile</a>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -27,4 +28,4 @@
|
||||||
{{template "cookie_banner" .}}
|
{{template "cookie_banner" .}}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
<head>
|
<head>
|
||||||
<title>{{.Title}}</title>
|
<title>{{.Title}}</title>
|
||||||
<link rel="stylesheet" href="{{.StaticPath}}/style.css">
|
<link rel="stylesheet" href="{{.StaticPath}}/style.css">
|
||||||
|
<script src="{{.StaticPath}}/app.js" defer></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
{{template "navbar" .}}
|
{{template "navbar" .}}
|
||||||
|
|
@ -12,13 +13,13 @@
|
||||||
<h2>Edit Profile</h2>
|
<h2>Edit Profile</h2>
|
||||||
</header>
|
</header>
|
||||||
<section>
|
<section>
|
||||||
<form method="post" action="{{.BasePath}}/profile/edit/">
|
<form method="post" action="{{.BasePath}}/profile/edit/" enctype="multipart/form-data">
|
||||||
<label for="display_name">Display Name:</label>
|
<label for="display_name">Display Name:</label>
|
||||||
<input type="text" id="display_name" name="display_name" value="{{.User.DisplayName}}"><br>
|
<input type="text" id="display_name" name="display_name" value="{{.User.DisplayName}}" maxlength="255"><br>
|
||||||
<label for="pfp_url">Profile Picture URL:</label>
|
<label for="pfp">Profile Picture:</label>
|
||||||
<input type="text" id="pfp_url" name="pfp_url" value="{{.User.PfpURL}}"><br>
|
<input type="file" id="pfp" name="pfp" accept="image/*"><br>
|
||||||
<label for="bio">Bio:</label>
|
<label for="bio">Bio:</label>
|
||||||
<textarea id="bio" name="bio">{{.User.Bio}}</textarea><br>
|
<textarea id="bio" name="bio" maxlength="500">{{.User.Bio}}</textarea><br>
|
||||||
<input type="submit" value="Save">
|
<input type="submit" value="Save">
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -26,4 +27,4 @@
|
||||||
{{template "cookie_banner" .}}
|
{{template "cookie_banner" .}}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
<head>
|
<head>
|
||||||
<title>{{.Title}}</title>
|
<title>{{.Title}}</title>
|
||||||
<link rel="stylesheet" href="{{.StaticPath}}/style.css">
|
<link rel="stylesheet" href="{{.StaticPath}}/style.css">
|
||||||
|
<script src="{{.StaticPath}}/app.js" defer></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
{{template "navbar" .}}
|
{{template "navbar" .}}
|
||||||
|
|
@ -13,13 +14,15 @@
|
||||||
</header>
|
</header>
|
||||||
<section>
|
<section>
|
||||||
{{if .Error}}
|
{{if .Error}}
|
||||||
<p style="color: red;">{{.Error}}</p>
|
<p class="field-error" style="text-align: center; font-size: 1em;">{{.Error}}</p>
|
||||||
{{end}}
|
{{end}}
|
||||||
<form method="post" action="{{.BasePath}}/signup/">
|
<form method="post" action="{{.BasePath}}/signup/">
|
||||||
<label for="username">Username:</label>
|
<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>
|
<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">
|
<input type="submit" value="Sign Up">
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,21 @@
|
||||||
<head>
|
<head>
|
||||||
<title>{{.Title}}</title>
|
<title>{{.Title}}</title>
|
||||||
<link rel="stylesheet" href="{{.StaticPath}}/style.css">
|
<link rel="stylesheet" href="{{.StaticPath}}/style.css">
|
||||||
|
<script src="{{.StaticPath}}/app.js" defer></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
{{template "navbar" .}}
|
{{template "navbar" .}}
|
||||||
<main>
|
<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>
|
<header>
|
||||||
<h2>{{.Thread.Title}}</h2>
|
<h2>{{.Thread.Title}}</h2>
|
||||||
</header>
|
</header>
|
||||||
|
|
@ -16,7 +27,7 @@
|
||||||
<article id="{{.ID}}" class="post-item" style="margin-left: {{if gt .ReplyTo 0}}20px{{else}}0px{{end}};">
|
<article id="{{.ID}}" class="post-item" style="margin-left: {{if gt .ReplyTo 0}}20px{{else}}0px{{end}};">
|
||||||
<header>
|
<header>
|
||||||
<h3>{{if .Title}}{{.Title}}{{else}}Post #{{.ID}}{{end}}</h3>
|
<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}}
|
{{if gt .ReplyTo 0}}
|
||||||
<p>Reply to post <a href="#{{.ReplyTo}}">{{.ReplyTo}}</a></p>
|
<p>Reply to post <a href="#{{.ReplyTo}}">{{.ReplyTo}}</a></p>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
<head>
|
<head>
|
||||||
<title>{{.Title}}</title>
|
<title>{{.Title}}</title>
|
||||||
<link rel="stylesheet" href="{{.StaticPath}}/style.css">
|
<link rel="stylesheet" href="{{.StaticPath}}/style.css">
|
||||||
|
<script src="{{.StaticPath}}/app.js" defer></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
{{template "navbar" .}}
|
{{template "navbar" .}}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
{{if .LoggedIn}}
|
{{if .LoggedIn}}
|
||||||
<li><a {{if eq .Navbar "userhome"}}class="active"{{end}} href="{{.BasePath}}/userhome/">User Home</a></li>
|
<li><a {{if eq .Navbar "userhome"}}class="active"{{end}} href="{{.BasePath}}/userhome/">User Home</a></li>
|
||||||
<li><a {{if eq .Navbar "profile"}}class="active"{{end}} href="{{.BasePath}}/profile/">Profile</a></li>
|
<li><a {{if eq .Navbar "profile"}}class="active"{{end}} href="{{.BasePath}}/profile/">Profile</a></li>
|
||||||
<li><a {{if eq .Navbar "chat"}}class="active"{{end}} href="{{.BasePath}}/chat/">Chat</a></li>
|
<li><a {{if eq .Navbar "preferences"}}class="active"{{end}} href="{{.BasePath}}/preferences/">Preferences</a></li>
|
||||||
<li><a href="{{.BasePath}}/logout/">Logout</a></li>
|
<li><a href="{{.BasePath}}/logout/">Logout</a></li>
|
||||||
{{else}}
|
{{else}}
|
||||||
<li><a {{if eq .Navbar "login"}}class="active"{{end}} href="{{.BasePath}}/login/">Login</a></li>
|
<li><a {{if eq .Navbar "login"}}class="active"{{end}} href="{{.BasePath}}/login/">Login</a></li>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue