feat: Add file-based avatar system

- A new `files` table to store metadata about uploaded files, including original name and hash.
- The `users` table is updated to reference a `pfp_file_id` from the new `files` table, removing the insecure `pfp_url` field.
- A new `/file` endpoint and handler (`handlers/file.go`) are created to serve files securely based on their ID, preventing direct file system access.
- Profile editing (`handlers/profile_edit.go` and `templates/pages/profile_edit.html`) is updated to handle file uploads instead of URL inputs.
- The chat feature (`models/chat.go` and `templates/pages/chat.html`) is updated to work with the new file ID system, ensuring avatars are displayed correctly.
Should also fix #68.
jocadbz
Joca 2025-06-21 16:21:21 -03:00
parent bdf81e7c68
commit 7b0528ef36
Signed by: jocadbz
GPG Key ID: B1836DCE2F50BDF7
12 changed files with 382 additions and 244 deletions

3
.gitignore vendored
View File

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

View File

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

View File

@ -25,6 +25,7 @@ type Config struct {
DBPassword string `json:"db_password"` DBPassword string `json:"db_password"`
DBDatabase string `json:"db_database"` DBDatabase string `json:"db_database"`
DBServerHost string `json:"db_svr_host"` DBServerHost string `json:"db_svr_host"`
FileStorageDir string `json:"file_storage_dir"`
} }
type App struct { type App struct {

32
handlers/file.go Normal file
View File

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

View File

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

64
main.go
View File

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

View File

@ -2,18 +2,19 @@ package models
import ( import (
"database/sql" "database/sql"
"regexp"
"time" "time"
) )
type ChatMessage struct { type ChatMessage struct {
ID int ID int `json:"id"`
UserID int UserID int `json:"userId"`
Content string Content string `json:"content"`
ReplyTo int // -1 if not a reply ReplyTo int `json:"replyTo"`
Timestamp time.Time Timestamp time.Time `json:"timestamp"`
Username string // For display, fetched from user Username string `json:"username"`
PfpURL string // For display, fetched from user PfpFileID sql.NullInt64 `json:"pfpFileId"`
Mentions []string // List of mentioned usernames Mentions []string `json:"mentions"`
} }
func CreateChatMessage(db *sql.DB, msg ChatMessage) error { func CreateChatMessage(db *sql.DB, msg ChatMessage) error {
@ -24,7 +25,7 @@ func CreateChatMessage(db *sql.DB, msg ChatMessage) error {
func GetRecentChatMessages(db *sql.DB, limit int) ([]ChatMessage, error) { func GetRecentChatMessages(db *sql.DB, 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
ORDER BY cm.timestamp DESC ORDER BY cm.timestamp DESC
@ -39,8 +40,7 @@ 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 err := rows.Scan(&msg.ID, &msg.UserID, &msg.Content, &msg.ReplyTo, &timestampStr, &msg.Username, &msg.PfpFileID)
err := rows.Scan(&msg.ID, &msg.UserID, &msg.Content, &msg.ReplyTo, &timestampStr, &msg.Username, &pfpURL)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -48,10 +48,6 @@ func GetRecentChatMessages(db *sql.DB, limit int) ([]ChatMessage, error) {
if err != nil { if err != nil {
msg.Timestamp = time.Time{} msg.Timestamp = time.Time{}
} }
if pfpURL.Valid {
msg.PfpURL = pfpURL.String
}
// Parse mentions from content (simple @username detection)
msg.Mentions = extractMentions(msg.Content) msg.Mentions = extractMentions(msg.Content)
messages = append(messages, msg) messages = append(messages, msg)
} }
@ -60,15 +56,14 @@ 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.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 err := row.Scan(&msg.ID, &msg.UserID, &msg.Content, &msg.ReplyTo, &timestampStr, &msg.Username, &msg.PfpFileID)
err := row.Scan(&msg.ID, &msg.UserID, &msg.Content, &msg.ReplyTo, &timestampStr, &msg.Username, &pfpURL)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return nil, nil return nil, nil
} }
@ -79,9 +74,6 @@ 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.PfpURL = pfpURL.String
}
msg.Mentions = extractMentions(msg.Content) msg.Mentions = extractMentions(msg.Content)
return &msg, nil return &msg, nil
} }
@ -107,26 +99,11 @@ func GetUsernamesMatching(db *sql.DB, prefix string) ([]string, error) {
// Simple utility to extract mentions from content // 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
} }

