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
parent
bdf81e7c68
commit
7b0528ef36
|
@ -1,5 +1,8 @@
|
||||||
config/config.json
|
config/config.json
|
||||||
config/about_page.htmlbody
|
config/about_page.htmlbody
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
files/
|
||||||
|
|
||||||
# nano
|
# nano
|
||||||
.swp
|
.swp
|
||||||
|
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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,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
64
main.go
|
@ -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))
|
||||||
|
|
|
@ -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, ×tampStr, &msg.Username, &msg.PfpFileID)
|
||||||
err := rows.Scan(&msg.ID, &msg.UserID, &msg.Content, &msg.ReplyTo, ×tampStr, &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, ×tampStr, &msg.Username, &msg.PfpFileID)
|
||||||
err := row.Scan(&msg.ID, &msg.UserID, &msg.Content, &msg.ReplyTo, ×tampStr, &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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
||||||
|
}
|
258
models/user.go
258
models/user.go
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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}}
|
||||||
|
|
|
@ -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}}
|
||||||
|
|
|
@ -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}}
|
||||||
|
|
Loading…
Reference in New Issue