Initial Commit

master
Joca 2025-03-07 12:44:13 -03:00
commit d2c73a5a13
50 changed files with 2837 additions and 0 deletions

136
README.md Normal file
View File

@ -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 doesnt 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 thats 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, thats 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 <user>@<threadr.ip|no public access set up currently>
cd /var/www/git
sudo -u www-data -s
rm -rf ./web-deployment
git clone <ssh git repository link>
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
"<MACRO>":"<text>" → direct replacement
"<MACRO>":["file","<file path>"] → insert file
"<MACRO>":["exec","<command>"] → 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 havent 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.

25
config/about.template Normal file
View File

@ -0,0 +1,25 @@
<p>
Hello there! This is the official ThreadR instance provided by the ThreadR development team.
</p>
<h2>
What is ThreadR?
</h2>
<p>
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.
</p>
<p>
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&apos;t necessarily have a a reason to run our own forum anymore,
we shifted the project goal to building a FOSS forum engine.
</p>
<h2>
Who are we?
</h2>
<p>
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: <a href="https://discord.gg/r3w3zSkEUE"> discord.gg/r3w3zSkEUE </a>
</p>

13
go.mod Normal file
View File

@ -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
)

10
go.sum Normal file
View File

@ -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=

45
handlers/about.go Normal file
View File

@ -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
}
}
}

22
handlers/accept_cookie.go Normal file
View File

@ -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)
}
}

70
handlers/app.go Normal file
View File

@ -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)
}
}

127
handlers/board.go Normal file
View File

@ -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
}
}
}

67
handlers/boards.go Normal file
View File

@ -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
}
}
}

34
handlers/home.go Normal file
View File

@ -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
}
}
}

78
handlers/like.go Normal file
View File

@ -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"))
}
}

62
handlers/login.go Normal file
View File

@ -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
}
}
}

21
handlers/logout.go Normal file
View File

@ -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)
}
}

38
handlers/news.go Normal file
View File

@ -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
}
}
}

55
handlers/profile.go Normal file
View File

@ -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
}
}
}

65
handlers/profile_edit.go Normal file
View File

@ -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
}
}
}

62
handlers/signup.go Normal file
View File

@ -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
}
}
}

140
handlers/thread.go Normal file
View File

@ -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
}
}
}

49
handlers/userhome.go Normal file
View File

@ -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
}
}
}

100
main.go Normal file
View File

@ -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))
}

65
models/board.go Normal file
View File

@ -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
}

View File

@ -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
}

62
models/like.go Normal file
View File

@ -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
}

49
models/notification.go Normal file
View File

@ -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(&notification.ID, &notification.UserID, &notification.Type, &notification.RelatedID, &notification.Read, &notification.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
}

45
models/post.go Normal file
View File

@ -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
}

44
models/reaction.go Normal file
View File

@ -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
}

41
models/repost.go Normal file
View File

@ -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
}

28
models/sla.html Normal file
View File

@ -0,0 +1,28 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Document</title>
</head>
<body>
<h1>Lista de frutas</h1>
<div id="array"></div>
<div id="pessoa"></div>
<script type="text/javascript">
let frutas = [];
frutas.push("Maçã");
frutas.push("Laranja");
frutas.push("Limão");
document.getElementById("array").innerHTML = `Frutas = ${frutas}`;
let pessoa = [];
pessoa = {
nome: "Joaquim",
idade: 17,
salario: 14400
}
document.getElementById("pessoa").innerHTML = `${pessoa["nome"]}`;
</script>
</body>
</html>

57
models/thread.go Normal file
View File

@ -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
}

89
models/user.go Normal file
View File

@ -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
}

BIN
static/img/ThreadR.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

272
static/img/ThreadR.svg Normal file
View File

