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

@ -19,12 +19,13 @@ type PageData struct {
} }
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"`
} }
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,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
} }
} }
} }

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

@ -1,161 +1,155 @@
package models package models
import ( import (
"crypto/sha256" "crypto/sha256"
"database/sql" "database/sql"
"fmt" "fmt"
"time" "time"
) )
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) { 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, &user.PfpFileID, &bio, &user.AuthenticationString, &user.AuthenticationSalt, &user.AuthenticationAlgorithm, &createdAtString, &updatedAtString, &user.Verified, &user.Permissions)
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 {
if err == sql.ErrNoRows { return nil, nil
return nil, nil }
} if err != nil {
if err != nil { return nil, err
return nil, err }
} if displayName.Valid {
if displayName.Valid { user.DisplayName = displayName.String
user.DisplayName = displayName.String } else {
} else { user.DisplayName = ""
user.DisplayName = "" }
} if bio.Valid {
if pfpURL.Valid { user.Bio = bio.String
user.PfpURL = pfpURL.String } else {
} else { user.Bio = ""
user.PfpURL = "" }
} if createdAtString.Valid {
if bio.Valid { user.CreatedAt, err = time.Parse("2006-01-02 15:04:05", createdAtString.String)
user.Bio = bio.String if err != nil {
} else { return nil, fmt.Errorf("error parsing created_at: %v", err)
user.Bio = "" }
} } else {
if createdAtString.Valid { user.CreatedAt = time.Time{}
user.CreatedAt, err = time.Parse("2006-01-02 15:04:05", createdAtString.String) }
if err != nil { if updatedAtString.Valid {
return nil, fmt.Errorf("error parsing created_at: %v", err) user.UpdatedAt, err = time.Parse("2006-01-02 15:04:05", updatedAtString.String)
} if err != nil {
} else { return nil, fmt.Errorf("error parsing updated_at: %v", err)
user.CreatedAt = time.Time{} }
} } else {
if updatedAtString.Valid { user.UpdatedAt = time.Time{}
user.UpdatedAt, err = time.Parse("2006-01-02 15:04:05", updatedAtString.String) }
if err != nil { return user, 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) { 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, &user.PfpFileID, &bio, &user.AuthenticationString, &user.AuthenticationSalt, &user.AuthenticationAlgorithm, &createdAtString, &updatedAtString, &user.Verified, &user.Permissions)
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 {
if err != nil { return nil, err
return nil, err }
} if displayName.Valid {
if displayName.Valid { user.DisplayName = displayName.String
user.DisplayName = displayName.String } else {
} else { user.DisplayName = ""
user.DisplayName = "" }
} if bio.Valid {
if pfpURL.Valid { user.Bio = bio.String
user.PfpURL = pfpURL.String } else {
} else { user.Bio = ""
user.PfpURL = "" }
} if createdAtString.Valid {
if bio.Valid { user.CreatedAt, err = time.Parse("2006-01-02 15:04:05", createdAtString.String)
user.Bio = bio.String if err != nil {
} else { return nil, fmt.Errorf("error parsing created_at: %v", err)
user.Bio = "" }
} } else {
if createdAtString.Valid { user.CreatedAt = time.Time{}
user.CreatedAt, err = time.Parse("2006-01-02 15:04:05", createdAtString.String) }
if err != nil { if updatedAtString.Valid {
return nil, fmt.Errorf("error parsing created_at: %v", err) user.UpdatedAt, err = time.Parse("2006-01-02 15:04:05", updatedAtString.String)
} if err != nil {
} else { return nil, fmt.Errorf("error parsing updated_at: %v", err)
user.CreatedAt = time.Time{} }
} } else {
if updatedAtString.Valid { user.UpdatedAt = time.Time{}
user.UpdatedAt, err = time.Parse("2006-01-02 15:04:05", updatedAtString.String) }
if err != nil { return user, 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 { func CheckPassword(password, salt, algorithm, hash string) bool {
if algorithm != "sha256" { if algorithm != "sha256" {
return false return false
} }
computedHash := HashPassword(password, salt, algorithm) computedHash := HashPassword(password, salt, algorithm)
return computedHash == hash return computedHash == hash
} }
func HashPassword(password, salt, algorithm string) string { func HashPassword(password, salt, algorithm string) string {
if algorithm != "sha256" { if algorithm != "sha256" {
return "" return ""
} }
data := password + salt data := password + salt
hash := sha256.Sum256([]byte(data)) hash := sha256.Sum256([]byte(data))
return fmt.Sprintf("%x", hash) return fmt.Sprintf("%x", hash)
} }
func CreateUser(db *sql.DB, username, password string) error { func CreateUser(db *sql.DB, username, password string) error {
salt := "random-salt" // Replace with secure random generation salt := "random-salt" // Replace with secure random generation
algorithm := "sha256" algorithm := "sha256"
hash := HashPassword(password, salt, algorithm) 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)" 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) _, err := db.Exec(query, username, hash, salt, algorithm, false)
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 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 ( const (
PermCreateBoard int64 = 1 << 0 PermCreateBoard int64 = 1 << 0
PermManageUsers int64 = 1 << 1 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
} }

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) {
@ -432,4 +438,4 @@
</script> </script>
</body> </body>
</html> </html>
{{end}} {{end}}

View File

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

View File

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