commit d2c73a5a13b5bf332afc052547349939c122ca2b Author: Jocadbz Date: Fri Mar 7 12:44:13 2025 -0300 Initial Commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..2842a38 --- /dev/null +++ b/README.md @@ -0,0 +1,136 @@ +# Welcome to ThreadR Rewritten +This is the source code for the ThreadR Forum Engine (rewritten in go). + +The project originated as a school project with the goal of developing a mix between a forum engine and a social media platform. When school was over, we left the project up for some time with the general intention to continue working on it until I took it down after an extended period of inactivity to host my own website on my server. + +Now, that it is being revived, the original scope of the project doesn’t really make sense anymore (at least to me) so it needs to shift slightly. Below is a list of goals that I would like to see achieved, feel free to discuss this in the issues or commit comments. + +- [ ] come back online (see issue #2) +- [ ] go FOSS (make the source code publicly available under a FOSS license (see issue #5)) +- [ ] make the code portable so everyone can set up their own instance +- [ ] get generic forum functionality going (sign-up, creation of boards, creation of threads within boards, messages, profiles) + +Once these two are given, here are some additional goals both from the original scope of the project as well as my own ideas. Input is welcome. + +- [ ] anonymous posts (users can choose to post anonymously, registered users will have a unique name per thread that stays the same so users can tell each other apart) +- [ ] subscribing to threads +- [ ] "split thread here" feature (kinda like on Reddit when multiple ppl answer to one person) +- [ ] automatic loading of new messages in threads (opt-out in settings) +- [ ] notifications for new messages in subscribed threads (opt-out in settings) +- [ ] question threads with an "accept answer" feature, threads can be marked as question threads on creation +- [ ] like/dislike feature but in better (as in more limited in functionality and more nuanced, kinda like on StackExchange but with two types of likes/dislikes and without showing an actual number) + +\- BodgeMaster + +UPDATE: The ThreadR Forum Engine is now technically host-independent. By default, it still contains the configuration for our local instance but all host-dependent stup information is configurable now. It is still heavily WIP. + +# Installation +First of all, keep in mind that the ThreadR Forum Engine is still in early development and things are subject to change. + +For now, the only way to set up an instance is doing it the manual way; automatic setup will be added in the future. + +This setup guide is assuming that you are on a UNIX-like system and have the following already installed and set up properly: +- Apache with PHP (will most likely also work on other web servers) +- MySQL or MariaDB +- Python 3 +- Bash + +Installation: + +- To install the ThreadR Forum Engine, clone this repository into a directory that the web server has access to but that it outside of any web root. +- Add a database to your MySQL/MariaDB server that contains the tables shown below. +- Create a MySQL/MariaDB user for ThreadR and grant usage privileges for the tables to it. +- Symlink the directory `build/` to your desired location on the web root. ThreadR does not support being linked directly to the webroot. +- adjust the files in `config/` to your setup +- run ./deployment-script.sh to apply configuration +- Optionally symlink `build/redirect_home.html` to all places that you want to redirect to ThreadR. + +Database tables: +- boards + - `id` (int, primary key, auto increment) + - `name` (varchar) + - `user_friendly_name` (varchar) + - `private` (boolean or tinyint(1)) + - `public_visible` (boolean or tinyint(1)) +- posts + - `id` (int, primary key, auto increment) + - `board_id` (int) + - `user_id` (int) + - `post_time` (timestamp, default current_timestamp()) + - `edit_time` (timestamp, may be null, default null, on update current_timestamp()) + - `content` (text, may be null, default null) + - `attachment_hash` (bigint(20), may be null, default null) + - `attachment_name` (varchar, may be null, default null) + - `title` (varchar) + - `reply_to` (int, default -1) +- profiles (do we even use this?) + - `id` (smallint (why? this makes no sense whatsoever), primary key, index (why? probably wanted to do unique)) + - `email` (varchar, index (I think that’s supposed to be unique?)) + - `display_name` (varchar) + - `status` (varchar) + - `about` (very long varchar) + - `website` (varchar) +- users + - `id` (smallint (again, this makes no sense), primary key) + - `name` (varchar, index (again, that’s probably supposed to be unique)) + - `authentication_string` (varchar(128)) + - `authentication_salt` (varchar) + - `authentication_algorithm` (varchar) + - `time_created` (timestamp, default current_timestamp()) + - `time_altered` (timestamp, default current_timestamp(), on update current_timestamp()) + - `verified` (boolean or tinyint(1), default 0) + +# Git based automatic web deployment system +This repository will be automagically pulled by the web server each time something is pushed by a user. + +Dear Developers, Please use pushes sparingly because it takes a while for the server to replace all code variables. + +What this thing does basically equates to: +``` +ssh @ +cd /var/www/git +sudo -u www-data -s +rm -rf ./web-deployment +git clone +cd web-deployment +./deployment-script +exit +logout +``` +TBD: Remove this section when the ThreadR project moves to its final home and this repository is only used for our local setup. + +## Symlinks +The following files and directories are linked to areas where they can be accessed by the web server: + * `build/` → `threadr.lostcave.ddnss.de/` (all files acessible by the web server, READMEs get deleted on deployment) + +# Individual documentation for each file +### [[DIR] src](./src) +This folder contains all the files that are parts of ThreadR directly +### [[DIR] build](./build) +Placeholder folder to link against, will be deleted and recreated by the deployment script, contains the a working instance of ThreadR after successful execution of the deployment script +### [[DIR] config](./config) +A place to store the configuation for a specific ThreadR instance (contains official instance config for now, will be moved elsewhere eventually) +### [[DIR] macros](./macros) +files for use with variable_grabbler.py +### [deployment_script.sh](./deployment_script.sh) +This script is executed each time (or most of the time) the repository gets pushed. +It contains the commands to execute the code variable replcement system and some other useful tasks. +Its working directory is the root of the git repository. +### [LICENSE.md](./LICENSE.md) +A copy of the Apache 2.0 license, the license this project is under +### [NOTICE](./NOTICE) +Copyright notice in plain text format +### [README.md](./README.md) +this file +### [variable_grabbler.py](./variable_grabbler.py) +Custom macro processor, takes two arguments: macro declaration file and the file to be processed + +Macros in code are strings of capitalized characters prefixed and suffixed with %. + +Macro definition format: JSON +"":"" → direct replacement +"":["file",""] → insert file +"":["exec",""] → run command and insert its output from stdout + +~~NOTICE: This file (or rather a more up-to-date version of it) will be moved to a new repository containing the deployment system.~~ +I haven’t exactly figured out how to handle this in the future. It is absolutely necessary to deploy a ThreadR instance because it is used to configure ThreadR so we need a copy of it here. diff --git a/config/about.template b/config/about.template new file mode 100644 index 0000000..ac6a59b --- /dev/null +++ b/config/about.template @@ -0,0 +1,25 @@ +

+ Hello there! This is the official ThreadR instance provided by the ThreadR development team. +

+

+ What is ThreadR? +

+

+ ThreadR is a free and open-source forum engine. That means you can download + it and host an instance of ThreadR on your own web server to run your own forum. +

+

+ The project originated as a school project in 2019 with the goal of building + a forum. When we finished school, the project was abandoned and eventually taken down. + A year later, we decided to revive it and started working on it again. Now that school + is over and we don't necessarily have a a reason to run our own forum anymore, + we shifted the project goal to building a FOSS forum engine. +

+

+ Who are we? +

+

+ We are a small group of (hobby) developers working on ThreadR in our free time. + To get in touch, ... uhh ... There will be a way once ThreadR is fully functional. + For now, you can find us on Discord: discord.gg/r3w3zSkEUE +

\ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..74365a9 --- /dev/null +++ b/go.mod @@ -0,0 +1,13 @@ +module threadr + +go 1.24.0 + +require ( + github.com/go-sql-driver/mysql v1.9.0 + github.com/gorilla/sessions v1.4.0 +) + +require ( + filippo.io/edwards25519 v1.1.0 // indirect + github.com/gorilla/securecookie v1.1.2 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..2b8450f --- /dev/null +++ b/go.sum @@ -0,0 +1,10 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/go-sql-driver/mysql v1.9.0 h1:Y0zIbQXhQKmQgTp44Y1dp3wTXcn804QoTptLZT1vtvo= +github.com/go-sql-driver/mysql v1.9.0/go.mod h1:pDetrLJeA3oMujJuvXc8RJoasr589B6A9fwzD3QMrqw= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= +github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= +github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= +github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= diff --git a/handlers/about.go b/handlers/about.go new file mode 100644 index 0000000..52875d8 --- /dev/null +++ b/handlers/about.go @@ -0,0 +1,45 @@ +package handlers + +import ( + "io/ioutil" + "log" + "net/http" + "github.com/gorilla/sessions" + "html/template" +) + +func AboutHandler(app *App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + session := r.Context().Value("session").(*sessions.Session) + loggedIn := session.Values["user_id"] != nil + + aboutContent, err := ioutil.ReadFile("config/about.template") + if err != nil { + log.Printf("Error reading about.template: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + data := struct { + PageData + AboutContent template.HTML + }{ + PageData: PageData{ + Title: "ThreadR - About", + Navbar: "about", + LoggedIn: loggedIn, + ShowCookieBanner: true, + BasePath: app.Config.ThreadrDir, + StaticPath: app.Config.ThreadrDir + "/static", + CurrentURL: r.URL.Path, + }, + AboutContent: template.HTML(aboutContent), + } + + if err := app.Tmpl.ExecuteTemplate(w, "about", data); err != nil { + log.Printf("Error executing template in AboutHandler: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + } +} \ No newline at end of file diff --git a/handlers/accept_cookie.go b/handlers/accept_cookie.go new file mode 100644 index 0000000..3947f5f --- /dev/null +++ b/handlers/accept_cookie.go @@ -0,0 +1,22 @@ +package handlers + +import ( + "net/http" + "time" +) + +func AcceptCookieHandler(app *App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + http.SetCookie(w, &http.Cookie{ + Name: "threadr_cookie_banner", + Value: "accepted", + Path: "/", + Expires: time.Now().Add(30 * 24 * time.Hour), + }) + from := r.URL.Query().Get("from") + if from == "" { + from = app.Config.ThreadrDir + "/" + } + http.Redirect(w, r, from, http.StatusFound) + } +} \ No newline at end of file diff --git a/handlers/app.go b/handlers/app.go new file mode 100644 index 0000000..9f4773f --- /dev/null +++ b/handlers/app.go @@ -0,0 +1,70 @@ +package handlers + +import ( + "context" + "database/sql" + "html/template" + "net/http" + "github.com/gorilla/sessions" +) + +type PageData struct { + Title string + Navbar string + LoggedIn bool + ShowCookieBanner bool + BasePath string + StaticPath string + CurrentURL string +} + +type Config struct { + DomainName string `json:"domain_name"` + ThreadrDir string `json:"threadr_dir"` + DBUsername string `json:"db_username"` + DBPassword string `json:"db_password"` + DBDatabase string `json:"db_database"` + DBServerHost string `json:"db_svr_host"` +} + +type App struct { + DB *sql.DB + Store *sessions.CookieStore + Config *Config + Tmpl *template.Template +} + +func (app *App) SessionMW(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + session, err := app.Store.Get(r, "session-name") + if err != nil { + session = sessions.NewSession(app.Store, "session-name") + } + if _, ok := session.Values["user_id"].(int); ok { + if session.Values["user_ip"] != r.RemoteAddr || session.Values["user_agent"] != r.UserAgent() { + session.Values = make(map[interface{}]interface{}) + session.Options.MaxAge = -1 + session.Save(r, w) + http.Redirect(w, r, app.Config.ThreadrDir+"/login/?error=session", http.StatusFound) + return + } + ctx := context.WithValue(r.Context(), "session", session) + r = r.WithContext(ctx) + } else { + ctx := context.WithValue(r.Context(), "session", session) + r = r.WithContext(ctx) + } + next(w, r) + } +} + +func (app *App) RequireLoginMW(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + session := r.Context().Value("session").(*sessions.Session) + if _, ok := session.Values["user_id"].(int); !ok { + http.Redirect(w, r, app.Config.ThreadrDir+"/login/?error=session", http.StatusFound) + return + } + next(w, r) + } +} \ No newline at end of file diff --git a/handlers/board.go b/handlers/board.go new file mode 100644 index 0000000..ee9c958 --- /dev/null +++ b/handlers/board.go @@ -0,0 +1,127 @@ +package handlers + +import ( + "log" + "net/http" + "strconv" + "threadr/models" + "github.com/gorilla/sessions" +) + +func BoardHandler(app *App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + session := r.Context().Value("session").(*sessions.Session) + loggedIn := session.Values["user_id"] != nil + userID, _ := session.Values["user_id"].(int) + + boardIDStr := r.URL.Query().Get("id") + boardID, err := strconv.Atoi(boardIDStr) + if err != nil { + http.Error(w, "Invalid board ID", http.StatusBadRequest) + return + } + + board, err := models.GetBoardByID(app.DB, boardID) + if err != nil { + log.Printf("Error fetching board: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + if board == nil { + http.Error(w, "Board not found", http.StatusNotFound) + return + } + + if board.Private { + if !loggedIn { + http.Redirect(w, r, app.Config.ThreadrDir+"/login/", http.StatusFound) + return + } + hasPerm, err := models.HasBoardPermission(app.DB, userID, boardID, models.PermViewBoard) + if err != nil { + log.Printf("Error checking permission: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + if !hasPerm { + http.Error(w, "You do not have permission to view this board", http.StatusForbidden) + return + } + } + + if r.Method == http.MethodPost && loggedIn { + action := r.URL.Query().Get("action") + if action == "create_thread" { + title := r.FormValue("title") + threadType := r.FormValue("type") + if title == "" || (threadType != "classic" && threadType != "chat" && threadType != "question") { + http.Error(w, "Invalid input", http.StatusBadRequest) + return + } + if board.Private { + hasPerm, err := models.HasBoardPermission(app.DB, userID, boardID, models.PermPostInBoard) + if err != nil { + log.Printf("Error checking permission: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + if !hasPerm { + http.Error(w, "You do not have permission to post in this board", http.StatusForbidden) + return + } + } + thread := models.Thread{ + BoardID: boardID, + Title: title, + Type: threadType, + CreatedByUserID: userID, + } + err = models.CreateThread(app.DB, thread) + if err != nil { + log.Printf("Error creating thread: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + var threadID int + err = app.DB.QueryRow("SELECT LAST_INSERT_ID()").Scan(&threadID) + if err != nil { + log.Printf("Error getting last insert id: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + http.Redirect(w, r, app.Config.ThreadrDir+"/thread/?id="+strconv.Itoa(threadID), http.StatusFound) + return + } + } + + threads, err := models.GetThreadsByBoardID(app.DB, boardID) + if err != nil { + log.Printf("Error fetching threads: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + data := struct { + PageData + Board models.Board + Threads []models.Thread + }{ + PageData: PageData{ + Title: "ThreadR - " + board.Name, + Navbar: "boards", + LoggedIn: loggedIn, + ShowCookieBanner: true, + BasePath: app.Config.ThreadrDir, + StaticPath: app.Config.ThreadrDir + "/static", + CurrentURL: r.URL.Path, + }, + Board: *board, + Threads: threads, + } + if err := app.Tmpl.ExecuteTemplate(w, "board", data); err != nil { + log.Printf("Error executing template in BoardHandler: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + } +} \ No newline at end of file diff --git a/handlers/boards.go b/handlers/boards.go new file mode 100644 index 0000000..78ac299 --- /dev/null +++ b/handlers/boards.go @@ -0,0 +1,67 @@ +package handlers + +import ( + "log" + "net/http" + "github.com/gorilla/sessions" + "threadr/models" +) + +func BoardsHandler(app *App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + session := r.Context().Value("session").(*sessions.Session) + loggedIn := session.Values["user_id"] != nil + userID, _ := session.Values["user_id"].(int) + + publicBoards, err := models.GetAllBoards(app.DB, false) + if err != nil { + log.Printf("Error fetching public boards: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + var privateBoards []models.Board + if loggedIn { + privateBoards, err = models.GetAllBoards(app.DB, true) + if err != nil { + log.Printf("Error fetching private boards: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + var accessiblePrivateBoards []models.Board + for _, board := range privateBoards { + hasPerm, err := models.HasBoardPermission(app.DB, userID, board.ID, models.PermViewBoard) + if err != nil { + log.Printf("Error checking permission: %v", err) + continue + } + if hasPerm { + accessiblePrivateBoards = append(accessiblePrivateBoards, board) + } + } + privateBoards = accessiblePrivateBoards + } + + data := struct { + PageData + PublicBoards []models.Board + PrivateBoards []models.Board + }{ + PageData: PageData{ + Title: "ThreadR - Boards", + Navbar: "boards", + LoggedIn: loggedIn, + ShowCookieBanner: true, + BasePath: app.Config.ThreadrDir, + StaticPath: app.Config.ThreadrDir + "/static", + CurrentURL: r.URL.Path, + }, + PublicBoards: publicBoards, + PrivateBoards: privateBoards, + } + if err := app.Tmpl.ExecuteTemplate(w, "boards", data); err != nil { + log.Printf("Error executing template in BoardsHandler: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + } +} \ No newline at end of file diff --git a/handlers/home.go b/handlers/home.go new file mode 100644 index 0000000..a9c9714 --- /dev/null +++ b/handlers/home.go @@ -0,0 +1,34 @@ +package handlers + +import ( + "log" + "net/http" + "path/filepath" + "github.com/gorilla/sessions" +) + +func HomeHandler(app *App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + session := r.Context().Value("session").(*sessions.Session) + loggedIn := session.Values["user_id"] != nil + cookie, _ := r.Cookie("threadr_cookie_banner") + data := struct { + PageData + }{ + PageData: PageData{ + Title: "ThreadR - Home", + Navbar: "home", + LoggedIn: loggedIn, + ShowCookieBanner: cookie == nil || cookie.Value == "", + BasePath: app.Config.ThreadrDir, + StaticPath: filepath.Join(app.Config.ThreadrDir, "static"), + CurrentURL: r.URL.String(), + }, + } + if err := app.Tmpl.ExecuteTemplate(w, "home", data); err != nil { + log.Printf("Error executing template in HomeHandler: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + } +} \ No newline at end of file diff --git a/handlers/like.go b/handlers/like.go new file mode 100644 index 0000000..303e70a --- /dev/null +++ b/handlers/like.go @@ -0,0 +1,78 @@ +package handlers + +import ( + "log" + "net/http" + "strconv" + "threadr/models" + "github.com/gorilla/sessions" +) + +func LikeHandler(app *App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + session := r.Context().Value("session").(*sessions.Session) + userID, ok := session.Values["user_id"].(int) + if !ok { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + postIDStr := r.FormValue("post_id") + postID, err := strconv.Atoi(postIDStr) + if err != nil { + http.Error(w, "Invalid post ID", http.StatusBadRequest) + return + } + + likeType := r.FormValue("type") + if likeType != "like" && likeType != "dislike" { + http.Error(w, "Invalid like type", http.StatusBadRequest) + return + } + + existingLike, err := models.GetLikeByPostAndUser(app.DB, postID, userID) + if err != nil { + log.Printf("Error checking existing like: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + if existingLike != nil { + if existingLike.Type == likeType { + err = models.DeleteLike(app.DB, postID, userID) + if err != nil { + log.Printf("Error deleting like: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + } else { + err = models.UpdateLikeType(app.DB, postID, userID, likeType) + if err != nil { + log.Printf("Error updating like: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + } + } else { + like := models.Like{ + PostID: postID, + UserID: userID, + Type: likeType, + } + err = models.CreateLike(app.DB, like) + if err != nil { + log.Printf("Error creating like: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + } + + w.WriteHeader(http.StatusOK) + w.Write([]byte("OK")) + } +} \ No newline at end of file diff --git a/handlers/login.go b/handlers/login.go new file mode 100644 index 0000000..7a0e122 --- /dev/null +++ b/handlers/login.go @@ -0,0 +1,62 @@ +package handlers + +import ( + "log" + "net/http" + "threadr/models" + "github.com/gorilla/sessions" +) + +func LoginHandler(app *App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + session := r.Context().Value("session").(*sessions.Session) + if r.Method == http.MethodPost { + username := r.FormValue("username") + password := r.FormValue("password") + user, err := models.GetUserByUsername(app.DB, username) + if err != nil { + log.Printf("Error fetching user in LoginHandler: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + if user == nil || !models.CheckPassword(password, user.AuthenticationSalt, user.AuthenticationAlgorithm, user.AuthenticationString) { + http.Redirect(w, r, app.Config.ThreadrDir+"/login/?error=invalid", http.StatusFound) + return + } + session.Values["user_id"] = user.ID + session.Values["user_ip"] = r.RemoteAddr + session.Values["user_agent"] = r.UserAgent() + if err := session.Save(r, w); err != nil { + log.Printf("Error saving session: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + http.Redirect(w, r, app.Config.ThreadrDir+"/userhome/", http.StatusFound) + return + } + + data := struct { + PageData + Error string + }{ + PageData: PageData{ + Title: "ThreadR - Login", + Navbar: "login", + LoggedIn: false, + BasePath: app.Config.ThreadrDir, + StaticPath: app.Config.ThreadrDir + "/static", + CurrentURL: r.URL.Path, + }, + Error: "", + } + if r.URL.Query().Get("error") == "invalid" { + data.Error = "Invalid username or password" + } + + if err := app.Tmpl.ExecuteTemplate(w, "login", data); err != nil { + log.Printf("Error executing template in LoginHandler: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + } +} \ No newline at end of file diff --git a/handlers/logout.go b/handlers/logout.go new file mode 100644 index 0000000..13da3fd --- /dev/null +++ b/handlers/logout.go @@ -0,0 +1,21 @@ +package handlers + +import ( + "log" + "net/http" + "github.com/gorilla/sessions" +) + +func LogoutHandler(app *App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + session := r.Context().Value("session").(*sessions.Session) + session.Values = make(map[interface{}]interface{}) + session.Options.MaxAge = -1 + if err := session.Save(r, w); err != nil { + log.Printf("Error saving session in LogoutHandler: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + http.Redirect(w, r, app.Config.ThreadrDir+"/", http.StatusFound) + } +} \ No newline at end of file diff --git a/handlers/news.go b/handlers/news.go new file mode 100644 index 0000000..8bf8699 --- /dev/null +++ b/handlers/news.go @@ -0,0 +1,38 @@ +package handlers + +import ( + "log" + "net/http" + "github.com/gorilla/sessions" +) + +func NewsHandler(app *App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + session := r.Context().Value("session").(*sessions.Session) + loggedIn := session.Values["user_id"] != nil + newsItems := []string{ + "2020-02-21 Whole Website updated: Homepage, News, Boards, About, Log In, Userhome, Log Out", + "2020-01-06 First Steps done", + } + data := struct { + PageData + News []string + }{ + PageData: PageData{ + Title: "ThreadR - News", + Navbar: "news", + LoggedIn: loggedIn, + ShowCookieBanner: true, + BasePath: app.Config.ThreadrDir, + StaticPath: app.Config.ThreadrDir + "/static", + CurrentURL: r.URL.Path, + }, + News: newsItems, + } + if err := app.Tmpl.ExecuteTemplate(w, "news", data); err != nil { + log.Printf("Error executing template in NewsHandler: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + } +} \ No newline at end of file diff --git a/handlers/profile.go b/handlers/profile.go new file mode 100644 index 0000000..5a454ec --- /dev/null +++ b/handlers/profile.go @@ -0,0 +1,55 @@ +package handlers + +import ( + "log" + "net/http" + "threadr/models" + "github.com/gorilla/sessions" +) + +func ProfileHandler(app *App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + session := r.Context().Value("session").(*sessions.Session) + userID, ok := session.Values["user_id"].(int) + if !ok { + http.Redirect(w, r, app.Config.ThreadrDir+"/login/", http.StatusFound) + return + } + user, err := models.GetUserByID(app.DB, userID) + if err != nil { + log.Printf("Error fetching user in ProfileHandler: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + if user == nil { + http.Error(w, "User not found", http.StatusNotFound) + return + } + displayName := user.DisplayName + if displayName == "" { + displayName = user.Username + } + data := struct { + PageData + User models.User + DisplayName string + }{ + PageData: PageData{ + Title: "ThreadR - Profile", + Navbar: "profile", + LoggedIn: true, + ShowCookieBanner: false, + BasePath: app.Config.ThreadrDir, + StaticPath: app.Config.ThreadrDir + "/static", + CurrentURL: r.URL.Path, + }, + User: *user, + DisplayName: displayName, + } + if err := app.Tmpl.ExecuteTemplate(w, "profile", data); err != nil { + log.Printf("Error executing template in ProfileHandler: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + } +} \ No newline at end of file diff --git a/handlers/profile_edit.go b/handlers/profile_edit.go new file mode 100644 index 0000000..c0d29fc --- /dev/null +++ b/handlers/profile_edit.go @@ -0,0 +1,65 @@ +package handlers + +import ( + "log" + "net/http" + "threadr/models" + "github.com/gorilla/sessions" +) + +func ProfileEditHandler(app *App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + session := r.Context().Value("session").(*sessions.Session) + userID, ok := session.Values["user_id"].(int) + if !ok { + http.Redirect(w, r, app.Config.ThreadrDir+"/login/", http.StatusFound) + return + } + + if r.Method == http.MethodPost { + displayName := r.FormValue("display_name") + pfpURL := r.FormValue("pfp_url") + bio := r.FormValue("bio") + err := models.UpdateUserProfile(app.DB, userID, displayName, pfpURL, bio) + if err != nil { + log.Printf("Error updating profile: %v", err) + http.Error(w, "Failed to update profile", http.StatusInternalServerError) + return + } + http.Redirect(w, r, app.Config.ThreadrDir+"/profile/", http.StatusFound) + return + } + + user, err := models.GetUserByID(app.DB, userID) + if err != nil { + log.Printf("Error fetching user: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + if user == nil { + http.Error(w, "User not found", http.StatusNotFound) + return + } + + data := struct { + PageData + User models.User + }{ + PageData: PageData{ + Title: "ThreadR - Edit Profile", + Navbar: "profile", + LoggedIn: true, + ShowCookieBanner: false, + BasePath: app.Config.ThreadrDir, + StaticPath: app.Config.ThreadrDir + "/static", + CurrentURL: r.URL.Path, + }, + User: *user, + } + if err := app.Tmpl.ExecuteTemplate(w, "profile_edit", data); err != nil { + log.Printf("Error executing template in ProfileEditHandler: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + } +} \ No newline at end of file diff --git a/handlers/signup.go b/handlers/signup.go new file mode 100644 index 0000000..ee55883 --- /dev/null +++ b/handlers/signup.go @@ -0,0 +1,62 @@ +package handlers + +import ( + "log" + "net/http" + "threadr/models" + "github.com/gorilla/sessions" +) + +func SignupHandler(app *App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + session := r.Context().Value("session").(*sessions.Session) + if r.Method == http.MethodPost { + username := r.FormValue("username") + password := r.FormValue("password") + err := models.CreateUser(app.DB, username, password) + if err != nil { + data := struct { + PageData + Error string + }{ + PageData: PageData{ + Title: "ThreadR - Sign Up", + Navbar: "signup", + LoggedIn: false, + ShowCookieBanner: true, + BasePath: app.Config.ThreadrDir, + StaticPath: app.Config.ThreadrDir + "/static", + CurrentURL: r.URL.Path, + }, + Error: "Error creating user", + } + if err := app.Tmpl.ExecuteTemplate(w, "signup", data); err != nil { + log.Printf("Error executing template in SignupHandler: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + return + } + http.Redirect(w, r, app.Config.ThreadrDir+"/login/", http.StatusFound) + return + } + data := struct { + PageData + }{ + PageData: PageData{ + Title: "ThreadR - Sign Up", + Navbar: "signup", + LoggedIn: session.Values["user_id"] != nil, + ShowCookieBanner: true, + BasePath: app.Config.ThreadrDir, + StaticPath: app.Config.ThreadrDir + "/static", + CurrentURL: r.URL.Path, + }, + } + if err := app.Tmpl.ExecuteTemplate(w, "signup", data); err != nil { + log.Printf("Error executing template in SignupHandler: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + } +} \ No newline at end of file diff --git a/handlers/thread.go b/handlers/thread.go new file mode 100644 index 0000000..76bc122 --- /dev/null +++ b/handlers/thread.go @@ -0,0 +1,140 @@ +package handlers + +import ( + "log" + "net/http" + "strconv" + "threadr/models" + "github.com/gorilla/sessions" +) + +func ThreadHandler(app *App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + session := r.Context().Value("session").(*sessions.Session) + loggedIn := session.Values["user_id"] != nil + userID, _ := session.Values["user_id"].(int) + + threadIDStr := r.URL.Query().Get("id") + threadID, err := strconv.Atoi(threadIDStr) + if err != nil { + http.Error(w, "Invalid thread ID", http.StatusBadRequest) + return + } + + thread, err := models.GetThreadByID(app.DB, threadID) + if err != nil { + log.Printf("Error fetching thread: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + if thread == nil { + http.Error(w, "Thread not found", http.StatusNotFound) + return + } + + board, err := models.GetBoardByID(app.DB, thread.BoardID) + if err != nil { + log.Printf("Error fetching board: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + if board.Private { + if !loggedIn { + http.Redirect(w, r, app.Config.ThreadrDir+"/login/", http.StatusFound) + return + } + hasPerm, err := models.HasBoardPermission(app.DB, userID, board.ID, models.PermViewBoard) + if err != nil { + log.Printf("Error checking permission: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + if !hasPerm { + http.Error(w, "You do not have permission to view this board", http.StatusForbidden) + return + } + } + + if r.Method == http.MethodPost && loggedIn { + action := r.URL.Query().Get("action") + if action == "submit" { + content := r.FormValue("content") + replyToStr := r.URL.Query().Get("to") + replyTo := 0 + if replyToStr != "" { + replyTo, err = strconv.Atoi(replyToStr) + if err != nil { + http.Error(w, "Invalid reply_to ID", http.StatusBadRequest) + return + } + } + if content == "" { + http.Error(w, "Content cannot be empty", http.StatusBadRequest) + return + } + if board.Private { + hasPerm, err := models.HasBoardPermission(app.DB, userID, board.ID, models.PermPostInBoard) + if err != nil { + log.Printf("Error checking permission: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + if !hasPerm { + http.Error(w, "You do not have permission to post in this board", http.StatusForbidden) + return + } + } + post := models.Post{ + ThreadID: threadID, + UserID: userID, + Content: content, + ReplyTo: replyTo, + } + err = models.CreatePost(app.DB, post) + if err != nil { + log.Printf("Error creating post: %v", err) + http.Error(w, "Failed to create post", http.StatusInternalServerError) + return + } + http.Redirect(w, r, app.Config.ThreadrDir+"/thread/?id="+threadIDStr, http.StatusFound) + return + } + } + + posts, err := models.GetPostsByThreadID(app.DB, threadID) + if err != nil { + log.Printf("Error fetching posts: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + if thread.Type == "chat" { + for i, j := 0, len(posts)-1; i < j; i, j = i+1, j-1 { + posts[i], posts[j] = posts[j], posts[i] + } + } + + data := struct { + PageData + Thread models.Thread + Posts []models.Post + }{ + PageData: PageData{ + Title: "ThreadR - " + thread.Title, + Navbar: "boards", + LoggedIn: loggedIn, + ShowCookieBanner: true, + BasePath: app.Config.ThreadrDir, + StaticPath: app.Config.ThreadrDir + "/static", + CurrentURL: r.URL.Path, + }, + Thread: *thread, + Posts: posts, + } + if err := app.Tmpl.ExecuteTemplate(w, "thread", data); err != nil { + log.Printf("Error executing template in ThreadHandler: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + } +} \ No newline at end of file diff --git a/handlers/userhome.go b/handlers/userhome.go new file mode 100644 index 0000000..679aec8 --- /dev/null +++ b/handlers/userhome.go @@ -0,0 +1,49 @@ +package handlers + +import ( + "log" + "net/http" + "threadr/models" + "github.com/gorilla/sessions" +) + +func UserHomeHandler(app *App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + session := r.Context().Value("session").(*sessions.Session) + userID, ok := session.Values["user_id"].(int) + if !ok { + http.Redirect(w, r, app.Config.ThreadrDir+"/login/", http.StatusFound) + return + } + user, err := models.GetUserByID(app.DB, userID) + if err != nil { + log.Printf("Error fetching user in UserHomeHandler: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + if user == nil { + http.Error(w, "User not found", http.StatusNotFound) + return + } + data := struct { + PageData + Username string + }{ + PageData: PageData{ + Title: "ThreadR - User Home", + Navbar: "userhome", + LoggedIn: true, + ShowCookieBanner: false, + BasePath: app.Config.ThreadrDir, + StaticPath: app.Config.ThreadrDir + "/static", + CurrentURL: r.URL.Path, + }, + Username: user.Username, + } + if err := app.Tmpl.ExecuteTemplate(w, "userhome", data); err != nil { + log.Printf("Error executing template in UserHomeHandler: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + } +} \ No newline at end of file diff --git a/main.go b/main.go new file mode 100644 index 0000000..2bc8f85 --- /dev/null +++ b/main.go @@ -0,0 +1,100 @@ +package main + +import ( + "database/sql" + "encoding/json" + "fmt" + "html/template" + "log" + "net/http" + "os" + "path/filepath" + "threadr/handlers" + + "github.com/gorilla/sessions" + _ "github.com/go-sql-driver/mysql" +) + +func loadConfig(filename string) (*handlers.Config, error) { + file, err := os.Open(filename) + if err != nil { + return nil, err + } + defer file.Close() + var config handlers.Config + err = json.NewDecoder(file).Decode(&config) + return &config, err +} + +func main() { + config, err := loadConfig("config/config.json") + if err != nil { + log.Fatal("Error loading config:", err) + } + + dsn := fmt.Sprintf("%s:%s@tcp(%s)/%s", config.DBUsername, config.DBPassword, config.DBServerHost, config.DBDatabase) + db, err := sql.Open("mysql", dsn) + if err != nil { + log.Fatal("Error connecting to database:", err) + } + defer db.Close() + + dir, err := os.Getwd() + if err != nil { + log.Fatal("Error getting working directory:", err) + } + + // Parse partial templates + tmpl := template.Must(template.ParseFiles( + filepath.Join(dir, "templates/partials/navbar.html"), + filepath.Join(dir, "templates/partials/cookie_banner.html"), + )) + + // Parse page-specific templates with unique names + tmpl, err = tmpl.ParseFiles( + filepath.Join(dir, "templates/pages/about.html"), + filepath.Join(dir, "templates/pages/board.html"), + filepath.Join(dir, "templates/pages/boards.html"), + filepath.Join(dir, "templates/pages/home.html"), + filepath.Join(dir, "templates/pages/login.html"), + filepath.Join(dir, "templates/pages/news.html"), + filepath.Join(dir, "templates/pages/profile.html"), + filepath.Join(dir, "templates/pages/profile_edit.html"), + filepath.Join(dir, "templates/pages/signup.html"), + filepath.Join(dir, "templates/pages/thread.html"), + filepath.Join(dir, "templates/pages/userhome.html"), + ) + if err != nil { + log.Fatal("Error parsing page templates:", err) + } + + store := sessions.NewCookieStore([]byte("secret-key")) // Replace with secure key in production + + app := &handlers.App{ + DB: db, + Store: store, + Config: config, + Tmpl: tmpl, + } + + fs := http.FileServer(http.Dir("static")) + http.Handle(config.ThreadrDir+"/static/", http.StripPrefix(config.ThreadrDir+"/static/", fs)) + + http.HandleFunc(config.ThreadrDir+"/", app.SessionMW(handlers.HomeHandler(app))) + http.HandleFunc(config.ThreadrDir+"/login/", app.SessionMW(handlers.LoginHandler(app))) + http.HandleFunc(config.ThreadrDir+"/logout/", app.SessionMW(handlers.LogoutHandler(app))) + http.HandleFunc(config.ThreadrDir+"/userhome/", app.SessionMW(app.RequireLoginMW(handlers.UserHomeHandler(app)))) + http.HandleFunc(config.ThreadrDir+"/boards/", app.SessionMW(handlers.BoardsHandler(app))) + http.HandleFunc(config.ThreadrDir+"/board/", app.SessionMW(handlers.BoardHandler(app))) + http.HandleFunc(config.ThreadrDir+"/thread/", app.SessionMW(handlers.ThreadHandler(app))) + http.HandleFunc(config.ThreadrDir+"/about/", app.SessionMW(handlers.AboutHandler(app))) + http.HandleFunc(config.ThreadrDir+"/profile/", app.SessionMW(app.RequireLoginMW(handlers.ProfileHandler(app)))) + http.HandleFunc(config.ThreadrDir+"/profile/edit/", app.SessionMW(app.RequireLoginMW(handlers.ProfileEditHandler(app)))) + http.HandleFunc(config.ThreadrDir+"/like/", app.SessionMW(app.RequireLoginMW(handlers.LikeHandler(app)))) + http.HandleFunc(config.ThreadrDir+"/news/", app.SessionMW(handlers.NewsHandler(app))) + http.HandleFunc(config.ThreadrDir+"/signup/", app.SessionMW(handlers.SignupHandler(app))) + http.HandleFunc(config.ThreadrDir+"/accept_cookie/", app.SessionMW(handlers.AcceptCookieHandler(app))) + + log.Println("Server starting on :8080") + log.Fatal(http.ListenAndServe(":8080", nil)) +} \ No newline at end of file diff --git a/models/board.go b/models/board.go new file mode 100644 index 0000000..ea2892b --- /dev/null +++ b/models/board.go @@ -0,0 +1,65 @@ +package models + +import ( + "database/sql" + "encoding/json" +) + +type Board struct { + ID int + Name string + Description string + Private bool + PublicVisible bool + PinnedThreads []int // Stored as JSON + CustomLandingPage string + ColorScheme string +} + +func GetBoardByID(db *sql.DB, id int) (*Board, error) { + query := "SELECT id, name, description, private, public_visible, pinned_threads, custom_landing_page, color_scheme FROM boards WHERE id = ?" + row := db.QueryRow(query, id) + board := &Board{} + var pinnedThreadsJSON string + err := row.Scan(&board.ID, &board.Name, &board.Description, &board.Private, &board.PublicVisible, &pinnedThreadsJSON, &board.CustomLandingPage, &board.ColorScheme) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + if pinnedThreadsJSON != "" { + err = json.Unmarshal([]byte(pinnedThreadsJSON), &board.PinnedThreads) + if err != nil { + return nil, err + } + } + return board, nil +} + +func GetAllBoards(db *sql.DB, private bool) ([]Board, error) { + query := "SELECT id, name, description, private, public_visible, pinned_threads, custom_landing_page, color_scheme FROM boards WHERE private = ? ORDER BY id ASC" + rows, err := db.Query(query, private) + if err != nil { + return nil, err + } + defer rows.Close() + + var boards []Board + for rows.Next() { + board := Board{} + var pinnedThreadsJSON string + err := rows.Scan(&board.ID, &board.Name, &board.Description, &board.Private, &board.PublicVisible, &pinnedThreadsJSON, &board.CustomLandingPage, &board.ColorScheme) + if err != nil { + return nil, err + } + if pinnedThreadsJSON != "" { + err = json.Unmarshal([]byte(pinnedThreadsJSON), &board.PinnedThreads) + if err != nil { + return nil, err + } + } + boards = append(boards, board) + } + return boards, nil +} \ No newline at end of file diff --git a/models/board_permission.go b/models/board_permission.go new file mode 100644 index 0000000..cc20560 --- /dev/null +++ b/models/board_permission.go @@ -0,0 +1,46 @@ +package models + +import "database/sql" + +type BoardPermission struct { + UserID int + BoardID int + Permissions int64 +} + +func GetBoardPermission(db *sql.DB, userID, boardID int) (*BoardPermission, error) { + query := "SELECT user_id, board_id, permissions FROM board_permissions WHERE user_id = ? AND board_id = ?" + row := db.QueryRow(query, userID, boardID) + bp := &BoardPermission{} + err := row.Scan(&bp.UserID, &bp.BoardID, &bp.Permissions) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + return bp, nil +} + +func SetBoardPermission(db *sql.DB, bp BoardPermission) error { + query := "INSERT INTO board_permissions (user_id, board_id, permissions) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE permissions = ?" + _, err := db.Exec(query, bp.UserID, bp.BoardID, bp.Permissions, bp.Permissions) + return err +} + +const ( + PermPostInBoard int64 = 1 << 0 + PermModerateBoard int64 = 1 << 1 + PermViewBoard int64 = 1 << 2 +) + +func HasBoardPermission(db *sql.DB, userID, boardID int, perm int64) (bool, error) { + bp, err := GetBoardPermission(db, userID, boardID) + if err != nil { + return false, err + } + if bp == nil { + return false, nil + } + return bp.Permissions&perm != 0, nil +} \ No newline at end of file diff --git a/models/like.go b/models/like.go new file mode 100644 index 0000000..cd866a2 --- /dev/null +++ b/models/like.go @@ -0,0 +1,62 @@ +package models + +import "database/sql" + +type Like struct { + ID int + PostID int + UserID int + Type string // "like" or "dislike" +} + +func GetLikesByPostID(db *sql.DB, postID int) ([]Like, error) { + query := "SELECT id, post_id, user_id, type FROM likes WHERE post_id = ?" + rows, err := db.Query(query, postID) + if err != nil { + return nil, err + } + defer rows.Close() + + var likes []Like + for rows.Next() { + like := Like{} + err := rows.Scan(&like.ID, &like.PostID, &like.UserID, &like.Type) + if err != nil { + return nil, err + } + likes = append(likes, like) + } + return likes, nil +} + +func GetLikeByPostAndUser(db *sql.DB, postID, userID int) (*Like, error) { + query := "SELECT id, post_id, user_id, type FROM likes WHERE post_id = ? AND user_id = ?" + row := db.QueryRow(query, postID, userID) + like := &Like{} + err := row.Scan(&like.ID, &like.PostID, &like.UserID, &like.Type) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + return like, nil +} + +func CreateLike(db *sql.DB, like Like) error { + query := "INSERT INTO likes (post_id, user_id, type) VALUES (?, ?, ?)" + _, err := db.Exec(query, like.PostID, like.UserID, like.Type) + return err +} + +func UpdateLikeType(db *sql.DB, postID, userID int, likeType string) error { + query := "UPDATE likes SET type = ? WHERE post_id = ? AND user_id = ?" + _, err := db.Exec(query, likeType, postID, userID) + return err +} + +func DeleteLike(db *sql.DB, postID, userID int) error { + query := "DELETE FROM likes WHERE post_id = ? AND user_id = ?" + _, err := db.Exec(query, postID, userID) + return err +} \ No newline at end of file diff --git a/models/notification.go b/models/notification.go new file mode 100644 index 0000000..105d21b --- /dev/null +++ b/models/notification.go @@ -0,0 +1,49 @@ +package models + +import ( + "database/sql" + "time" +) + +type Notification struct { + ID int + UserID int + Type string + RelatedID int + CreatedAt time.Time + Read bool +} + +func GetNotificationsByUserID(db *sql.DB, userID int) ([]Notification, error) { + query := "SELECT id, user_id, type, related_id, read, created_at FROM notifications WHERE user_id = ? ORDER BY created_at DESC" + rows, err := db.Query(query, userID) + if err != nil { + return nil, err + } + defer rows.Close() + + var notifications []Notification + for rows.Next() { + notification := Notification{} + err := rows.Scan(¬ification.ID, ¬ification.UserID, ¬ification.Type, ¬ification.RelatedID, ¬ification.Read, ¬ification.CreatedAt) + if err != nil { + return nil, err + } + notifications = append(notifications, notification) + } + return notifications, nil +} + +// Stubbed for future implementation +func CreateNotification(db *sql.DB, notification Notification) error { + query := "INSERT INTO notifications (user_id, type, related_id, read, created_at) VALUES (?, ?, ?, ?, NOW())" + _, err := db.Exec(query, notification.UserID, notification.Type, notification.RelatedID, notification.Read) + return err +} + +// Stubbed for future implementation +func MarkNotificationAsRead(db *sql.DB, id int) error { + query := "UPDATE notifications SET read = true WHERE id = ?" + _, err := db.Exec(query, id) + return err +} \ No newline at end of file diff --git a/models/post.go b/models/post.go new file mode 100644 index 0000000..fea607f --- /dev/null +++ b/models/post.go @@ -0,0 +1,45 @@ +package models + +import ( + "database/sql" + "time" +) + +type Post struct { + ID int + ThreadID int + UserID int + PostTime time.Time + EditTime *time.Time + Content string + AttachmentHash *int64 + AttachmentName *string + Title string + ReplyTo int +} + +func GetPostsByThreadID(db *sql.DB, threadID int) ([]Post, error) { + query := "SELECT id, thread_id, user_id, post_time, edit_time, content, attachment_hash, attachment_name, title, reply_to FROM posts WHERE thread_id = ? ORDER BY post_time ASC" + rows, err := db.Query(query, threadID) + if err != nil { + return nil, err + } + defer rows.Close() + + var posts []Post + for rows.Next() { + post := Post{} + err := rows.Scan(&post.ID, &post.ThreadID, &post.UserID, &post.PostTime, &post.EditTime, &post.Content, &post.AttachmentHash, &post.AttachmentName, &post.Title, &post.ReplyTo) + if err != nil { + return nil, err + } + posts = append(posts, post) + } + return posts, nil +} + +func CreatePost(db *sql.DB, post Post) error { + query := "INSERT INTO posts (thread_id, user_id, content, title, reply_to, post_time) VALUES (?, ?, ?, ?, ?, NOW())" + _, err := db.Exec(query, post.ThreadID, post.UserID, post.Content, post.Title, post.ReplyTo) + return err +} \ No newline at end of file diff --git a/models/reaction.go b/models/reaction.go new file mode 100644 index 0000000..2439950 --- /dev/null +++ b/models/reaction.go @@ -0,0 +1,44 @@ +package models + +import "database/sql" + +type Reaction struct { + ID int + PostID int + UserID int + Emoji string +} + +func GetReactionsByPostID(db *sql.DB, postID int) ([]Reaction, error) { + query := "SELECT id, post_id, user_id, emoji FROM reactions WHERE post_id = ?" + rows, err := db.Query(query, postID) + if err != nil { + return nil, err + } + defer rows.Close() + + var reactions []Reaction + for rows.Next() { + reaction := Reaction{} + err := rows.Scan(&reaction.ID, &reaction.PostID, &reaction.UserID, &reaction.Emoji) + if err != nil { + return nil, err + } + reactions = append(reactions, reaction) + } + return reactions, nil +} + +// Stubbed for future implementation +func CreateReaction(db *sql.DB, reaction Reaction) error { + query := "INSERT INTO reactions (post_id, user_id, emoji) VALUES (?, ?, ?)" + _, err := db.Exec(query, reaction.PostID, reaction.UserID, reaction.Emoji) + return err +} + +// Stubbed for future implementation +func DeleteReaction(db *sql.DB, postID, userID int, emoji string) error { + query := "DELETE FROM reactions WHERE post_id = ? AND user_id = ? AND emoji = ?" + _, err := db.Exec(query, postID, userID, emoji) + return err +} \ No newline at end of file diff --git a/models/repost.go b/models/repost.go new file mode 100644 index 0000000..bf9a2f1 --- /dev/null +++ b/models/repost.go @@ -0,0 +1,41 @@ +package models + +import ( + "database/sql" + "time" +) + +type Repost struct { + ID int + ThreadID int + BoardID int + UserID int + CreatedAt time.Time +} + +func GetRepostsByThreadID(db *sql.DB, threadID int) ([]Repost, error) { + query := "SELECT id, thread_id, board_id, user_id, created_at FROM reposts WHERE thread_id = ?" + rows, err := db.Query(query, threadID) + if err != nil { + return nil, err + } + defer rows.Close() + + var reposts []Repost + for rows.Next() { + repost := Repost{} + err := rows.Scan(&repost.ID, &repost.ThreadID, &repost.BoardID, &repost.UserID, &repost.CreatedAt) + if err != nil { + return nil, err + } + reposts = append(reposts, repost) + } + return reposts, nil +} + +// Stubbed for future implementation +func CreateRepost(db *sql.DB, repost Repost) error { + query := "INSERT INTO reposts (thread_id, board_id, user_id, created_at) VALUES (?, ?, ?, NOW())" + _, err := db.Exec(query, repost.ThreadID, repost.BoardID, repost.UserID) + return err +} \ No newline at end of file diff --git a/models/sla.html b/models/sla.html new file mode 100644 index 0000000..c227d0b --- /dev/null +++ b/models/sla.html @@ -0,0 +1,28 @@ + + + + + + Document + + +

Lista de frutas

+
+
+ + + \ No newline at end of file diff --git a/models/thread.go b/models/thread.go new file mode 100644 index 0000000..a357c41 --- /dev/null +++ b/models/thread.go @@ -0,0 +1,57 @@ +package models + +import ( + "database/sql" + "time" +) + +type Thread struct { + ID int + BoardID int + Title string + Type string // "classic", "chat", "question" + CreatedAt time.Time + UpdatedAt time.Time + CreatedByUserID int + AcceptedAnswerPostID *int +} + +func GetThreadByID(db *sql.DB, id int) (*Thread, error) { + query := "SELECT id, board_id, title, type, created_at, updated_at, created_by_user_id, accepted_answer_post_id FROM threads WHERE id = ?" + row := db.QueryRow(query, id) + thread := &Thread{} + err := row.Scan(&thread.ID, &thread.BoardID, &thread.Title, &thread.Type, &thread.CreatedAt, &thread.UpdatedAt, &thread.CreatedByUserID, &thread.AcceptedAnswerPostID) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + return thread, nil +} + +func GetThreadsByBoardID(db *sql.DB, boardID int) ([]Thread, error) { + query := "SELECT id, board_id, title, type, created_at, updated_at, created_by_user_id, accepted_answer_post_id FROM threads WHERE board_id = ? ORDER BY updated_at DESC" + rows, err := db.Query(query, boardID) + if err != nil { + return nil, err + } + defer rows.Close() + + var threads []Thread + for rows.Next() { + thread := Thread{} + err := rows.Scan(&thread.ID, &thread.BoardID, &thread.Title, &thread.Type, &thread.CreatedAt, &thread.UpdatedAt, &thread.CreatedByUserID, &thread.AcceptedAnswerPostID) + if err != nil { + return nil, err + } + threads = append(threads, thread) + } + return threads, nil +} + +func CreateThread(db *sql.DB, thread Thread) error { + query := "INSERT INTO threads (board_id, title, type, created_by_user_id, created_at, updated_at) VALUES (?, ?, ?, ?, NOW(), NOW())" + _, err := db.Exec(query, thread.BoardID, thread.Title, thread.Type, thread.CreatedByUserID) + return err +} \ No newline at end of file diff --git a/models/user.go b/models/user.go new file mode 100644 index 0000000..4ec582e --- /dev/null +++ b/models/user.go @@ -0,0 +1,89 @@ +package models + +import ( + "crypto/sha256" + "database/sql" + "fmt" + "time" +) + +type User struct { + ID int + Username string + DisplayName string + PfpURL string + Bio string + AuthenticationString string + AuthenticationSalt string + AuthenticationAlgorithm string + CreatedAt time.Time + UpdatedAt time.Time + Verified bool + Permissions int64 +} + +func GetUserByID(db *sql.DB, id int) (*User, error) { + query := "SELECT id, username, display_name, pfp_url, bio, authentication_string, authentication_salt, authentication_algorithm, created_at, updated_at, verified, permissions FROM users WHERE id = ?" + row := db.QueryRow(query, id) + user := &User{} + err := row.Scan(&user.ID, &user.Username, &user.DisplayName, &user.PfpURL, &user.Bio, &user.AuthenticationString, &user.AuthenticationSalt, &user.AuthenticationAlgorithm, &user.CreatedAt, &user.UpdatedAt, &user.Verified, &user.Permissions) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + return user, nil +} + +func GetUserByUsername(db *sql.DB, username string) (*User, error) { + query := "SELECT id, username, display_name, pfp_url, bio, authentication_string, authentication_salt, authentication_algorithm, created_at, updated_at, verified, permissions FROM users WHERE username = ?" + row := db.QueryRow(query, username) + user := &User{} + err := row.Scan(&user.ID, &user.Username, &user.DisplayName, &user.PfpURL, &user.Bio, &user.AuthenticationString, &user.AuthenticationSalt, &user.AuthenticationAlgorithm, &user.CreatedAt, &user.UpdatedAt, &user.Verified, &user.Permissions) + if err != nil { + return nil, err + } + return user, nil +} + +func CheckPassword(password, salt, algorithm, hash string) bool { + if algorithm != "sha256" { + return false + } + computedHash := HashPassword(password, salt, algorithm) + return computedHash == hash +} + +func HashPassword(password, salt, algorithm string) string { + if algorithm != "sha256" { + return "" + } + data := password + salt + hash := sha256.Sum256([]byte(data)) + return fmt.Sprintf("%x", hash) +} + +func CreateUser(db *sql.DB, username, password string) error { + salt := "random-salt" // Replace with secure random generation + algorithm := "sha256" + hash := HashPassword(password, salt, algorithm) + query := "INSERT INTO users (username, authentication_string, authentication_salt, authentication_algorithm, created_at, updated_at, verified, permissions) VALUES (?, ?, ?, ?, NOW(), NOW(), ?, 0)" + _, err := db.Exec(query, username, hash, salt, algorithm, false) + return err +} + +func UpdateUserProfile(db *sql.DB, userID int, displayName, pfpURL, bio string) error { + query := "UPDATE users SET display_name = ?, pfp_url = ?, bio = ?, updated_at = NOW() WHERE id = ?" + _, err := db.Exec(query, displayName, pfpURL, bio, userID) + return err +} + +const ( + PermCreateBoard int64 = 1 << 0 + PermManageUsers int64 = 1 << 1 +) + +func HasGlobalPermission(user *User, perm int64) bool { + return user.Permissions&perm != 0 +} \ No newline at end of file diff --git a/static/img/ThreadR.png b/static/img/ThreadR.png new file mode 100644 index 0000000..ce49d63 Binary files /dev/null and b/static/img/ThreadR.png differ diff --git a/static/img/ThreadR.svg b/static/img/ThreadR.svg new file mode 100644 index 0000000..73eb9be --- /dev/null +++ b/static/img/ThreadR.svg @@ -0,0 +1,272 @@ + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/img/ThreadR_Home.svg b/static/img/ThreadR_Home.svg new file mode 100644 index 0000000..87a8071 --- /dev/null +++ b/static/img/ThreadR_Home.svg @@ -0,0 +1,249 @@ + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/img/favicon-32x32.png b/static/img/favicon-32x32.png new file mode 100644 index 0000000..45d52df Binary files /dev/null and b/static/img/favicon-32x32.png differ diff --git a/static/img/threadR.png b/static/img/threadR.png new file mode 100644 index 0000000..78d9776 Binary files /dev/null and b/static/img/threadR.png differ diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..d5d8e4d --- /dev/null +++ b/static/style.css @@ -0,0 +1,186 @@ +body { + font-family: Arial, sans-serif; + margin: 0; + padding: 0; + background-color: #fef6e4; /* beige */ + color: #001858; /* blue */ +} + +main { + display: flex; + flex-direction: column; + align-items: center; + padding: 20px; +} + +main > header { + text-align: center; + margin-bottom: 1em; +} + +main > section { + margin: 1em; + padding: 1em; + border: 1px solid #001858; + border-radius: 5px; + background-color: #f3d2c1; /* orange */ + box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.2); +} + +main > div > article { + border: 1px solid #001858; + padding: 1em; + margin-bottom: 1em; + background-color: #fef6e4; + border-radius: 5px; + box-shadow: inset 0px 8px 16px 0px rgba(0,0,0,0.2); +} + +article > header { + border-bottom: 1px solid #001858; + background-color: #001858; + color: #fef6e4; + padding: 0.5em; + margin: -1em -1em 1em -1em; +} + +ul.topnav { + list-style-type: none; + margin: 0; + padding: 0; + overflow: hidden; + background-color: #001858; + position: fixed; + top: 0; + width: 100%; + box-shadow: 0 0.7em 1.2em 0 rgba(0,0,0,0.2); +} + +ul.topnav li { + float: left; +} + +ul.topnav li a { + display: block; + color: #fef6e4; + text-align: center; + padding: 14px 16px; + text-decoration: none; +} + +ul.topnav li a:hover { + background-color: #8bd3dd; /* cyan */ +} + +ul.topnav li a.active { + background-color: #f582ae; /* pink */ +} + +div.topnav { + height: 3em; +} + +div.banner { + position: fixed; + bottom: 0; + width: 100%; + background-color: #001858; + padding: 10px; + text-align: center; +} + +div.banner p { + color: #fef6e4; + margin: 0; +} + +div.banner a { + color: #8bd3dd; +} + +form { + display: flex; + flex-direction: column; +} + +input, textarea, select { + margin: 0.5em 0; + padding: 0.5em; + border: 1px solid #001858; + border-radius: 4px; + background-color: #fef6e4; + color: #001858; +} + +input[type="submit"] { + background-color: #001858; + color: #fef6e4; + cursor: pointer; +} + +input[type="submit"]:hover { + background-color: #8bd3dd; +} + +button { + margin: 0.5em 0; + padding: 0.5em; + border: none; + border-radius: 4px; + background-color: #001858; + color: #fef6e4; + cursor: pointer; +} + +button:hover { + background-color: #8bd3dd; +} + +img { + max-width: 100%; +} + +@media (prefers-color-scheme: dark) { + body { + background-color: #333; + color: #fef6e4; + } + main > section { + background-color: #555; + border-color: #fef6e4; + } + main > div > article { + background-color: #444; + border-color: #fef6e4; + } + article > header { + background-color: #222; + border-color: #fef6e4; + } + input, textarea, select { + background-color: #444; + color: #fef6e4; + border-color: #fef6e4; + } + input[type="submit"], button { + background-color: #fef6e4; + color: #001858; + } + input[type="submit"]:hover, button:hover { + background-color: #8bd3dd; + } +} + +@media (max-width: 600px) { + ul.topnav li { + float: none; + width: 100%; + } + main { + padding: 10px; + } + main > section { + margin: 0.5em; + padding: 0.5em; + } +} \ No newline at end of file diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..85b8843 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,16 @@ +{{define "base"}} + + + + {{.Title}} + + + + {{template "navbar" .}} +
+ {{block "content" .}}{{end}} +
+ {{template "cookie_banner" .}} + + +{{end}} \ No newline at end of file diff --git a/templates/pages/about.html b/templates/pages/about.html new file mode 100644 index 0000000..6802192 --- /dev/null +++ b/templates/pages/about.html @@ -0,0 +1,21 @@ +{{define "about"}} + + + + {{.Title}} + + + + {{template "navbar" .}} +
+
+

About ThreadR

+
+
+ {{.AboutContent}} +
+
+ {{template "cookie_banner" .}} + + +{{end}} \ No newline at end of file diff --git a/templates/pages/board.html b/templates/pages/board.html new file mode 100644 index 0000000..3d25e5d --- /dev/null +++ b/templates/pages/board.html @@ -0,0 +1,42 @@ +{{define "board"}} + + + + {{.Title}} + + + + {{template "navbar" .}} +
+
+

{{.Board.Name}}

+

{{.Board.Description}}

+
+
+
    + {{range .Threads}} +
  • {{.Title}} ({{.Type}})
  • + {{end}} +
+
+ {{if .LoggedIn}} +
+

Create New Thread

+
+ +
+ +
+ +
+
+ {{end}} +
+ {{template "cookie_banner" .}} + + +{{end}} \ No newline at end of file diff --git a/templates/pages/boards.html b/templates/pages/boards.html new file mode 100644 index 0000000..9960c04 --- /dev/null +++ b/templates/pages/boards.html @@ -0,0 +1,36 @@ +{{define "boards"}} + + + + {{.Title}} + + + + {{template "navbar" .}} +
+
+

Boards

+
+
+

Public Boards

+
    + {{range .PublicBoards}} +
  • {{.Name}}
  • + {{end}} +
+
+ {{if .LoggedIn}} +
+

Private Boards

+
    + {{range .PrivateBoards}} +
  • {{.Name}}
  • + {{end}} +
+
+ {{end}} +
+ {{template "cookie_banner" .}} + + +{{end}} \ No newline at end of file diff --git a/templates/pages/home.html b/templates/pages/home.html new file mode 100644 index 0000000..cba7c6d --- /dev/null +++ b/templates/pages/home.html @@ -0,0 +1,21 @@ +{{define "home"}} + + + + {{.Title}} + + + + {{template "navbar" .}} +
+
+

Welcome to ThreadR

+
+
+ ThreadR +
+
+ {{template "cookie_banner" .}} + + +{{end}} \ No newline at end of file diff --git a/templates/pages/login.html b/templates/pages/login.html new file mode 100644 index 0000000..d27f2f2 --- /dev/null +++ b/templates/pages/login.html @@ -0,0 +1,30 @@ +{{define "login"}} + + + + {{.Title}} + + + + {{template "navbar" .}} +
+
+

Login

+
+
+ {{if .Error}} +

{{.Error}}

+ {{end}} +
+ +
+ +
+ +
+
+
+ {{template "cookie_banner" .}} + + +{{end}} \ No newline at end of file diff --git a/templates/pages/news.html b/templates/pages/news.html new file mode 100644 index 0000000..74c3fc4 --- /dev/null +++ b/templates/pages/news.html @@ -0,0 +1,25 @@ +{{define "news"}} + + + + {{.Title}} + + + + {{template "navbar" .}} +
+
+

News

+
+
+
    + {{range .News}} +
  • {{.}}
  • + {{end}} +
+
+
+ {{template "cookie_banner" .}} + + +{{end}} \ No newline at end of file diff --git a/templates/pages/profile.html b/templates/pages/profile.html new file mode 100644 index 0000000..67ca93a --- /dev/null +++ b/templates/pages/profile.html @@ -0,0 +1,30 @@ +{{define "profile"}} + + + + {{.Title}} + + + + {{template "navbar" .}} +
+
+

Profile

+
+
+

Username: {{.User.Username}}

+

Display Name: {{.DisplayName}}

+ {{if .User.PfpURL}} + Profile Picture + {{end}} +

Bio: {{.User.Bio}}

+

Joined: {{.User.CreatedAt}}

+

Last Updated: {{.User.UpdatedAt}}

+

Verified: {{.User.Verified}}

+ Edit Profile +
+
+ {{template "cookie_banner" .}} + + +{{end}} \ No newline at end of file diff --git a/templates/pages/profile_edit.html b/templates/pages/profile_edit.html new file mode 100644 index 0000000..80d01f5 --- /dev/null +++ b/templates/pages/profile_edit.html @@ -0,0 +1,29 @@ +{{define "profile_edit"}} + + + + {{.Title}} + + + + {{template "navbar" .}} +
+
+

Edit Profile

+
+
+
+ +
+ +
+ +
+ +
+
+
+ {{template "cookie_banner" .}} + + +{{end}} \ No newline at end of file diff --git a/templates/pages/signup.html b/templates/pages/signup.html new file mode 100644 index 0000000..480ba05 --- /dev/null +++ b/templates/pages/signup.html @@ -0,0 +1,30 @@ +{{define "signup"}} + + + + {{.Title}} + + + + {{template "navbar" .}} +
+
+

Sign Up

+
+
+ {{if .Error}} +

{{.Error}}

+ {{end}} +
+ +
+ +
+ +
+
+
+ {{template "cookie_banner" .}} + + +{{end}} \ No newline at end of file diff --git a/templates/pages/thread.html b/templates/pages/thread.html new file mode 100644 index 0000000..4ae367b --- /dev/null +++ b/templates/pages/thread.html @@ -0,0 +1,60 @@ +{{define "thread"}} + + + + {{.Title}} + + + + {{template "navbar" .}} +
+
+

{{.Thread.Title}}

+ {{if eq .Thread.Type "question"}} + {{if .Thread.AcceptedAnswerPostID}} +

Accepted Answer: Post {{.Thread.AcceptedAnswerPostID}}

+ {{end}} + {{end}} +
+
+ {{range .Posts}} +
+
+

{{.Title}}

+

Posted by User {{.UserID}} on {{.PostTime}}

+ {{if gt .ReplyTo 0}} +

Reply to post {{.ReplyTo}}

+ {{end}} +
+

{{.Content}}

+ {{if $.LoggedIn}} +
+ + + +
+
+ + + +
+ Reply + {{end}} +
+ {{end}} +
+ {{if .LoggedIn}} +
+

Post a Message

+
+ +
+ +
+
+ {{end}} +
+ {{template "cookie_banner" .}} + + +{{end}} \ No newline at end of file diff --git a/templates/pages/userhome.html b/templates/pages/userhome.html new file mode 100644 index 0000000..541e431 --- /dev/null +++ b/templates/pages/userhome.html @@ -0,0 +1,21 @@ +{{define "userhome"}} + + + + {{.Title}} + + + + {{template "navbar" .}} +
+
+

Welcome, {{.Username}}

+
+
+

This is your user home page.

+
+
+ {{template "cookie_banner" .}} + + +{{end}} \ No newline at end of file diff --git a/templates/partials/cookie_banner.html b/templates/partials/cookie_banner.html new file mode 100644 index 0000000..b638ba9 --- /dev/null +++ b/templates/partials/cookie_banner.html @@ -0,0 +1,7 @@ +{{define "cookie_banner"}} +{{if .ShowCookieBanner}} + +{{end}} +{{end}} \ No newline at end of file diff --git a/templates/partials/navbar.html b/templates/partials/navbar.html new file mode 100644 index 0000000..0f6af1d --- /dev/null +++ b/templates/partials/navbar.html @@ -0,0 +1,17 @@ +{{define "navbar"}} + +
+{{end}} \ No newline at end of file