@ -0,0 +1,272 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="261.82941mm"
height="95.154205mm"
viewBox="0 0 261.82941 95.154203"
version="1.1"
id="svg8"
inkscape:version="0.92.3 (2405546, 2018-03-11)"
sodipodi:docname="ThreadR.svg">
<defs
id="defs2">
<linearGradient
inkscape:collect="always"
id="linearGradient1092">
<stop
style="stop-color:#a9dfff;stop-opacity:1;"
offset="0"
id="stop1088" />
<stop
style="stop-color:#a9dfff;stop-opacity:0;"
offset="1"
id="stop1090" />
</linearGradient>
<linearGradient
inkscape:collect="always"
id="linearGradient1060">
<stop
style="stop-color:#766fff;stop-opacity:1;"
offset="0"
id="stop1056" />
<stop
style="stop-color:#766fff;stop-opacity:0;"
offset="1"
id="stop1058" />
</linearGradient>
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient1060"
id="linearGradient1062"
x1="30.859402"
y1="95.286171"
x2="146.35486"
y2="95.286171"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(2.2724332,0,0,0.98143486,-39.444389,5.9391184)" />
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient1092"
id="linearGradient1094"
x1="-144.12688"
y1="82.6875"
x2="-39.544453"
y2="82.6875"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(2.5099298,0,0,0.96280136,68.591637,19.844646)" />
</defs>
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="0.70710678"
inkscape:cx="84.893034"
inkscape:cy="106.28905"
inkscape:document-units="mm"
inkscape:current-layer="layer4"
showgrid="false"
inkscape:measure-start="0,0"
inkscape:measure-end="0,0"
inkscape:window-width="1600"
inkscape:window-height="847"
inkscape:window-x="0"
inkscape:window-y="1"
inkscape:window-maximized="1" />
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:groupmode="layer"
id="layer5"
inkscape:label="background"
transform="translate(-30.990202,-96.114479)">
<rect
style="display:inline;opacity:1;fill:#f9f9f9;fill-opacity:1;stroke:none;stroke-width:0.78851396;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="rect1103"
width="261.82941"
height="95.154205"
x="30.990202"
y="96.114479" />
</g>
<g
inkscape:groupmode="layer"
id="layer3"
inkscape:label="window"
style="display:inline"
transform="translate(-30.990202,-96.114479)">
<rect
style="fill:#e6e6e6;stroke:none;stroke-width:0.47835484;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="rect1021"
width="1.0631751"
height="88.470581"
x="30.999176"
y="102.7981" />
<path
style="fill:#e6e6e6;stroke:none;stroke-width:0.48693055;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 291.71793,102.7981 v 88.47058 h 1.10168 V 102.7981 Z"
id="rect1021-9"
inkscape:connector-curvature="0" />
<rect
style="fill:#e6e6e6;stroke:none;stroke-width:0.53601629;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="rect909-6-2-9-2"
width="259.65558"
height="5.0737643"
x="32.062351"
y="186.19492"
ry="0" />
<rect
style="opacity:1;fill:url(#linearGradient1094);fill-opacity:1;stroke:none;stroke-width:0.5291667;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="rect1023-6"
width="261.82043"
height="6.6836219"
x="-292.81961"
y="96.114479"
transform="scale(-1,1)" />
<rect
style="display:inline;opacity:1;fill:url(#linearGradient1062);fill-opacity:1;stroke:none;stroke-width:0.53430772;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="rect1023"
width="261.82043"
height="6.6836276"
x="30.999176"
y="96.114479" />
</g>
<g
inkscape:label="contents"
inkscape:groupmode="layer"
id="layer1"
style="display:inline"
transform="translate(-30.990202,-96.114479)">
<path
style="fill:#e6e6e6;stroke:none;stroke-width:0.46272928;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 33.365806,121.08017 4.989638,-12.31832 c 1.088722,-2.58288 1.758941,-2.58288 5.171142,-2.58288 H 111.3019 c 3.41221,0 4.08244,0 5.17115,2.58288 l 4.98963,12.31833 z"
id="path817"
inkscape:connector-curvature="0"
sodipodi:nodetypes="ccccccc" />
<rect
style="fill:%COLOR_CYAN%;fill-opacity:1;stroke:none;stroke-width:0.46806985;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="rect909"
width="255.74254"
height="15.640329"
x="32.062351"
y="121.10081"
ry="0" />
<rect
style="fill:%COLOR_ORANGE%;fill-opacity:1;stroke:none;stroke-width:0.41052076;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="rect909-5"
width="255.74254"
height="9.9996996"
x="32.062351"
y="136.74113"
ry="0" />
<rect
style="fill:%COLOR_PINK%;fill-opacity:1;stroke:none;stroke-width:0.5291667;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="rect909-6-2-9"
width="255.74255"
height="10.471648"
x="32.062351"
y="175.72327"
ry="0" />
<rect
style="fill:%COLOR_BEIGE%;fill-opacity:1;stroke:none;stroke-width:0.52205408;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="rect909-6-2-1"
width="255.74254"
height="28.982441"
x="32.062351"
y="146.74083"
ry="0" />
<rect
style="fill:#ececec;stroke:none;stroke-width:0.48659384;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="rect983"
width="3.9130244"
height="65.094109"
x="287.8049"
y="121.10081" />
<rect
style="fill:#b3b3b3;stroke:none;stroke-width:0.5291667;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="rect983-7"
width="2.182373"
height="45.629673"
x="288.67023"
y="127.22635"
ry="1.1820796"
rx="1.0911865" />
<rect
style="fill:#b3b3b3;stroke:none;stroke-width:0.5291667;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="rect1002"
width="0.46817124"
height="0.48868293"
x="144.64522"
y="120.98614"
rx="1.5645972"
ry="0" />
<rect
style="fill:#b3b3b3;stroke:none;stroke-width:0.5291667;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="rect1002-0"
width="0.46817127"
height="0.48868293"
x="141.20036"
y="120.98614"
rx="1.5645972"
ry="0" />
</g>
<g
inkscape:groupmode="layer"
id="layer4"
inkscape:label="ThreadR"
transform="translate(4.9926758e-8,-4.1053391)">
<g
aria-label="ThreadR"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:98.77777863px;line-height:1.25;font-family:Playball;-inkscape-font-specification:Playball;letter-spacing:0px;word-spacing:0px;fill:%COLOR_BLUE%;fill-opacity:1;stroke:none;stroke-width:0.26458332"
id="text840">
<path
d="M 66.388955,26.034411 52.461289,25.441745 q -0.987778,0 -6.519334,11.853333 -5.531555,11.754556 -9.581444,22.817667 -4.049889,11.063111 -4.049889,16.002 0,4.840111 3.062111,4.840111 3.160889,0 7.902222,-6.420555 0.493889,-0.592667 0.987778,-0.592667 0.493889,0 0.493889,0.889 0,0.889 -1.382889,3.259667 -1.284111,2.370666 -3.457222,5.136444 -2.173111,2.667 -5.630334,4.741334 -3.457222,2.173111 -7.112,2.173111 -5.432777,0 -5.432777,-7.507111 0,-9.285112 9.383888,-31.60889 6.716889,-15.705666 12.347223,-26.077333 H 42.1884 L 32.014288,24.7503 q -8.494888,0 -14.816666,2.173111 -3.457223,1.185334 -5.531556,3.556 -1.9755555,2.271889 -1.9755555,5.531556 l 0.7902225,4.148667 q 0,0.987778 -0.987778,0.987778 -1.2841112,0 -2.0743334,-2.469445 -0.7902222,-2.568222 -0.7902222,-4.445 0,-4.642556 2.1731111,-7.902222 2.1731115,-3.259667 5.9266665,-4.938889 7.112,-3.160889 15.705667,-3.160889 8.593667,0 19.459222,1.086555 10.865556,0.987778 19.755556,0.987778 8.89,0 11.557,-2.765778 0.296334,0.691445 0.296334,1.382889 0,3.259667 -4.148667,5.235223 -4.148667,1.876777 -10.964334,1.876777 z"
style="fill:%COLOR_BLUE%;fill-opacity:1;stroke-width:0.26458332"
id="path841" />
<path
d="m 83.240932,73.360961 q -8.938683,15.043151 -15.115823,15.043151 -4.287661,0 -4.287661,-4.941711 0,-5.741106 4.57835,-16.351251 1.017412,-2.688873 1.017412,-3.706284 0,-1.453444 -1.308101,-1.453444 -0.872066,0 -2.979561,1.017411 -2.034822,1.017411 -3.9243,2.979561 -1.889478,1.96215 -3.197578,4.57835 -1.235428,2.543528 -2.034822,4.433006 -0.726723,1.889478 -1.526117,4.651023 -0.799394,2.688872 -1.453445,4.433005 -0.581377,1.671462 -1.235427,3.924301 h -7.049206 q 1.96215,-5.014384 5.595761,-15.98789 3.706284,-11.046178 6.322484,-17.29599 2.6162,-6.322484 5.087056,-10.610145 2.543528,-4.360333 5.741105,-7.267222 3.270251,-2.906889 6.83119,-2.906889 3.633611,0 3.633611,3.415594 0,2.398184 -1.96215,6.322484 -4.214989,8.284634 -14.098412,19.185467 3.9243,-2.834216 6.177139,-3.706283 2.325511,-0.872067 4.433006,-0.872067 4.142317,0 4.142317,3.633611 0,1.453445 -0.508706,2.688873 -0.508705,1.235428 -1.162755,2.834217 -0.581378,1.598789 -1.017411,2.6162 -0.363362,1.017411 -1.017412,2.761544 -0.65405,1.744134 -1.017411,2.979562 -0.944739,3.052233 -0.944739,4.869039 0,1.816805 1.235428,1.816805 4.360334,0 10.900834,-12.644967 0.218017,-0.508705 0.508706,-0.508705 0.363361,0 0.363361,1.380772 0,1.3081 -0.726723,2.688872 z M 74.447593,35.716748 q -2.543528,0 -6.685845,8.066617 -4.069644,8.066617 -7.993944,18.531418 9.956094,-11.700229 13.589706,-19.185468 2.034822,-4.57835 2.034822,-5.959122 0,-1.453445 -0.944739,-1.453445 z"
style="font-size:72.67222595px;fill:%COLOR_BLUE%;fill-opacity:1"
id="path843" />
<path
d="m 100.06455,58.753844 3.70629,0.07267 h 0.72672 q -1.09009,0.799394 -3.12491,4.723695 -1.962148,3.9243 -2.979559,5.959122 -0.944739,1.96215 -2.180167,5.377745 -1.162756,3.342922 -1.162756,5.450417 0,2.107494 1.453445,2.107494 0.944739,-0.07267 2.6162,-1.3081 1.744137,-1.3081 2.834217,-2.688872 1.16276,-1.453445 2.47086,-3.633611 1.38077,-2.252839 2.32551,-3.851628 1.01741,-1.671461 1.16275,-1.671461 0.50871,0 0.50871,1.380772 0,1.3081 -0.72672,2.543528 -3.12491,5.159728 -5.15973,7.921272 -2.03482,2.761545 -4.941714,5.087056 -2.834217,2.252839 -5.159728,2.252839 -4.142317,0 -4.142317,-6.104467 0,-4.142317 1.598789,-9.374717 1.598789,-5.2324 4.723695,-10.101439 -2.543528,2.034822 -5.087056,2.034822 -0.799394,0 -1.671461,-0.363361 -5.159728,10.174111 -5.595762,10.174111 -0.363361,0 -0.363361,-1.162755 0,-1.235428 0.872067,-3.342923 0.944739,-2.180166 1.96215,-4.142316 1.090084,-1.96215 1.162756,-2.180167 -2.6162,-1.3081 -2.6162,-4.214989 0,-1.453445 0.872066,-2.6162 0.944739,-1.162756 2.543528,-1.162756 1.598789,0 2.6162,1.017411 -0.145344,0.436033 -0.145344,1.3081 0,1.96215 1.380772,3.124906 1.453445,1.090083 3.488267,1.090083 0.581378,0 0.872067,-0.07267 2.543528,-3.633611 5.159726,-3.633611 z"
style="font-size:72.67222595px;fill:%COLOR_BLUE%;fill-opacity:1"
id="path845" />
<path
d="m 134.07062,69.0733 q 0.43603,0 0.43603,1.453444 0,1.380773 -1.16276,3.342923 -1.16275,1.96215 -2.32551,3.706283 -1.09008,1.744134 -2.97956,4.069645 -1.88948,2.325511 -3.70628,3.706283 -4.57835,3.342923 -9.22938,3.342923 -4.57835,0 -7.41256,-2.325511 -2.83422,-2.398184 -2.83422,-7.267223 0,-7.412567 5.45042,-13.735051 5.45041,-6.322483 12.71764,-6.322483 3.27025,0 5.2324,1.744133 1.96215,1.671461 1.96215,4.069645 0,4.069644 -3.85163,6.5405 -3.85163,2.470856 -9.08403,2.470856 -3.05223,0 -4.50568,-0.799395 -1.38077,3.706284 -1.38077,6.540501 0,6.104467 5.15973,6.104467 3.05223,0 6.24981,-2.325512 3.27025,-2.398183 5.52309,-5.305072 2.32551,-2.979561 5.2324,-8.502651 0.29069,-0.508705 0.50871,-0.508705 z m -7.70326,-5.741106 q 0,-2.107495 -2.03482,-2.107495 -2.83422,0 -6.32249,3.488267 -3.48826,3.488267 -3.48826,5.668434 0,1.453444 2.76154,1.453444 2.76155,0 5.74111,-2.688872 3.05223,-2.688872 3.34292,-5.813778 z"
style="font-size:72.67222595px;fill:%COLOR_BLUE%;fill-opacity:1"
id="path847" />
<path
d="m 160.69931,58.899188 3.63361,0.07267 h 0.72672 q -2.39818,1.962151 -5.88645,9.956095 -3.48827,7.921273 -3.48827,11.554884 0,1.889478 1.3081,1.889478 4.21499,0 10.75549,-12.42695 0.29069,-0.508706 0.50871,-0.508706 0.43603,0 0.43603,1.380772 0,1.3081 -0.72672,2.543528 -8.93869,15.043151 -14.97048,15.043151 -3.9243,0 -3.9243,-6.031795 0,-4.505678 1.52612,-8.866011 -8.28464,14.679789 -15.33384,14.679789 -4.86904,0 -4.86904,-6.685844 0,-5.087056 2.18016,-10.319457 2.25284,-5.2324 6.39516,-8.866011 4.14232,-3.706284 9.08403,-3.706284 2.32551,0 4.36033,1.090084 2.03482,1.017411 2.68887,3.342922 0.7994,-1.671461 2.47086,-2.906889 1.67146,-1.235428 3.12491,-1.235428 z m -6.0318,4.796367 q -0.72672,-2.325511 -3.41559,-2.325511 -4.79637,0 -9.30205,6.685845 -4.50568,6.613172 -4.50568,11.845573 0,2.252839 1.74414,2.252839 1.88947,0 4.433,-2.325512 2.54353,-2.398183 4.57835,-5.595761 4.50568,-7.121878 6.46783,-10.537473 z"
style="font-size:72.67222595px;fill:%COLOR_BLUE%;fill-opacity:1"
id="path849" />
<path
d="m 206.38516,40.440443 q -2.39819,1.744133 -6.32249,9.81075 -3.85162,7.993945 -6.83119,16.932629 -2.97956,8.866012 -2.97956,12.862984 0,2.325511 1.23543,2.325511 1.23543,0 3.05223,-1.598789 1.88948,-1.598789 2.68888,-2.688872 0.79939,-1.090084 2.10749,-3.27025 1.38077,-2.252839 2.32551,-3.851628 1.01741,-1.671461 1.16276,-1.671461 0.5087,0 0.5087,1.380772 -0.0727,2.543528 -6.03179,10.610145 -1.96215,2.6162 -4.7237,4.869039 -2.76154,2.252839 -5.01438,2.252839 -3.85163,0 -3.85163,-5.741106 0,-4.214989 2.1075,-10.828162 -3.34293,6.613173 -7.63059,11.482212 -4.28766,4.869039 -8.57532,4.869039 -4.86904,0 -4.86904,-6.685844 0,-5.087056 2.18017,-10.319457 2.25284,-5.2324 6.39515,-8.866011 4.14232,-3.706284 8.93869,-3.706284 4.86904,0 6.68584,3.197578 2.03482,-3.996972 4.14232,-9.229372 2.18017,-5.232401 2.83422,-6.758517 0.72672,-1.598789 1.01741,-2.325512 0.36336,-0.726722 0.87206,-1.380772 0.7994,-1.017411 1.88948,-1.235428 1.16276,-0.290689 3.63361,-0.290689 2.54353,-0.07267 3.05224,-0.145344 z m -17.8047,22.237701 q -0.87207,-1.3081 -2.90689,-1.3081 -3.27025,0 -6.61317,3.342922 -3.27025,3.27025 -5.30507,7.630584 -1.96215,4.287661 -1.96215,7.557912 0,2.252839 1.74413,2.252839 5.88645,0 15.04315,-19.476157 z"
style="font-size:72.67222595px;fill:%COLOR_BLUE%;fill-opacity:1"
id="path851" />
<path
d="m 250.30984,40.077082 q 3.41559,2.761544 3.41559,7.049206 0,4.287661 -2.54352,7.557911 -4.50568,5.959123 -13.8804,7.703256 l -2.32551,0.436033 q 3.63361,12.644968 15.26117,24.999246 -1.09009,0.218017 -3.34292,0.218017 l -5.59577,-0.218017 q -2.90688,0 -6.17714,-5.668433 -3.19757,-5.741106 -5.08705,-11.627557 -1.88948,-5.959122 -1.88948,-7.921272 0,-1.090084 1.01741,-1.162756 17.07798,-2.107494 17.58668,-13.371689 0,-3.996973 -2.39818,-6.17714 -2.32551,-2.180166 -7.70326,-2.470855 -5.66843,7.630583 -12.57229,24.417868 -6.83119,17.077973 -6.83119,22.52839 0,1.090083 0.29069,1.598789 h -9.37472 q 2.03482,-8.79334 8.57532,-24.417868 8.72067,-20.784257 11.2642,-23.618474 -6.17714,0.145345 -12.5723,2.543528 -6.32248,2.325511 -7.33989,5.595762 -0.0727,0.218016 -0.29069,0.218016 -0.65405,0 -0.65405,-0.944739 0,-0.145344 0.14534,-0.726722 1.38077,-3.996972 3.99697,-6.177139 2.6162,-2.180167 5.08706,-2.688873 2.54353,-0.508705 6.24981,-0.508705 h 17.65935 q 6.68585,0 10.02877,2.834217 z"
style="font-size:72.67222595px;fill:%COLOR_BLUE%;fill-opacity:1"
id="path853" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 17 KiB