35
models/file.go Normal file
View File

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

View File

@ -11,7 +11,7 @@ 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
@ -23,15 +23,14 @@ type User struct {
} }
func GetUserByID(db *sql.DB, id int) (*User, error) { func GetUserByID(db *sql.DB, id int) (*User, error) {
query := "SELECT id, username, display_name, pfp_url, bio, authentication_string, authentication_salt, authentication_algorithm, created_at, updated_at, verified, permissions FROM users WHERE id = ?" query := "SELECT id, username, display_name, pfp_file_id, bio, authentication_string, authentication_salt, authentication_algorithm, created_at, updated_at, verified, permissions FROM users WHERE id = ?"
row := db.QueryRow(query, id) row := db.QueryRow(query, id)
user := &User{} user := &User{}
var displayName sql.NullString var displayName sql.NullString
var pfpURL sql.NullString
var bio sql.NullString var bio sql.NullString
var createdAtString sql.NullString var createdAtString sql.NullString
var updatedAtString sql.NullString var updatedAtString sql.NullString
err := row.Scan(&user.ID, &user.Username, &displayName, &pfpURL, &bio, &user.AuthenticationString, &user.AuthenticationSalt, &user.AuthenticationAlgorithm, &createdAtString, &updatedAtString, &user.Verified, &user.Permissions) 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 { if err == sql.ErrNoRows {
return nil, nil return nil, nil
} }
@ -43,11 +42,6 @@ func GetUserByID(db *sql.DB, id int) (*User, error) {
} else { } else {
user.DisplayName = "" user.DisplayName = ""
} }
if pfpURL.Valid {
user.PfpURL = pfpURL.String
} else {
user.PfpURL = ""
}
if bio.Valid { if bio.Valid {
user.Bio = bio.String user.Bio = bio.String
} else { } else {
@ -73,15 +67,14 @@ func GetUserByID(db *sql.DB, id int) (*User, error) {
} }
func GetUserByUsername(db *sql.DB, username string) (*User, error) { func GetUserByUsername(db *sql.DB, username string) (*User, error) {
query := "SELECT id, username, display_name, pfp_url, bio, authentication_string, authentication_salt, authentication_algorithm, created_at, updated_at, verified, permissions FROM users WHERE username = ?" query := "SELECT id, username, display_name, pfp_file_id, bio, authentication_string, authentication_salt, authentication_algorithm, created_at, updated_at, verified, permissions FROM users WHERE username = ?"
row := db.QueryRow(query, username) row := db.QueryRow(query, username)
user := &User{} user := &User{}
var displayName sql.NullString var displayName sql.NullString
var pfpURL sql.NullString
var bio sql.NullString var bio sql.NullString
var createdAtString sql.NullString var createdAtString sql.NullString
var updatedAtString sql.NullString var updatedAtString sql.NullString
err := row.Scan(&user.ID, &user.Username, &displayName, &pfpURL, &bio, &user.AuthenticationString, &user.AuthenticationSalt, &user.AuthenticationAlgorithm, &createdAtString, &updatedAtString, &user.Verified, &user.Permissions) 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 { if err != nil {
return nil, err return nil, err
} }
@ -90,11 +83,6 @@ func GetUserByUsername(db *sql.DB, username string) (*User, error) {
} else { } else {
user.DisplayName = "" user.DisplayName = ""
} }
if pfpURL.Valid {
user.PfpURL = pfpURL.String
} else {
user.PfpURL = ""
}
if bio.Valid { if bio.Valid {
user.Bio = bio.String user.Bio = bio.String
} else { } else {
@ -145,9 +133,15 @@ func CreateUser(db *sql.DB, username, password string) error {
return err return err
} }
func UpdateUserProfile(db *sql.DB, userID int, displayName, pfpURL, bio string) error { func UpdateUserProfile(db *sql.DB, userID int, displayName, bio string) error {
query := "UPDATE users SET display_name = ?, pfp_url = ?, bio = ?, updated_at = NOW() WHERE id = ?" query := "UPDATE users SET display_name = ?, bio = ?, updated_at = NOW() WHERE id = ?"
_, err := db.Exec(query, displayName, pfpURL, bio, userID) _, err := db.Exec(query, displayName, bio, userID)
return err
}
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 return err
} }

View File

@ -224,8 +224,8 @@
{{range .Messages}} {{range .Messages}}
<div class="chat-message" id="msg-{{.ID}}"> <div class="chat-message" id="msg-{{.ID}}">
<div class="chat-message-header"> <div class="chat-message-header">
{{if .PfpURL}} {{if .PfpFileID.Valid}}
<img src="{{.PfpURL}}" alt="PFP" class="chat-message-pfp"> <img src="{{$.BasePath}}/file?id={{.PfpFileID.Int64}}" alt="PFP" class="chat-message-pfp">
{{else}} {{else}}
<div class="chat-message-pfp" style="background-color: #001858;"></div> <div class="chat-message-pfp" style="background-color: #001858;"></div>
{{end}} {{end}}
@ -262,7 +262,7 @@
let replyUsername = ''; let replyUsername = '';
function connectWebSocket() { function connectWebSocket() {
ws = new WebSocket('ws://' + window.location.host + '{{.BasePath}}/chat/?ws=true', [], { credentials: 'include' }); ws = new WebSocket('ws://' + window.location.host + '{{.BasePath}}/chat/?ws=true');
ws.onmessage = function(event) { ws.onmessage = function(event) {
const msg = JSON.parse(event.data); const msg = JSON.parse(event.data);
appendMessage(msg); appendMessage(msg);
@ -298,21 +298,26 @@
const messages = document.getElementById('chat-messages'); const messages = document.getElementById('chat-messages');
const msgDiv = document.createElement('div'); const msgDiv = document.createElement('div');
msgDiv.className = 'chat-message'; msgDiv.className = 'chat-message';
msgDiv.id = 'msg-' + msg.ID; 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 pfpHTML = '';
let replyHTML = msg.ReplyTo > 0 ? `<div class="chat-message-reply" onclick="scrollToMessage(${msg.ReplyTo})">Replying to ${msg.Username}</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">`;
} else {
pfpHTML = `<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 // Process content for mentions
let content = msg.Content.replace(/@[\w]+/g, match => `<span class="chat-message-mention">${match}</span>`); let content = msg.content.replace(/@[\w]+/g, match => `<span class="chat-message-mention">${match}</span>`);
msgDiv.innerHTML = ` msgDiv.innerHTML = `
<div class="chat-message-header"> <div class="chat-message-header">
${pfpHTML} ${pfpHTML}
<span class="chat-message-username">${msg.Username}</span> <span class="chat-message-username">${msg.username}</span>
<span class="chat-message-timestamp">${new Date(msg.Timestamp).toLocaleString()}</span> <span class="chat-message-timestamp">${new Date(msg.timestamp).toLocaleString()}</span>
</div> </div>
${replyHTML} ${replyHTML}
<div class="chat-message-content">${content}</div> <div class="chat-message-content">${content}</div>
<div class="post-actions"> <div class="post-actions">
<a href="javascript:void(0)" onclick="replyToMessage(${msg.ID}, '${msg.Username}')">Reply</a> <a href="javascript:void(0)" onclick="replyToMessage(${msg.id}, '${msg.username}')">Reply</a>
</div> </div>
`; `;
messages.appendChild(msgDiv); messages.appendChild(msgDiv);
@ -382,6 +387,7 @@
if (atIndex !== -1 && (caretPos === text.length || text[caretPos] === ' ')) { if (atIndex !== -1 && (caretPos === text.length || text[caretPos] === ' ')) {
const prefix = text.substring(atIndex + 1, caretPos); const prefix = text.substring(atIndex + 1, caretPos);
autocompletePrefix = prefix; autocompletePrefix = prefix;
// TODO: Fix this.
const response = await fetch('{{.BasePath}}/chat/?autocomplete=true&prefix=' + encodeURIComponent(prefix)); const response = await fetch('{{.BasePath}}/chat/?autocomplete=true&prefix=' + encodeURIComponent(prefix));
const usernames = await response.json(); const usernames = await response.json();
if (usernames.length > 0) { if (usernames.length > 0) {

View File

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

View File

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