249
static/img/ThreadR_Home.svg Normal file
View File

@ -0,0 +1,249 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="97.333481mm"
height="95.154205mm"
viewBox="0 0 97.333492 95.154203"
version="1.1"
id="svg8"
inkscape:version="0.92.3 (2405546, 2018-03-11)"
sodipodi:docname="ThreadR_Home.svg">
<defs
id="defs2">
<linearGradient
inkscape:collect="always"
id="linearGradient1092">
<stop
style="stop-color:#a9dfff;stop-opacity:1;"
offset="0"
id="stop1088" />
<stop
style="stop-color:#a9dfff;stop-opacity:0;"
offset="1"
id="stop1090" />
</linearGradient>
<linearGradient
inkscape:collect="always"
id="linearGradient1060">
<stop
style="stop-color:#766fff;stop-opacity:1;"
offset="0"
id="stop1056" />
<stop
style="stop-color:#766fff;stop-opacity:0;"
offset="1"
id="stop1058" />
</linearGradient>
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient1060"
id="linearGradient1062"
x1="30.859402"
y1="95.286171"
x2="146.35486"
y2="95.286171"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(0.84479197,0,0,0.98143437,4.8113177,5.9391634)" />
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient1092"
id="linearGradient1094"
x1="-144.12688"
y1="82.6875"
x2="-39.544453"
y2="82.6875"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(0.93308299,0,0,0.96280088,6.0243672,19.844684)" />
</defs>
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="0.5"
inkscape:cx="207.39258"
inkscape:cy="-260.45274"
inkscape:document-units="mm"
inkscape:current-layer="layer3"
showgrid="false"
inkscape:measure-start="0,0"
inkscape:measure-end="0,0"
inkscape:window-width="1680"
inkscape:window-height="997"
inkscape:window-x="0"
inkscape:window-y="1"
inkscape:window-maximized="1" />
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:groupmode="layer"
id="layer5"
inkscape:label="background"
transform="translate(-30.999176,-96.114479)">
<rect
style="display:inline;opacity:1;fill:#f9f9f9;fill-opacity:1;stroke:none;stroke-width:0.48078543;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="rect1103"
width="97.342438"
height="95.154205"
x="30.990202"
y="96.114479" />
</g>
<g
inkscape:groupmode="layer"
id="layer3"
inkscape:label="window"
style="display:inline"
transform="translate(-30.999176,-96.114479)">
<rect
style="fill:#e6e6e6;stroke:none;stroke-width:0.47835484;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="rect1021"
width="1.0631751"
height="88.470581"
x="30.999176"
y="102.7981" />
<path
style="fill:#e6e6e6;stroke:none;stroke-width:0.48693055;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 127.23097,102.7981 v 88.47058 h 1.10168 V 102.7981 Z"
id="rect1021-9"
inkscape:connector-curvature="0" />
<rect
style="fill:#e6e6e6;stroke:none;stroke-width:0.32450849;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="rect909-6-2-9-2"
width="95.168625"
height="5.0737643"
x="32.062351"
y="186.19492"
ry="0" />
<rect
style="opacity:1;fill:url(#linearGradient1094);fill-opacity:1;stroke:none;stroke-width:0.32264262;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="rect1023-6"
width="97.333473"
height="6.6836185"
x="-128.33266"
y="96.114479"
transform="scale(-1,1)" />
<rect
style="display:inline;opacity:1;fill:url(#linearGradient1062);fill-opacity:1;stroke:none;stroke-width:0.32577717;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="rect1023"
width="97.333466"
height="6.6836243"
x="30.999176"
y="96.114479" />
</g>
<g
inkscape:label="contents"
inkscape:groupmode="layer"
id="layer1"
style="display:inline"
transform="translate(-30.999176,-96.114479)">
<path
style="fill:#e6e6e6;stroke:none;stroke-width:0.46272928;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 33.365806,121.08017 4.989638,-12.31832 c 1.088722,-2.58288 1.758941,-2.58288 5.171142,-2.58288 H 111.3019 c 3.41221,0 4.08244,0 5.17115,2.58288 l 4.98963,12.31833 z"
id="path817"
inkscape:connector-curvature="0"
sodipodi:nodetypes="ccccccc" />
<rect
style="fill:%COLOR_CYAN%;fill-opacity:1;stroke:none;stroke-width:0.27960116;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="rect909"
width="91.2556"
height="15.640329"
x="32.062351"
y="121.10081"
ry="0" />
<rect
style="fill:%COLOR_ORANGE%;fill-opacity:1;stroke:none;stroke-width:0.24522424;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="rect909-5"
width="91.255592"
height="9.9996996"
x="32.062351"
y="136.74113"
ry="0" />
<rect
style="fill:%COLOR_PINK%;fill-opacity:1;stroke:none;stroke-width:0.31609729;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="rect909-6-2-9"
width="91.2556"
height="10.471648"
x="32.062351"
y="175.72327"
ry="0" />
<rect
style="fill:%COLOR_BEIGE%;fill-opacity:1;stroke:none;stroke-width:0.31184855;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="rect909-6-2-1"
width="91.255592"
height="28.982441"
x="32.062351"
y="146.74083"
ry="0" />
<rect
style="fill:#ececec;stroke:none;stroke-width:0.48659384;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="rect983"
width="3.9130244"
height="65.094109"
x="123.31795"
y="121.10081" />
<rect
style="fill:#b3b3b3;stroke:none;stroke-width:0.5291667;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="rect983-7"
width="2.182373"
height="45.629673"
x="124.17254"
y="125.91673"
ry="1.1820796"
rx="1.0911865" />
<rect
style="fill:#b3b3b3;stroke:none;stroke-width:0.5291667;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="rect1002"
width="0.46817124"
height="0.48868293"
x="144.64522"
y="120.98614"
rx="1.5645972"
ry="0" />
<rect
style="fill:#b3b3b3;stroke:none;stroke-width:0.5291667;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="rect1002-0"
width="0.46817127"
height="0.48868293"
x="141.20036"
y="120.98614"
rx="1.5645972"
ry="0" />
</g>
<g
inkscape:groupmode="layer"
id="layer4"
inkscape:label="ThreadR"
transform="translate(-0.00897398,-4.1053392)">
<g
aria-label="ThreadR"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:98.77777863px;line-height:1.25;font-family:Playball;-inkscape-font-specification:Playball;letter-spacing:0px;word-spacing:0px;fill:%COLOR_BLUE%;fill-opacity:1;stroke:none;stroke-width:0.26458332"
id="text840">
<path
d="M 66.388955,26.034411 52.461289,25.441745 q -0.987778,0 -6.519334,11.853333 -5.531555,11.754556 -9.581444,22.817667 -4.049889,11.063111 -4.049889,16.002 0,4.840111 3.062111,4.840111 3.160889,0 7.902222,-6.420555 0.493889,-0.592667 0.987778,-0.592667 0.493889,0 0.493889,0.889 0,0.889 -1.382889,3.259667 -1.284111,2.370666 -3.457222,5.136444 -2.173111,2.667 -5.630334,4.741334 -3.457222,2.173111 -7.112,2.173111 -5.432777,0 -5.432777,-7.507111 0,-9.285112 9.383888,-31.60889 6.716889,-15.705666 12.347223,-26.077333 H 42.1884 L 32.014288,24.7503 q -8.494888,0 -14.816666,2.173111 -3.457223,1.185334 -5.531556,3.556 -1.9755555,2.271889 -1.9755555,5.531556 l 0.7902225,4.148667 q 0,0.987778 -0.987778,0.987778 -1.2841112,0 -2.0743334,-2.469445 -0.7902222,-2.568222 -0.7902222,-4.445 0,-4.642556 2.1731111,-7.902222 2.1731115,-3.259667 5.9266665,-4.938889 7.112,-3.160889 15.705667,-3.160889 8.593667,0 19.459222,1.086555 10.865556,0.987778 19.755556,0.987778 8.89,0 11.557,-2.765778 0.296334,0.691445 0.296334,1.382889 0,3.259667 -4.148667,5.235223 -4.148667,1.876777 -10.964334,1.876777 z"
style="fill:%COLOR_BLUE%;fill-opacity:1;stroke-width:0.26458332"
id="path841"
inkscape:connector-curvature="0" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 752 B

BIN
static/img/threadR.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

186
static/style.css Normal file
View File

@ -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;
}
}

16
templates/base.html Normal file
View File

@ -0,0 +1,16 @@
{{define "base"}}
<!DOCTYPE html>
<html>
<head>
<title>{{.Title}}</title>
<link rel="stylesheet" href="{{.StaticPath}}/style.css">
</head>
<body>
{{template "navbar" .}}
<main>
{{block "content" .}}{{end}} <!-- Define a block for content -->
</main>
{{template "cookie_banner" .}}
</body>
</html>
{{end}}

View File

@ -0,0 +1,21 @@
{{define "about"}}
<!DOCTYPE html>
<html>
<head>
<title>{{.Title}}</title>
<link rel="stylesheet" href="{{.StaticPath}}/style.css">
</head>
<body>
{{template "navbar" .}}
<main>
<header>
<h2>About ThreadR</h2>
</header>
<section>
{{.AboutContent}}
</section>
</main>
{{template "cookie_banner" .}}
</body>
</html>
{{end}}

View File

@ -0,0 +1,42 @@
{{define "board"}}
<!DOCTYPE html>
<html>
<head>
<title>{{.Title}}</title>
<link rel="stylesheet" href="{{.StaticPath}}/style.css">
</head>
<body>
{{template "navbar" .}}
<main>
<header>
<h2>{{.Board.Name}}</h2>
<p>{{.Board.Description}}</p>
</header>
<section>
<ul>
{{range .Threads}}
<li><a href="{{$.BasePath}}/thread/?id={{.ID}}">{{.Title}}</a> ({{.Type}})</li>
{{end}}
</ul>
</section>
{{if .LoggedIn}}
<section>
<h3>Create New Thread</h3>
<form method="post" action="{{.BasePath}}/board/?id={{.Board.ID}}&action=create_thread">
<label for="title">Thread Title:</label>
<input type="text" id="title" name="title" required><br>
<label for="type">Thread Type:</label>
<select id="type" name="type">
<option value="classic">Classic</option>
<option value="chat">Chat</option>
<option value="question">Question</option>
</select><br>
<input type="submit" value="Create Thread">
</form>
</section>
{{end}}
</main>
{{template "cookie_banner" .}}
</body>
</html>
{{end}}

View File

@ -0,0 +1,36 @@
{{define "boards"}}
<!DOCTYPE html>
<html>
<head>
<title>{{.Title}}</title>
<link rel="stylesheet" href="{{.StaticPath}}/style.css">
</head>
<body>
{{template "navbar" .}}
<main>
<header>
<h2>Boards</h2>
</header>
<section>
<h3>Public Boards</h3>
<ul>
{{range .PublicBoards}}
<li><a href="{{$.BasePath}}/board/?id={{.ID}}">{{.Name}}</a></li>
{{end}}
</ul>
</section>
{{if .LoggedIn}}
<section>
<h3>Private Boards</h3>
<ul>
{{range .PrivateBoards}}
<li><a href="{{$.BasePath}}/board/?id={{.ID}}">{{.Name}}</a></li>
{{end}}
</ul>
</section>
{{end}}
</main>
{{template "cookie_banner" .}}
</body>
</html>
{{end}}

21
templates/pages/home.html Normal file
View File

@ -0,0 +1,21 @@
{{define "home"}}
<!DOCTYPE html>
<html>
<head>
<title>{{.Title}}</title>
<link rel="stylesheet" href="{{.StaticPath}}/style.css">
</head>
<body>
{{template "navbar" .}}
<main>
<header>
<h1><center>Welcome to ThreadR</center></h1>
</header>
<section>
<img src="{{.StaticPath}}/img/ThreadR.png" alt="ThreadR" height="100%" width="100%">
</section>
</main>
{{template "cookie_banner" .}}
</body>
</html>
{{end}}

View File

@ -0,0 +1,30 @@
{{define "login"}}
<!DOCTYPE html>
<html>
<head>
<title>{{.Title}}</title>
<link rel="stylesheet" href="{{.StaticPath}}/style.css">
</head>
<body>
{{template "navbar" .}}
<main>
<header>
<h2>Login</h2>
</header>
<section>
{{if .Error}}
<p style="color: red;">{{.Error}}</p>
{{end}}
<form method="post" action="{{.BasePath}}/login/">
<label for="username">Username:</label>
<input type="text" id="username" name="username" required><br>
<label for="password">Password:</label>
<input type="password" id="password" name="password" required><br>
<input type="submit" value="Login">
</form>
</section>
</main>
{{template "cookie_banner" .}}
</body>
</html>
{{end}}

25
templates/pages/news.html Normal file
View File

@ -0,0 +1,25 @@
{{define "news"}}
<!DOCTYPE html>
<html>
<head>
<title>{{.Title}}</title>
<link rel="stylesheet" href="{{.StaticPath}}/style.css">
</head>
<body>
{{template "navbar" .}}
<main>
<header>
<h2>News</h2>
</header>
<section>
<ul>
{{range .News}}
<li>{{.}}</li>
{{end}}
</ul>
</section>
</main>
{{template "cookie_banner" .}}
</body>
</html>
{{end}}

View File

@ -0,0 +1,30 @@
{{define "profile"}}
<!DOCTYPE html>
<html>
<head>
<title>{{.Title}}</title>
<link rel="stylesheet" href="{{.StaticPath}}/style.css">
</head>
<body>
{{template "navbar" .}}
<main>
<header>
<h2>Profile</h2>
</header>
<section>
<p>Username: {{.User.Username}}</p>
<p>Display Name: {{.DisplayName}}</p>
{{if .User.PfpURL}}
<img src="{{.User.PfpURL}}" alt="Profile Picture">
{{end}}
<p>Bio: {{.User.Bio}}</p>
<p>Joined: {{.User.CreatedAt}}</p>
<p>Last Updated: {{.User.UpdatedAt}}</p>
<p>Verified: {{.User.Verified}}</p>
<a href="{{.BasePath}}/profile/edit/">Edit Profile</a>
</section>
</main>
{{template "cookie_banner" .}}
</body>
</html>
{{end}}

View File

@ -0,0 +1,29 @@
{{define "profile_edit"}}
<!DOCTYPE html>
<html>
<head>
<title>{{.Title}}</title>
<link rel="stylesheet" href="{{.StaticPath}}/style.css">
</head>
<body>
{{template "navbar" .}}
<main>
<header>
<h2>Edit Profile</h2>
</header>
<section>
<form method="post" action="{{.BasePath}}/profile/edit/">
<label for="display_name">Display Name:</label>
<input type="text" id="display_name" name="display_name" value="{{.User.DisplayName}}"><br>
<label for="pfp_url">Profile Picture URL:</label>
<input type="text" id="pfp_url" name="pfp_url" value="{{.User.PfpURL}}"><br>
<label for="bio">Bio:</label>
<textarea id="bio" name="bio">{{.User.Bio}}</textarea><br>
<input type="submit" value="Save">
</form>
</section>
</main>
{{template "cookie_banner" .}}
</body>
</html>
{{end}}

View File

@ -0,0 +1,30 @@
{{define "signup"}}
<!DOCTYPE html>
<html>
<head>
<title>{{.Title}}</title>
<link rel="stylesheet" href="{{.StaticPath}}/style.css">
</head>
<body>
{{template "navbar" .}}
<main>
<header>
<h2>Sign Up</h2>
</header>
<section>
{{if .Error}}
<p style="color: red;">{{.Error}}</p>
{{end}}
<form method="post" action="{{.BasePath}}/signup/">
<label for="username">Username:</label>
<input type="text" id="username" name="username" required><br>
<label for="password">Password:</label>
<input type="password" id="password" name="password" required><br>
<input type="submit" value="Sign Up">
</form>
</section>
</main>
{{template "cookie_banner" .}}
</body>
</html>
{{end}}

View File

@ -0,0 +1,60 @@
{{define "thread"}}
<!DOCTYPE html>
<html>
<head>
<title>{{.Title}}</title>
<link rel="stylesheet" href="{{.StaticPath}}/style.css">
</head>
<body>
{{template "navbar" .}}
<main>
<header>
<h2>{{.Thread.Title}}</h2>
{{if eq .Thread.Type "question"}}
{{if .Thread.AcceptedAnswerPostID}}
<p>Accepted Answer: <a href="#{{.Thread.AcceptedAnswerPostID}}">Post {{.Thread.AcceptedAnswerPostID}}</a></p>
{{end}}
{{end}}
</header>
<div>
{{range .Posts}}
<article id="{{.ID}}">
<header>
<h3>{{.Title}}</h3>
<p>Posted by User {{.UserID}} on {{.PostTime}}</p>
{{if gt .ReplyTo 0}}
<p>Reply to post {{.ReplyTo}}</p>
{{end}}
</header>
<p>{{.Content}}</p>
{{if $.LoggedIn}}
<form method="post" action="{{$.BasePath}}/like/" style="display:inline;">
<input type="hidden" name="post_id" value="{{.ID}}">
<input type="hidden" name="type" value="like">
<button type="submit">Like</button>
</form>
<form method="post" action="{{$.BasePath}}/like/" style="display:inline;">
<input type="hidden" name="post_id" value="{{.ID}}">
<input type="hidden" name="type" value="dislike">
<button type="submit">Dislike</button>
</form>
<a href="{{$.BasePath}}/thread/?id={{$.Thread.ID}}&action=submit&to={{.ID}}">Reply</a>
{{end}}
</article>
{{end}}
</div>
{{if .LoggedIn}}
<section>
<h3>Post a Message</h3>
<form method="post" action="{{.BasePath}}/thread/?id={{.Thread.ID}}&action=submit">
<label for="content">Content:</label>
<textarea id="content" name="content" required></textarea><br>
<input type="submit" value="Post">
</form>
</section>
{{end}}
</main>
{{template "cookie_banner" .}}
</body>
</html>
{{end}}

View File

@ -0,0 +1,21 @@
{{define "userhome"}}
<!DOCTYPE html>
<html>
<head>
<title>{{.Title}}</title>
<link rel="stylesheet" href="{{.StaticPath}}/style.css">
</head>
<body>
{{template "navbar" .}}
<main>
<header>
<h2>Welcome, {{.Username}}</h2>
</header>
<section>
<p>This is your user home page.</p>
</section>
</main>
{{template "cookie_banner" .}}
</body>
</html>
{{end}}

View File

@ -0,0 +1,7 @@
{{define "cookie_banner"}}
{{if .ShowCookieBanner}}
<div class="banner">
<p>We use cookies to enhance your experience. <a href="{{.BasePath}}/accept_cookie/?from={{.CurrentURL | urlquery}}">Accept</a></p>
</div>
{{end}}
{{end}}

View File

@ -0,0 +1,17 @@
{{define "navbar"}}
<ul class="topnav">
<li><a {{if eq .Navbar "home"}}class="active"{{end}} href="{{.BasePath}}/">Home</a></li>
{{if .LoggedIn}}
<li><a {{if eq .Navbar "userhome"}}class="active"{{end}} href="{{.BasePath}}/userhome/">User Home</a></li>
<li><a {{if eq .Navbar "profile"}}class="active"{{end}} href="{{.BasePath}}/profile/">Profile</a></li>
<li><a href="{{.BasePath}}/logout/">Logout</a></li>
{{else}}
<li><a {{if eq .Navbar "login"}}class="active"{{end}} href="{{.BasePath}}/login/">Login</a></li>
<li><a {{if eq .Navbar "signup"}}class="active"{{end}} href="{{.BasePath}}/signup/">Sign Up</a></li>
{{end}}
<li><a {{if eq .Navbar "boards"}}class="active"{{end}} href="{{.BasePath}}/boards/">Boards</a></li>
<li><a {{if eq .Navbar "news"}}class="active"{{end}} href="{{.BasePath}}/news/">News</a></li>
<li><a {{if eq .Navbar "about"}}class="active"{{end}} href="{{.BasePath}}/about/">About</a></li>
</ul>
<div class="topnav"></div>
{{end}}